Compare commits
325 Commits
v0.3.0
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
| 429de4001f | |||
| 76f8cc1675 | |||
| e061095110 | |||
| f4eb6645bc | |||
| 6014fa72cf | |||
| c164aadfc9 | |||
| a01796a4c3 | |||
| d98acc2764 | |||
| 35654ccb9a | |||
| d856f7e18d | |||
| eccfb76550 | |||
| 77392ce997 | |||
| a8576121cf | |||
| 76587e17a2 | |||
| 0e8ba30c7a | |||
| 2e71ebe16f | |||
| f06d03996a | |||
| 21c837aa02 | |||
| 0012088191 | |||
| 28e35bf23e | |||
| a94a72afa2 | |||
| 75607b4464 | |||
| 8563229bf4 | |||
| e87506ce6b | |||
| e843be21a4 | |||
| 9b34d6a82f | |||
| 42af7c4357 | |||
| 4b58dc32ab | |||
| 09cb78f1b8 | |||
| f002839c48 | |||
| cab50db1c1 | |||
| 802af222ee | |||
| 8262a4999e | |||
| b9cb3998f8 | |||
| 62bede5dae | |||
| 74f85ce4e9 | |||
| 124e097e23 | |||
| a5425969ed | |||
| fb16bc9ed1 | |||
| dd8a940431 | |||
| eaa6b8576d | |||
| c2dc382787 | |||
| 3671af2498 | |||
| e34bcedc31 | |||
| ff91ef3edf | |||
| ba3a3db98c | |||
| 6faad45a28 | |||
| c08884fa2c | |||
| 6164d7cae3 | |||
| dc6776cc8f | |||
| 0bfda08f47 | |||
| 485be22a16 | |||
| ead406bdac | |||
| 1472d77b52 | |||
| 6a0fea30a6 | |||
| 8c34388fc2 | |||
| aca43d51eb | |||
| 522e1c5e90 | |||
| d1d316f35c | |||
| 2af8412cdc | |||
| c93ad5e2db | |||
| 38799770db | |||
| 51b5d7b3ba | |||
| fd5fa28205 | |||
| 1244c08e42 | |||
| 021d709de2 | |||
| cac6fcd626 | |||
| 4954b19d7c | |||
| 01819642cc | |||
| cb28f7759d | |||
| 1178ab8b21 | |||
| 233e747d5d | |||
| 20a228eb8d | |||
| ef5f223863 | |||
| 31456fd169 | |||
| bbcde17d49 | |||
| f328113a2a | |||
| 1143acd37a | |||
| 0b53d34417 | |||
| 3deecbff89 | |||
| bb582deefa | |||
| 7ef9a64613 | |||
| f3572f7356 | |||
| 2e998a26a2 | |||
| f3ec626d58 | |||
| f4733e2f7a | |||
| cdacc12a48 | |||
| bd98610153 | |||
| 5e13879954 | |||
| 339cd7bee1 | |||
| 153a54fdcd | |||
| c3afd0c8a0 | |||
| 11928a172a | |||
| bb2146d838 | |||
| d270ab7752 | |||
| 7fd801c1eb | |||
| edec170652 | |||
| 684c98b0fa | |||
| ebbbde9cd1 | |||
| 6a6b489471 | |||
| dbc6030c17 | |||
| 9ee6e3e1a8 | |||
| aa96557d1c | |||
| 47be1143fb | |||
| 8f8638e6db | |||
| 003453fa3c | |||
| 92aab09104 | |||
| e7670e4449 | |||
| 955aa09780 | |||
| ce452c3d11 | |||
| ab034062d3 | |||
| 24f904fa74 | |||
| cc63ed2197 | |||
| 9c4fea93bc | |||
| 977c3a466a | |||
| e6e3975426 | |||
| bba5f80294 | |||
| 44c32587e3 | |||
| b9aa444472 | |||
| fb4b67adb3 | |||
| 9ea847923c | |||
| ed2222c80c | |||
| e91109d69c | |||
| 451d0819cc | |||
| 084c6caa7d | |||
| 43e838c55c | |||
| e1b23f3d79 | |||
| a3f75300a9 | |||
| e8a2217247 | |||
| 93e6fe8160 | |||
| f69fa8cded | |||
| bc8348b199 | |||
| 81715947bb | |||
| d705f83b68 | |||
| 2f0e7fcdc2 | |||
| 970ae2b6b2 | |||
| ded9c15f06 | |||
| 9d68eb5f00 | |||
| 700512b627 | |||
| d1975089f1 | |||
| a5ad3731f2 | |||
| 11d746dcfc | |||
| 5f877afb9e | |||
| 5b4fde055c | |||
| 59507d7553 | |||
| a265c15c4d | |||
| d332630267 | |||
| 587f6ed8ad | |||
| 82d281b5b5 | |||
| 17d5d07b3a | |||
| 71b87020c9 | |||
| 516890c49a | |||
| 962860ba74 | |||
| 117c46a386 | |||
| d828a6be08 | |||
| bbdb44afee | |||
| 784fe5c7a8 | |||
| 60c0de670c | |||
| 90bea52ccd | |||
| f920023ecf | |||
| 19ddb1ab8b | |||
| 5633957b51 | |||
| eb47e22496 | |||
| b1595baa19 | |||
| da06ecb97b | |||
| 0d5007089e | |||
| 0e7b198768 | |||
| 9ad704c12b | |||
| 0189d030bb | |||
| 7b853a05ba | |||
| 108b68c3fa | |||
| 565817273d | |||
| ea65fb584c | |||
| 25b5258841 | |||
| 46c43b95c2 | |||
| 000318f77e | |||
| f5eeb0ffcc | |||
| 75af2a4f66 | |||
| f6c3dc0801 | |||
| f5a9a1209f | |||
| 8d0356e372 | |||
| c3c9ced9dd | |||
| 7d0a21644f | |||
| 26917d432d | |||
| 430fb9e70e | |||
| 0807946d45 | |||
| 11ca6935a6 | |||
| e2f869bfc2 | |||
| 21b82c2283 | |||
| dbad418093 | |||
| 2bad843069 | |||
| 9332f4ac18 | |||
| cf191ca821 | |||
| d2202c4f49 | |||
| 1826af37a5 | |||
| acc721526d | |||
| 4b2ff279f7 | |||
| ab3fc4a807 | |||
| 09f030d202 | |||
| 43c15c86de | |||
| 851f54a9e1 | |||
| 4ed17b2776 | |||
| 0e6daaabe0 | |||
| 82531b735c | |||
| c9283cac70 | |||
| c1987b0bda | |||
| 2b31af8fde | |||
| 831c898b71 | |||
| 0f78982186 | |||
| 84dd15d22b | |||
| 5148b5dd64 | |||
| 37d85ec998 | |||
| e2beef817d | |||
| a9ca13b238 | |||
| e3ffa576ce | |||
| dd50cfdcb1 | |||
| 3675ecba65 | |||
| c8615466be | |||
| 2737288a2b | |||
| 0ea0e7cc70 | |||
| 11e08e5b96 | |||
| 4dc8cc3867 | |||
| 9852f96127 | |||
| bf05677bef | |||
| 4f340b8847 | |||
| f57fe85492 | |||
| 9a52b47786 | |||
| ab38c8372c | |||
| 8b5fa3438e | |||
| 2b4c5e0176 | |||
| 377e856c8f | |||
| 948db13bed | |||
| 25ce0729d5 | |||
| 88f919c4ca | |||
| 8f06fc554a | |||
| 1ca10230a9 | |||
| 46d60fc5a5 | |||
| 906cc42d13 | |||
| 75b28ed899 | |||
| 03e96629c7 | |||
| 34af77e0d8 | |||
| c89a664a37 | |||
| 3509ca90e8 | |||
| c8acac92cc | |||
| 5cb37bf2a0 | |||
| c0d6381bbe | |||
| 4ddd494160 | |||
| cdd89a7cee | |||
| 1ce62934b3 | |||
| 6898528096 | |||
| 12d1e356fa | |||
| 139f86bf9c | |||
| b0a8b498a8 | |||
| 4be65b0abb | |||
| f4675ebfc0 | |||
| 602ee470d9 | |||
| 8cdf91d895 | |||
| ed7880bc6c | |||
| 27ff828e60 | |||
| 6eacf0f735 | |||
| 88cb24bb86 | |||
| 49b0b646a8 | |||
| 9acdbb5ae9 | |||
| 7473c15f52 | |||
| 8d5ba3873a | |||
| b0e6f5ffb3 | |||
| a69b0f579c | |||
| 45793e181c | |||
| e3750f79f9 | |||
| 930da8a578 | |||
| 47b0ed5b52 | |||
| fee66bda50 | |||
| 2349f585ae | |||
| d4eb24be7e | |||
| 0414fbe733 | |||
| 6b76abe38e | |||
| 3ce8fdcb02 | |||
| 1be30034bd | |||
| 9878156798 | |||
| e069b3e679 | |||
| 9bf67aa7f7 | |||
| f968da9768 | |||
| 3932bc056e | |||
| aaeccd32ce | |||
| 915d934c26 | |||
| 336080bb6d | |||
| 90c296f5e1 | |||
| bf494f73fc | |||
| 71a7fdb76f | |||
| b3e8bdd171 | |||
| 1e681da738 | |||
| 8e5d426638 | |||
| 4e26d7a292 | |||
| 79eba782fb | |||
| b786a825d4 | |||
| 71564b202e | |||
| 5f0dbadea6 | |||
| 36782e1b4f | |||
| 97292ba0bf | |||
| 866f3ac012 | |||
| 2c66959b92 | |||
| e4ead076e3 | |||
| 1d59b50e49 | |||
| f38d5812d1 | |||
| 931b7d54b3 | |||
| 477d4f8cf6 | |||
| 88246fac2b | |||
| 1846168736 | |||
| c92cc29b88 | |||
| 712b77b01c | |||
| 80a99eec87 | |||
| a0d635ae5e | |||
| 63e9132d38 | |||
| 778894b488 | |||
| a1bd439c75 | |||
| 9b94df7f56 | |||
| 0c245e9e1c | |||
| b4d8edb65b | |||
| ec3360d9ad | |||
| d7b25b0891 | |||
| 34c89e814d | |||
| 6f0c365d44 | |||
| c31ce90306 | |||
| 7753938044 | |||
| a3898d68fb |
15
.env.example
15
.env.example
@ -17,21 +17,6 @@ WRENN_HOST_INTERFACE=eth0
|
||||
WRENN_CP_URL=http://localhost:9725
|
||||
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
|
||||
WRENN_CH_BIN=/usr/local/bin/cloud-hypervisor
|
||||
# Public domain sandboxes are served under; injected into envd so `envd ports`
|
||||
# can build {port}-{sandbox_id}.{domain} URLs.
|
||||
WRENN_PROXY_DOMAIN=wrenn.dev
|
||||
|
||||
# Inactivity activity sampler (all optional; shown values are the defaults).
|
||||
# The host polls each running sandbox's guest liveness and refreshes its
|
||||
# inactivity TTL when it is doing real work, so a long-running but
|
||||
# non-interactive job (build, download) is not auto-paused. A sandbox counts
|
||||
# as busy when guest CPU ≥ threshold, or net/disk throughput ≥ the floor.
|
||||
# Busy requires the threshold to hold for 2 consecutive samples (debounced),
|
||||
# so isolated idle-noise spikes do not keep a sandbox alive.
|
||||
WRENN_ACTIVITY_SAMPLE_INTERVAL=5s
|
||||
WRENN_CPU_BUSY_THRESHOLD=5.0
|
||||
WRENN_NET_FLOOR_BPS=16384
|
||||
WRENN_DISK_FLOOR_BPS=32768
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
when:
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
build-go:
|
||||
image: python:3.13
|
||||
environment:
|
||||
WRENN_API_KEY:
|
||||
from_secret: wrenn_api_key
|
||||
commands:
|
||||
- pip install wrenn httpx
|
||||
- export GO_VERSION=$$(grep '^go ' go.mod | cut -d' ' -f2)
|
||||
- python .woodpecker/scripts/build_go.py
|
||||
depends_on: []
|
||||
|
||||
build-rust:
|
||||
image: python:3.13
|
||||
environment:
|
||||
WRENN_API_KEY:
|
||||
from_secret: wrenn_api_key
|
||||
commands:
|
||||
- pip install wrenn
|
||||
- export RUST_VERSION=$$(grep '^rust-version ' envd-rs/Cargo.toml | cut -d'"' -f2)
|
||||
- python .woodpecker/scripts/build_rust.py
|
||||
depends_on: []
|
||||
|
||||
tag-release:
|
||||
image: python:3.13
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- VERSION=$$(cat VERSION_CP)
|
||||
- git config user.name "R3dRum92"
|
||||
- git config user.email "tksadik@omukk.dev"
|
||||
- git tag "v$${VERSION}"
|
||||
- git push "https://tksadik92:$${GITEA_TOKEN}@git.omukk.dev/wrenn/wrenn.git" "v$${VERSION}"
|
||||
depends_on: [build-go, build-rust]
|
||||
|
||||
release-notes:
|
||||
image: python:3.13
|
||||
environment:
|
||||
WRENN_API_KEY:
|
||||
from_secret: wrenn_api_key
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
ZHIPU_API_KEY:
|
||||
from_secret: zhipu_api_key
|
||||
commands:
|
||||
- pip install wrenn
|
||||
- python .woodpecker/scripts/release_notes.py
|
||||
depends_on: [tag-release]
|
||||
|
||||
publish-github:
|
||||
image: python:3.13
|
||||
environment:
|
||||
GITHUB_TOKEN:
|
||||
from_secret: github_token
|
||||
commands:
|
||||
- pip install httpx
|
||||
- python .woodpecker/scripts/publish_github.py
|
||||
depends_on: [release-notes]
|
||||
@ -1,112 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from wrenn import Capsule, StreamExitEvent, StreamStderrEvent, StreamStdoutEvent
|
||||
from wrenn._git import GitCommandError
|
||||
|
||||
GO_VERSION = os.getenv("GO_VERSION", "1.25.8")
|
||||
REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git"
|
||||
REPO_DIR = "/home/wrenn-user/wrenn"
|
||||
BUILDS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "builds")
|
||||
|
||||
|
||||
def read_remote_version(capsule: Capsule, filename: str) -> str:
|
||||
content = capsule.files.read_bytes(f"{REPO_DIR}/{filename}")
|
||||
return content.decode("utf-8").strip()
|
||||
|
||||
|
||||
def run(capsule: Capsule, cmd: str, timeout: int = 30) -> int:
|
||||
result = capsule.commands.run(cmd, timeout=timeout)
|
||||
if result.exit_code != 0:
|
||||
print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
return result.exit_code
|
||||
print(f"OK [{cmd.split()[0]}]")
|
||||
return 0
|
||||
|
||||
|
||||
def clone_repo(capsule: Capsule) -> bool:
|
||||
try:
|
||||
capsule.git.clone(REPO_URL, REPO_DIR)
|
||||
print("OK [git clone]")
|
||||
return True
|
||||
except GitCommandError as e:
|
||||
print(f"FAIL [git clone]: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def build_go(capsule: Capsule) -> bool:
|
||||
command = "CGO_ENABLED=1 make build-cp build-agent"
|
||||
handle = capsule.commands.run(
|
||||
command,
|
||||
background=True,
|
||||
cwd=REPO_DIR,
|
||||
envs={
|
||||
"PATH": "/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
},
|
||||
)
|
||||
print(f"{command} started (pid={handle.pid}), streaming output...")
|
||||
|
||||
exit_code = 0
|
||||
for event in capsule.commands.connect(handle.pid):
|
||||
if isinstance(event, StreamStdoutEvent):
|
||||
print(event.data, end="")
|
||||
elif isinstance(event, StreamStderrEvent):
|
||||
print(event.data, end="", file=sys.stderr)
|
||||
elif isinstance(event, StreamExitEvent):
|
||||
exit_code = event.exit_code
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"FAIL [go build]: exit={exit_code}", file=sys.stderr)
|
||||
return False
|
||||
print("OK [go build]")
|
||||
return True
|
||||
|
||||
|
||||
def download_artifacts(capsule: Capsule) -> bool:
|
||||
remote_dir = f"{REPO_DIR}/builds"
|
||||
entries = capsule.files.list(remote_dir, depth=1)
|
||||
files = [e for e in entries if e.type != "directory"]
|
||||
|
||||
if not files:
|
||||
print("FAIL [download]: no files found in builds/", file=sys.stderr)
|
||||
return False
|
||||
|
||||
local_dir = os.path.normpath(BUILDS_DIR)
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
versions = {
|
||||
"wrenn-cp": read_remote_version(capsule, "VERSION_CP"),
|
||||
"wrenn-agent": read_remote_version(capsule, "VERSION_AGENT"),
|
||||
}
|
||||
|
||||
for entry in files:
|
||||
name = entry.name or "unknown"
|
||||
remote_path = f"{remote_dir}/{name}"
|
||||
local_name = f"{name}-{versions[name]}" if name in versions else name
|
||||
local_path = os.path.join(local_dir, local_name)
|
||||
print(f"Downloading {name} as {local_name} ({entry.size or '?'} bytes)...")
|
||||
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in capsule.files.download_stream(remote_path):
|
||||
f.write(chunk)
|
||||
|
||||
print(f"OK [download {local_name}]")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with Capsule(template="golang", wait=True) as capsule:
|
||||
print(f"Capsule: {capsule.capsule_id}")
|
||||
if not clone_repo(capsule):
|
||||
sys.exit(1)
|
||||
if not build_go(capsule):
|
||||
sys.exit(1)
|
||||
if not download_artifacts(capsule):
|
||||
sys.exit(1)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,105 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from wrenn import Capsule, StreamExitEvent, StreamStderrEvent, StreamStdoutEvent
|
||||
from wrenn._git import GitCommandError
|
||||
|
||||
RUST_VERSION = os.getenv("RUST_VERSION", "1.95.0")
|
||||
REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git"
|
||||
REPO_DIR = "/home/wrenn-user/wrenn"
|
||||
BUILDS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "builds")
|
||||
|
||||
|
||||
def read_envd_version(capsule: Capsule) -> str:
|
||||
content = capsule.files.read_bytes(f"{REPO_DIR}/envd-rs/Cargo.toml")
|
||||
for line in content.decode("utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("version ="):
|
||||
return stripped.split("=", 1)[1].strip().strip('"')
|
||||
print("FAIL [version]: envd-rs/Cargo.toml has no package version", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(capsule: Capsule, cmd: str, timeout: int = 30, envs={}) -> int:
|
||||
result = capsule.commands.run(cmd, timeout=timeout, envs=envs)
|
||||
if result.exit_code != 0:
|
||||
print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
return result.exit_code
|
||||
print(f"OK [{cmd.split()[0]}]")
|
||||
return 0
|
||||
|
||||
|
||||
def clone_repo(capsule: Capsule) -> bool:
|
||||
try:
|
||||
capsule.git.clone(REPO_URL, REPO_DIR)
|
||||
print("OK [git clone]")
|
||||
return True
|
||||
except GitCommandError as e:
|
||||
print(f"FAIL [git clone]: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def build_rust(capsule: Capsule) -> bool:
|
||||
if run(capsule, f"mkdir -p {REPO_DIR}/builds") != 0:
|
||||
return False
|
||||
|
||||
handle = capsule.commands.run(
|
||||
"make build-envd",
|
||||
background=True,
|
||||
cwd=REPO_DIR,
|
||||
envs={
|
||||
"PATH": "/home/wrenn-user/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
},
|
||||
)
|
||||
print(f"rust build started (pid={handle.pid}), streaming output...")
|
||||
|
||||
exit_code = 0
|
||||
for event in capsule.commands.connect(handle.pid):
|
||||
if isinstance(event, StreamStdoutEvent):
|
||||
print(event.data, end="")
|
||||
elif isinstance(event, StreamStderrEvent):
|
||||
print(event.data, end="", file=sys.stderr)
|
||||
elif isinstance(event, StreamExitEvent):
|
||||
exit_code = event.exit_code
|
||||
|
||||
if exit_code != 0:
|
||||
print(f"FAIL [rust build]: exit={exit_code}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print("OK [rust build]")
|
||||
return True
|
||||
|
||||
|
||||
def download_artifacts(capsule: Capsule) -> bool:
|
||||
version = read_envd_version(capsule)
|
||||
remote_path = f"{REPO_DIR}/builds/envd"
|
||||
local_dir = os.path.normpath(BUILDS_DIR)
|
||||
local_name = f"envd-{version}"
|
||||
local_path = os.path.join(local_dir, local_name)
|
||||
os.makedirs(local_dir, exist_ok=True)
|
||||
|
||||
print(f"Downloading envd as {local_name}...")
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in capsule.files.download_stream(remote_path):
|
||||
f.write(chunk)
|
||||
|
||||
print(f"OK [download {local_name}]")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with Capsule(template="rust-1.95", wait=True) as capsule:
|
||||
print(f"Capsule: {capsule.capsule_id}")
|
||||
if not clone_repo(capsule):
|
||||
sys.exit(1)
|
||||
if not build_rust(capsule):
|
||||
sys.exit(1)
|
||||
if not download_artifacts(capsule):
|
||||
sys.exit(1)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,104 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
GITHUB_REPO = "wrennhq/wrenn"
|
||||
GITHUB_API = "https://api.github.com"
|
||||
GITHUB_UPLOADS = "https://uploads.github.com"
|
||||
BUILDS_DIR = "builds"
|
||||
VERSION_FILE = "VERSION_CP"
|
||||
NOTES_FILE = os.path.join(".woodpecker", "release_notes.md")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
|
||||
with open(VERSION_FILE) as f:
|
||||
version = f.read().strip()
|
||||
tag = f"v{version}"
|
||||
|
||||
release_notes = ""
|
||||
if os.path.exists(NOTES_FILE):
|
||||
with open(NOTES_FILE) as f:
|
||||
release_notes = f.read()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
client = httpx.Client(headers=headers, timeout=60)
|
||||
|
||||
print(f"Creating GitHub release for {tag}...")
|
||||
resp = client.post(
|
||||
f"{GITHUB_API}/repos/{GITHUB_REPO}/releases",
|
||||
json={
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": release_notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 422:
|
||||
print(f"WARN [create release]: release for {tag} already exists, skipping")
|
||||
data = resp.json()
|
||||
errors = data.get("errors", [])
|
||||
if errors:
|
||||
existing_url = errors[0].get("documentation_url", "")
|
||||
print(f" See: {existing_url}")
|
||||
client.close()
|
||||
return
|
||||
if resp.status_code != 201:
|
||||
print(f"FAIL [create release]: {resp.status_code} {resp.text}", file=sys.stderr)
|
||||
client.close()
|
||||
sys.exit(1)
|
||||
|
||||
release_data = resp.json()
|
||||
release_id = release_data["id"]
|
||||
release_url = release_data.get("html_url", "")
|
||||
print(f"OK [create release] id={release_id}")
|
||||
|
||||
builds_path = Path(BUILDS_DIR)
|
||||
if not builds_path.exists():
|
||||
print(f"No {BUILDS_DIR}/ directory found, skipping asset upload")
|
||||
client.close()
|
||||
print(f"Release published: {release_url}")
|
||||
return
|
||||
|
||||
upload_headers = {
|
||||
**headers,
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
|
||||
for artifact in sorted(builds_path.iterdir()):
|
||||
if artifact.is_dir():
|
||||
continue
|
||||
print(f"Uploading {artifact.name}...")
|
||||
|
||||
with open(artifact, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
resp = client.post(
|
||||
f"{GITHUB_UPLOADS}/repos/{GITHUB_REPO}/releases/{release_id}/assets",
|
||||
params={"name": artifact.name},
|
||||
headers=upload_headers,
|
||||
content=data,
|
||||
)
|
||||
if resp.status_code != 201:
|
||||
print(
|
||||
f"WARN [upload {artifact.name}]: {resp.status_code} {resp.text}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"OK [upload {artifact.name}]")
|
||||
|
||||
client.close()
|
||||
print(f"Release published: {release_url}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,241 +0,0 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
from wrenn import Capsule
|
||||
|
||||
REPO_URL = "https://git.omukk.dev/wrenn/wrenn.git"
|
||||
REPO_DIR = "wrenn-releases"
|
||||
CAPSULE_OUTPUT = "/tmp/release_notes.md"
|
||||
LOCAL_OUTPUT = os.path.join(os.path.dirname(__file__), "..", "release_notes.md")
|
||||
|
||||
DEFAULT_MODEL = "opencode/deepseek-v4-flash-free"
|
||||
|
||||
RELEASE_NOTES_EXAMPLE = """
|
||||
## What's new
|
||||
Sandbox HTTP proxying, terminal reliability, and auth robustness improvements.
|
||||
|
||||
### Proxy
|
||||
- Fixed redirect loops for apps served inside sandboxes (Python HTTP server, Jupyter, etc.)
|
||||
- Proxy traffic no longer interferes with terminal and exec connections
|
||||
- Services that take a moment to start up inside a sandbox are now retried instead of immediately failing
|
||||
|
||||
### Terminal (PTY)
|
||||
- Terminal input is no longer blocked by slow network conditions — fast typing no longer causes timeouts or disconnects
|
||||
- Input bursts are coalesced into fewer round trips — lower latency under fast typing
|
||||
|
||||
### Authentication
|
||||
- WebSocket connections now authenticate correctly for both SDK clients (header-based) and browser clients (message-based)
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed crash in envd when a process exits without a PTY
|
||||
- Fixed goroutine leak on sandbox pause
|
||||
|
||||
### Others
|
||||
- Version bump
|
||||
""".strip()
|
||||
|
||||
|
||||
def run(capsule: Capsule, cmd: str, cwd: str | None = None, timeout: int = 30) -> int:
|
||||
result = capsule.commands.run(cmd, cwd=cwd, timeout=timeout)
|
||||
if result.exit_code != 0:
|
||||
print(f"FAIL [{cmd.split()[0]}]: exit={result.exit_code}", file=sys.stderr)
|
||||
if result.stderr:
|
||||
print(result.stderr.strip(), file=sys.stderr)
|
||||
return result.exit_code
|
||||
print(f"OK [{cmd.split()[0]}]")
|
||||
return 0
|
||||
|
||||
|
||||
def generate_release_notes(
|
||||
capsule: Capsule,
|
||||
output_path: str,
|
||||
model: str,
|
||||
) -> None:
|
||||
prompt = f"""
|
||||
You are inside a cloned git repository at:
|
||||
|
||||
{REPO_DIR}
|
||||
|
||||
Generate release notes for the latest tagged version of this software project.
|
||||
|
||||
Before writing anything, inspect the repository yourself using git commands.
|
||||
|
||||
You MUST determine:
|
||||
1. The latest version tag.
|
||||
2. The previous version tag, if one exists.
|
||||
3. The commits between the previous tag and the latest tag.
|
||||
4. The files and areas changed between those tags.
|
||||
|
||||
Use commands like:
|
||||
|
||||
git tag --sort=-version:refname
|
||||
|
||||
If there are at least two tags, compare the newest tag against the previous tag:
|
||||
|
||||
git log PREVIOUS_TAG..LATEST_TAG --pretty=format:'%s (%h)'
|
||||
git diff PREVIOUS_TAG..LATEST_TAG --stat
|
||||
git diff PREVIOUS_TAG..LATEST_TAG --name-only
|
||||
|
||||
If there is only one tag, inspect the latest tag with:
|
||||
|
||||
git log LATEST_TAG --pretty=format:'%s (%h)' -n 50
|
||||
git show LATEST_TAG --stat
|
||||
git show LATEST_TAG --name-only
|
||||
|
||||
Do not rely on any pre-injected commit list or diff summary.
|
||||
You must inspect the git history yourself.
|
||||
|
||||
Write the release notes in plain, friendly language that any developer can understand
|
||||
without deep knowledge of the codebase.
|
||||
|
||||
Avoid jargon like "goroutine", "PTY", "envd", or internal function names.
|
||||
Describe what the change means for the user instead.
|
||||
|
||||
Group related changes under headings that reflect what actually changed.
|
||||
Only include sections that are relevant to the actual changes.
|
||||
Do not include CI/CD-only changes.
|
||||
|
||||
Start with:
|
||||
|
||||
## What's New
|
||||
|
||||
The very next line must be a single short summary sentence.
|
||||
|
||||
Keep each bullet point to one clear sentence.
|
||||
|
||||
Here is an example of the style to aim for — not a template to copy:
|
||||
|
||||
{RELEASE_NOTES_EXAMPLE}
|
||||
|
||||
Output only the final markdown.
|
||||
No intro.
|
||||
No explanation.
|
||||
No conversational filler.
|
||||
No acknowledgments.
|
||||
No "I checked the logs" text.
|
||||
No thoughts.
|
||||
""".strip()
|
||||
|
||||
prompt_b64 = base64.b64encode(prompt.encode("utf-8")).decode("utf-8")
|
||||
|
||||
write_prompt_cmd = f"echo '{prompt_b64}' | base64 -d > /tmp/oc_prompt.txt"
|
||||
|
||||
result = capsule.commands.run(
|
||||
write_prompt_cmd,
|
||||
cwd=REPO_DIR,
|
||||
timeout=10,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
print(f"FAIL [write prompt]: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def run_opencode_with_model(target_model: str) -> int:
|
||||
env = ""
|
||||
if "zhipu" in target_model.lower():
|
||||
env = f"ZHIPU_API_KEY={os.environ.get('ZHIPU_API_KEY', '')}"
|
||||
|
||||
raw_output_path = "/tmp/opencode_raw.txt"
|
||||
cmd = (
|
||||
f"{env} "
|
||||
f"~/.opencode/bin/opencode run "
|
||||
f'"Read the attached file and generate the release notes. Output ONLY markdown." '
|
||||
f"--model {target_model} "
|
||||
f"--file /tmp/oc_prompt.txt "
|
||||
f"> {raw_output_path}"
|
||||
)
|
||||
|
||||
cmd_result = capsule.commands.run(cmd, cwd=REPO_DIR, timeout=300)
|
||||
if cmd_result.exit_code != 0:
|
||||
print(
|
||||
f"FAIL [opencode via {target_model}]: exit={cmd_result.exit_code}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"STDOUT:\n{cmd_result.stdout}", file=sys.stderr)
|
||||
print(f"STDERR:\n{cmd_result.stderr}", file=sys.stderr)
|
||||
|
||||
clean_cmd = (
|
||||
f"awk 'found || /^## What.s [Nn]ew/ {{ found=1; print }}' "
|
||||
f"{raw_output_path} > {output_path}"
|
||||
)
|
||||
|
||||
clean_result = capsule.commands.run(clean_cmd, cwd=REPO_DIR, timeout=10)
|
||||
if clean_result.exit_code != 0:
|
||||
print(f"FAIL [clean output]: {clean_result.stderr}", file=sys.stderr)
|
||||
return clean_result.exit_code
|
||||
|
||||
check_result = capsule.commands.run(
|
||||
f"grep -q '^## What.s New' {output_path}",
|
||||
cwd=REPO_DIR,
|
||||
timeout=10,
|
||||
)
|
||||
if check_result.exit_code != 0:
|
||||
print(
|
||||
"FAIL: Could not find release notes heading in opencode output",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(cmd_result.stdout, file=sys.stderr)
|
||||
print(cmd_result.stderr, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return cmd_result.exit_code
|
||||
|
||||
exit_status = run_opencode_with_model(model)
|
||||
|
||||
if exit_status != 0:
|
||||
fallback_model = "opencode/big-pickle"
|
||||
if model != fallback_model:
|
||||
print(
|
||||
f"\n[!] Model {model} failed. Falling back to {fallback_model}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
exit_status = run_opencode_with_model(fallback_model)
|
||||
if exit_status != 0:
|
||||
print("FAIL: Fallback model also failed. Exiting.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(1)
|
||||
|
||||
result = capsule.commands.run(f"cat {output_path}")
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr)
|
||||
|
||||
print(f"OK [opencode] release notes written to {output_path}")
|
||||
|
||||
|
||||
def download_release_notes(capsule: Capsule) -> None:
|
||||
local_path = os.path.normpath(LOCAL_OUTPUT)
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
|
||||
print("Downloading release notes from capsule...")
|
||||
content = capsule.files.read_bytes(CAPSULE_OUTPUT)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"OK [download] release notes → {local_path}")
|
||||
print(content.decode("utf-8", errors="replace"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model = os.environ.get("OPENCODE_MODEL", DEFAULT_MODEL)
|
||||
|
||||
with Capsule(template="opencode", wait=True) as capsule:
|
||||
print(f"Capsule: {capsule.capsule_id}")
|
||||
|
||||
capsule.git.clone(
|
||||
REPO_URL,
|
||||
REPO_DIR,
|
||||
)
|
||||
print("OK [git clone]")
|
||||
|
||||
# Note: This simply creates the directory string safely
|
||||
output_path = os.path.normpath(CAPSULE_OUTPUT)
|
||||
|
||||
generate_release_notes(capsule, output_path, model)
|
||||
|
||||
download_release_notes(capsule)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wrenn is the runtime where AI engineers live — an open-source platform for running AI coding agents. Each project gets a persistent, isolated workspace (Cloud Hypervisor microVM) where agents edit code, run tests, debug, and ship continuously, with humans supervising via SDKs and chat surfaces. Fast boot, persistent state, available hosted or fully self-hosted with a single agent binary on each host you own. (The underlying primitive is still an isolated microVM — "sandbox" in the API/backend, "capsule" in the dashboard.)
|
||||
Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Cloud Hypervisor microVMs), run code inside them, and get output back via SDKs. Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
@ -28,7 +28,7 @@ make dev-envd # envd in debug mode (port 49983)
|
||||
make check # fmt + vet + lint + test (CI order)
|
||||
make test # Unit tests: go test -race -v ./internal/...
|
||||
make test-integration # Integration tests (require host agent + Cloud Hypervisor)
|
||||
make fmt # gofmt and rust fmt
|
||||
make fmt # gofmt
|
||||
make vet # go vet
|
||||
make lint # golangci-lint
|
||||
|
||||
|
||||
1
Makefile
1
Makefile
@ -106,7 +106,6 @@ sqlc:
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
cargo fmt --manifest-path envd-rs/Cargo.toml
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
# Wrenn
|
||||
|
||||
Runtime where AI engineers live
|
||||
|
||||
Wrenn is an open-source platform for running AI coding agents. Each project gets a persistent, isolated microVM workspace — booted in seconds, stateful across sessions — where agents edit code, run tests, debug, and ship continuously while humans supervise via SDKs and chat surfaces. Available hosted or fully self-hosted: run the control plane anywhere, deploy a single agent binary on each compute host you own.
|
||||
Secure infrastructure for AI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
0.3.0
|
||||
0.2.0
|
||||
|
||||
@ -1 +1 @@
|
||||
0.3.0
|
||||
0.2.0
|
||||
|
||||
@ -148,13 +148,6 @@ func main() {
|
||||
VMMBin: chBin,
|
||||
VMMVersion: chVersion,
|
||||
AgentVersion: version,
|
||||
ProxyDomain: envOrDefault("WRENN_PROXY_DOMAIN", "wrenn.dev"),
|
||||
|
||||
// Activity sampler tuning (all optional; zero → sandbox package default).
|
||||
ActivitySampleInterval: envDuration("WRENN_ACTIVITY_SAMPLE_INTERVAL"),
|
||||
CPUBusyPct: envFloat32("WRENN_CPU_BUSY_THRESHOLD"),
|
||||
NetFloorBps: envUint64("WRENN_NET_FLOOR_BPS"),
|
||||
DiskFloorBps: envUint64("WRENN_DISK_FLOOR_BPS"),
|
||||
}
|
||||
|
||||
// Remove any *.staging-* / *.trash-* directories left behind by a
|
||||
@ -178,7 +171,6 @@ func main() {
|
||||
mgr.RestorePausedSandboxes()
|
||||
|
||||
mgr.StartTTLReaper(ctx)
|
||||
mgr.StartActivitySampler(ctx)
|
||||
|
||||
// httpServer is declared here so the shutdown func can reference it.
|
||||
// ReadTimeout/WriteTimeout are intentionally omitted — they would kill
|
||||
@ -319,49 +311,6 @@ func envOrDefault(key, def string) string {
|
||||
return def
|
||||
}
|
||||
|
||||
// envDuration parses an optional duration env var (e.g. "5s"). Empty or
|
||||
// invalid → zero, letting the sandbox package apply its default.
|
||||
func envDuration(key string) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
slog.Warn("invalid duration env var, using default", "key", key, "value", v)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// envFloat32 parses an optional float env var. Empty or invalid → 0.
|
||||
func envFloat32(key string) float32 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 32)
|
||||
if err != nil {
|
||||
slog.Warn("invalid float env var, using default", "key", key, "value", v)
|
||||
return 0
|
||||
}
|
||||
return float32(f)
|
||||
}
|
||||
|
||||
// envUint64 parses an optional unsigned-int env var. Empty or invalid → 0.
|
||||
func envUint64(key string) uint64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 64)
|
||||
if err != nil {
|
||||
slog.Warn("invalid uint env var, using default", "key", key, "value", v)
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// checkPrivileges verifies the process has the required Linux capabilities.
|
||||
// Always reads CapEff — even for root — because a root process inside a
|
||||
// restricted container (e.g. docker --cap-drop=all) may not have all caps.
|
||||
|
||||
@ -113,9 +113,3 @@ UPDATE sandboxes
|
||||
SET status = $2,
|
||||
last_updated = NOW()
|
||||
WHERE id = ANY($1::uuid[]) AND status = 'missing';
|
||||
|
||||
-- name: UpdateSandboxDiskSize :exec
|
||||
UPDATE sandboxes
|
||||
SET disk_size_mb = $2,
|
||||
last_updated = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
434
envd-rs/Cargo.lock
generated
434
envd-rs/Cargo.lock
generated
@ -124,9 +124,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
@ -195,9 +195,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@ -258,24 +258,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.65"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@ -297,9 +297,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
@ -489,9 +489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
@ -514,9 +514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
@ -529,7 +529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "envd"
|
||||
version = "0.5.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
@ -542,6 +542,7 @@ dependencies = [
|
||||
"connectrpc",
|
||||
"connectrpc-build",
|
||||
"dashmap",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
@ -549,6 +550,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"mime_guess",
|
||||
"nix",
|
||||
"notify",
|
||||
"serde",
|
||||
@ -592,12 +594,13 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -758,20 +761,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@ -804,9 +809,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@ -831,9 +836,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@ -882,9 +887,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@ -928,7 +933,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -940,6 +945,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@ -947,7 +958,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1003,20 +1016,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.102"
|
||||
version = "0.3.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.2.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
@ -1024,11 +1038,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.1.2"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@ -1038,12 +1052,30 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@ -1061,9 +1093,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
@ -1082,9 +1114,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@ -1114,9 +1146,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@ -1147,7 +1179,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@ -1159,7 +1191,7 @@ version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
@ -1238,7 +1270,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"redox_syscall 0.5.18",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@ -1251,18 +1283,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1281,6 +1313,12 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@ -1302,9 +1340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@ -1347,7 +1385,16 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1363,9 +1410,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
@ -1373,7 +1420,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@ -1407,6 +1454,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@ -1439,9 +1492,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@ -1495,9 +1548,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
@ -1523,15 +1576,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@ -1557,9 +1610,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1593,7 +1646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@ -1630,9 +1683,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
version = "1.52.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@ -1688,11 +1741,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.11"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@ -1709,6 +1762,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1800,9 +1854,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
@ -1816,6 +1870,12 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@ -1852,18 +1912,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.125"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@ -1874,9 +1943,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.125"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@ -1884,9 +1953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.125"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@ -1897,13 +1966,47 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.125"
|
||||
version = "0.2.121"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@ -1941,7 +2044,7 @@ version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-core",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
@ -1951,25 +2054,12 @@ version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
@ -1981,17 +2071,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
@ -2003,17 +2082,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@ -2029,24 +2097,6 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@ -2129,6 +2179,15 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@ -2136,19 +2195,98 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "envd"
|
||||
version = "0.5.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
@ -50,6 +50,9 @@ zeroize = { version = "1", features = ["derive"] }
|
||||
# File watching
|
||||
notify = "7"
|
||||
|
||||
# Compression
|
||||
flate2 = "1"
|
||||
|
||||
# Directory walking
|
||||
walkdir = "2"
|
||||
|
||||
@ -67,6 +70,7 @@ subtle = "2"
|
||||
http-body = "1.0.1"
|
||||
buffa = "0.3"
|
||||
async-stream = "0.3.6"
|
||||
mime_guess = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@ -81,8 +81,8 @@ make build-envd-go # Go version (for comparison)
|
||||
| GET | `/envs` | Current environment variables |
|
||||
| POST | `/init` | Host agent init (token, env, mounts) |
|
||||
| POST | `/snapshot/prepare` | Quiesce before Cloud Hypervisor snapshot |
|
||||
| GET | `/files` | Download file (streamed from disk) |
|
||||
| PUT | `/files` | Upload file (raw body, streamed, atomic) |
|
||||
| GET | `/files` | Download file (gzip, range support) |
|
||||
| POST | `/files` | Upload file(s) via multipart |
|
||||
|
||||
### Connect RPC (same port)
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
||||
/// Format: "METHOD/path"
|
||||
const AUTH_EXCLUDED: &[&str] = &[
|
||||
"GET/health",
|
||||
"GET/activity",
|
||||
"GET/files",
|
||||
"POST/files",
|
||||
"POST/init",
|
||||
@ -22,7 +21,11 @@ const AUTH_EXCLUDED: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Axum middleware that checks X-Access-Token header.
|
||||
pub async fn auth_layer(request: Request, next: Next, access_token: Arc<SecureToken>) -> Response {
|
||||
pub async fn auth_layer(
|
||||
request: Request,
|
||||
next: Next,
|
||||
access_token: Arc<SecureToken>,
|
||||
) -> Response {
|
||||
if access_token.is_set() {
|
||||
let method = request.method().as_str();
|
||||
let path = request.uri().path();
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod middleware;
|
||||
pub mod signing;
|
||||
pub mod token;
|
||||
pub mod signing;
|
||||
pub mod middleware;
|
||||
|
||||
@ -140,32 +140,13 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_correct_header_token() {
|
||||
let token = test_token(b"secret");
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
Some("secret"),
|
||||
None,
|
||||
None,
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate_signing(&token, Some("secret"), None, None, "root", "/f", READ_OPERATION).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_wrong_header_token() {
|
||||
let token = test_token(b"secret");
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
Some("wrong"),
|
||||
None,
|
||||
None,
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
let result = validate_signing(&token, Some("wrong"), None, None, "root", "/f", READ_OPERATION);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not match"));
|
||||
}
|
||||
@ -175,32 +156,13 @@ mod tests {
|
||||
let token = test_token(b"secret");
|
||||
let exp = far_future();
|
||||
let sig = generate_signature(&token, "/file", "root", READ_OPERATION, Some(exp)).unwrap();
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
Some(exp),
|
||||
"root",
|
||||
"/file",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate_signing(&token, None, Some(&sig), Some(exp), "root", "/file", READ_OPERATION).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_invalid_signature() {
|
||||
let token = test_token(b"secret");
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some("v1_bad"),
|
||||
Some(far_future()),
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
let result = validate_signing(&token, None, Some("v1_bad"), Some(far_future()), "root", "/f", READ_OPERATION);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("invalid signature"));
|
||||
}
|
||||
@ -210,15 +172,7 @@ mod tests {
|
||||
let token = test_token(b"secret");
|
||||
let expired: i64 = 1_000_000;
|
||||
let sig = generate_signature(&token, "/f", "root", READ_OPERATION, Some(expired)).unwrap();
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
Some(expired),
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
let result = validate_signing(&token, None, Some(&sig), Some(expired), "root", "/f", READ_OPERATION);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("expired"));
|
||||
}
|
||||
@ -243,18 +197,7 @@ mod tests {
|
||||
fn validate_valid_signature_no_expiration() {
|
||||
let token = test_token(b"secret");
|
||||
let sig = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap();
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
None,
|
||||
"root",
|
||||
"/file",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(validate_signing(&token, None, Some(&sig), None, "root", "/file", READ_OPERATION).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -19,25 +19,20 @@ pub struct Cgroup2Manager {
|
||||
}
|
||||
|
||||
impl Cgroup2Manager {
|
||||
pub fn new(
|
||||
root: &str,
|
||||
configs: &[(ProcessType, &str, &[(&str, &str)])],
|
||||
) -> Result<Self, String> {
|
||||
pub fn new(root: &str, configs: &[(ProcessType, &str, &[(&str, &str)])]) -> Result<Self, String> {
|
||||
let mut fds = HashMap::new();
|
||||
|
||||
for (proc_type, sub_path, properties) in configs {
|
||||
let full_path = PathBuf::from(root).join(sub_path);
|
||||
|
||||
fs::create_dir_all(&full_path)
|
||||
.map_err(|e| format!("failed to create cgroup {}: {e}", full_path.display()))?;
|
||||
fs::create_dir_all(&full_path).map_err(|e| {
|
||||
format!("failed to create cgroup {}: {e}", full_path.display())
|
||||
})?;
|
||||
|
||||
for (name, value) in *properties {
|
||||
let prop_path = full_path.join(name);
|
||||
fs::write(&prop_path, value).map_err(|e| {
|
||||
format!(
|
||||
"failed to write cgroup property {}: {e}",
|
||||
prop_path.display()
|
||||
)
|
||||
format!("failed to write cgroup property {}: {e}", prop_path.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
//! Client subcommands for the `envd` binary. These run as short-lived
|
||||
//! invocations (e.g. `envd ports`) inside the guest, separate from the
|
||||
//! long-running daemon, and exit when done.
|
||||
|
||||
pub mod ports;
|
||||
@ -1,164 +0,0 @@
|
||||
//! `envd ports` — list the open ports inside the sandbox that are reachable
|
||||
//! from outside, alongside the URL each is served at.
|
||||
//!
|
||||
//! Runs as a one-shot client (not the daemon): it scans `/proc/net/tcp[6]`
|
||||
//! directly via the shared port helper and reads the sandbox identity that the
|
||||
//! daemon recorded under /run/wrenn at /init time. It refuses to run outside a
|
||||
//! wrenn sandbox.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{DEFAULT_PORT, DEFAULT_PROXY_DOMAIN, WRENN_RUN_DIR};
|
||||
use crate::port::conn::reachable_listening_ports;
|
||||
|
||||
/// Arguments for the `envd ports` subcommand.
|
||||
#[derive(clap::Args)]
|
||||
pub struct PortsArgs {
|
||||
/// Override the proxy domain used to build URLs (default: the domain
|
||||
/// injected by the host, falling back to the built-in default).
|
||||
#[arg(long)]
|
||||
domain: Option<String>,
|
||||
|
||||
/// Emit JSON instead of a table.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct PortEntry {
|
||||
port: u32,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Runs the subcommand and returns the desired process exit code.
|
||||
pub fn run(args: &PortsArgs) -> i32 {
|
||||
if !inside_sandbox() {
|
||||
eprintln!("envd ports: not running inside a wrenn sandbox");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let sandbox_id = read_identity("WRENN_SANDBOX_ID", ".WRENN_SANDBOX_ID");
|
||||
let domain = args
|
||||
.domain
|
||||
.clone()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or_else(|| read_identity("WRENN_PROXY_DOMAIN", ".WRENN_PROXY_DOMAIN"))
|
||||
.unwrap_or_else(|| DEFAULT_PROXY_DOMAIN.to_string());
|
||||
|
||||
let entries: Vec<PortEntry> = reachable_listening_ports(DEFAULT_PORT as u32)
|
||||
.into_iter()
|
||||
.map(|port| PortEntry {
|
||||
url: build_url(port, sandbox_id.as_deref(), &domain),
|
||||
port,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if args.json {
|
||||
match serde_json::to_string_pretty(&entries) {
|
||||
Ok(s) => println!("{s}"),
|
||||
Err(e) => {
|
||||
eprintln!("envd ports: failed to encode JSON: {e}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No open ports.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
println!("{:<6} {}", "PORT", "URL");
|
||||
for e in &entries {
|
||||
println!("{:<6} {}", e.port, e.url);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// A wrenn sandbox is identified by the marker the daemon writes at startup
|
||||
/// (`/run/wrenn/.WRENN_SANDBOX`) and the `WRENN_SANDBOX` env var it exports
|
||||
/// into spawned processes. Running `envd ports` on a normal host finds neither
|
||||
/// and is refused.
|
||||
fn inside_sandbox() -> bool {
|
||||
if std::env::var("WRENN_SANDBOX").as_deref() == Ok("true") {
|
||||
return true;
|
||||
}
|
||||
Path::new(WRENN_RUN_DIR).join(".WRENN_SANDBOX").exists()
|
||||
}
|
||||
|
||||
/// Reads an identity value from the environment, falling back to the matching
|
||||
/// /run/wrenn file. Returns None when neither is set or both are blank.
|
||||
fn read_identity(env_key: &str, file_name: &str) -> Option<String> {
|
||||
if let Ok(v) = std::env::var(env_key) {
|
||||
let v = v.trim().to_string();
|
||||
if !v.is_empty() {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
match fs::read_to_string(Path::new(WRENN_RUN_DIR).join(file_name)) {
|
||||
Ok(v) => {
|
||||
let v = v.trim().to_string();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the externally-reachable URL for a port. With a known sandbox ID the
|
||||
/// result is a working https URL; without it (identity not yet injected) the
|
||||
/// sandbox-ID segment degrades to a `<sandbox-id>` placeholder so output is
|
||||
/// still informative.
|
||||
fn build_url(port: u32, sandbox_id: Option<&str>, domain: &str) -> String {
|
||||
let id = sandbox_id.unwrap_or("<sandbox-id>");
|
||||
format!("https://{port}-{id}.{domain}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_with_sandbox_id() {
|
||||
assert_eq!(
|
||||
build_url(8000, Some("cl-abcd1234"), "wrenn.dev"),
|
||||
"https://8000-cl-abcd1234.wrenn.dev"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_without_sandbox_id_uses_placeholder() {
|
||||
assert_eq!(
|
||||
build_url(5173, None, "wrenn.dev"),
|
||||
"https://5173-<sandbox-id>.wrenn.dev"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_honors_custom_domain() {
|
||||
assert_eq!(
|
||||
build_url(3000, Some("cl-deadbeef"), "sandbox.example.com"),
|
||||
"https://3000-cl-deadbeef.sandbox.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_identity_prefers_env() {
|
||||
// SAFETY: test-local env var, single-threaded test body.
|
||||
unsafe { std::env::set_var("ENVD_PORTS_TEST_ID", " cl-fromenv ") };
|
||||
assert_eq!(
|
||||
read_identity("ENVD_PORTS_TEST_ID", ".nonexistent-file"),
|
||||
Some("cl-fromenv".to_string())
|
||||
);
|
||||
unsafe { std::env::remove_var("ENVD_PORTS_TEST_ID") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_identity_none_when_unset() {
|
||||
assert_eq!(
|
||||
read_identity("ENVD_PORTS_TEST_UNSET", ".nonexistent-file"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,5 @@ pub const PORT_SCANNER_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
pub const DEFAULT_USER: &str = "root";
|
||||
pub const WRENN_RUN_DIR: &str = "/run/wrenn";
|
||||
|
||||
/// Fallback proxy domain used by `envd ports` to build URLs when the host has
|
||||
/// not injected one via /init. Matches the host agent's WRENN_PROXY_DOMAIN
|
||||
/// default.
|
||||
pub const DEFAULT_PROXY_DOMAIN: &str = "wrenn.dev";
|
||||
|
||||
pub const KILOBYTE: u64 = 1024;
|
||||
pub const MEGABYTE: u64 = 1024 * KILOBYTE;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod hmac_sha256;
|
||||
pub mod sha256;
|
||||
pub mod sha512;
|
||||
pub mod hmac_sha256;
|
||||
|
||||
@ -20,22 +20,14 @@ mod tests {
|
||||
const VECTORS: &[(&[u8], &str)] = &[
|
||||
(b"", "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"),
|
||||
(b"abc", "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"),
|
||||
(
|
||||
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
"JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE",
|
||||
),
|
||||
(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn known_answer_with_prefix() {
|
||||
for (input, expected_b64) in VECTORS {
|
||||
let result = hash(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
format!("$sha256${expected_b64}"),
|
||||
"input: {:?}",
|
||||
String::from_utf8_lossy(input)
|
||||
);
|
||||
assert_eq!(result, format!("$sha256${expected_b64}"), "input: {:?}", String::from_utf8_lossy(input));
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,12 +35,7 @@ mod tests {
|
||||
fn known_answer_without_prefix() {
|
||||
for (input, expected_b64) in VECTORS {
|
||||
let result = hash_without_prefix(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
*expected_b64,
|
||||
"input: {:?}",
|
||||
String::from_utf8_lossy(input)
|
||||
);
|
||||
assert_eq!(result, *expected_b64, "input: {:?}", String::from_utf8_lossy(input));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,18 +15,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const VECTORS: &[(&str, &str)] = &[
|
||||
(
|
||||
"",
|
||||
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
|
||||
),
|
||||
(
|
||||
"abc",
|
||||
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
|
||||
),
|
||||
(
|
||||
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
"204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445",
|
||||
),
|
||||
("", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"),
|
||||
("abc", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"),
|
||||
("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
@ -39,10 +30,7 @@ mod tests {
|
||||
#[test]
|
||||
fn str_and_bytes_agree() {
|
||||
for (input, _) in VECTORS {
|
||||
assert_eq!(
|
||||
hash_access_token(input),
|
||||
hash_access_token_bytes(input.as_bytes())
|
||||
);
|
||||
assert_eq!(hash_access_token(input), hash_access_token_bytes(input.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,9 +38,6 @@ mod tests {
|
||||
fn output_is_lowercase_hex_128_chars() {
|
||||
let h = hash_access_token("anything");
|
||||
assert_eq!(h.len(), 128);
|
||||
assert!(
|
||||
h.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,10 +62,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workdir_explicit_overrides_default() {
|
||||
assert_eq!(
|
||||
resolve_default_workdir("/explicit", Some("/default")),
|
||||
"/explicit"
|
||||
);
|
||||
assert_eq!(resolve_default_workdir("/explicit", Some("/default")), "/explicit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -85,10 +82,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn username_explicit_returns_explicit() {
|
||||
assert_eq!(
|
||||
resolve_default_username(Some("root"), "wrenn").unwrap(),
|
||||
"root"
|
||||
);
|
||||
assert_eq!(resolve_default_username(Some("root"), "wrenn").unwrap(), "root");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Liveness snapshot the host activity sampler polls to decide whether a
|
||||
/// sandbox is doing real work. All fields are served straight from atomics
|
||||
/// updated by the 1s sampler thread — no syscalls per request, so the host
|
||||
/// can poll cheaply at a few-second cadence.
|
||||
#[derive(Serialize)]
|
||||
pub struct Activity {
|
||||
cpu_count: u32,
|
||||
cpu_used_pct: f32,
|
||||
net_bps: u64,
|
||||
disk_bps: u64,
|
||||
}
|
||||
|
||||
pub async fn get_activity(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
tracing::trace!("get activity");
|
||||
|
||||
let body = Activity {
|
||||
cpu_count: state.cpu_count(),
|
||||
cpu_used_pct: state.cpu_used_pct(),
|
||||
net_bps: state.net_bps(),
|
||||
disk_bps: state.disk_bps(),
|
||||
};
|
||||
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(body))
|
||||
}
|
||||
336
envd-rs/src/http/encoding.rs
Normal file
336
envd-rs/src/http/encoding.rs
Normal file
@ -0,0 +1,336 @@
|
||||
use axum::http::Request;
|
||||
|
||||
const ENCODING_GZIP: &str = "gzip";
|
||||
const ENCODING_IDENTITY: &str = "identity";
|
||||
const ENCODING_WILDCARD: &str = "*";
|
||||
|
||||
const SUPPORTED_ENCODINGS: &[&str] = &[ENCODING_GZIP];
|
||||
|
||||
struct EncodingWithQuality {
|
||||
encoding: String,
|
||||
quality: f64,
|
||||
}
|
||||
|
||||
fn parse_encoding_with_quality(value: &str) -> EncodingWithQuality {
|
||||
let value = value.trim();
|
||||
let mut quality = 1.0;
|
||||
|
||||
if let Some(idx) = value.find(';') {
|
||||
let params = &value[idx + 1..];
|
||||
let enc = value[..idx].trim();
|
||||
for param in params.split(';') {
|
||||
let param = param.trim();
|
||||
if let Some(stripped) = param.strip_prefix("q=").or_else(|| param.strip_prefix("Q=")) {
|
||||
if let Ok(q) = stripped.parse::<f64>() {
|
||||
quality = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
return EncodingWithQuality {
|
||||
encoding: enc.to_ascii_lowercase(),
|
||||
quality,
|
||||
};
|
||||
}
|
||||
|
||||
EncodingWithQuality {
|
||||
encoding: value.to_ascii_lowercase(),
|
||||
quality,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_accept_encoding_header(header: &str) -> (Vec<EncodingWithQuality>, bool) {
|
||||
if header.is_empty() {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
let encodings: Vec<EncodingWithQuality> =
|
||||
header.split(',').map(|v| parse_encoding_with_quality(v)).collect();
|
||||
|
||||
let mut identity_rejected = false;
|
||||
let mut identity_explicitly_accepted = false;
|
||||
let mut wildcard_rejected = false;
|
||||
|
||||
for eq in &encodings {
|
||||
match eq.encoding.as_str() {
|
||||
ENCODING_IDENTITY => {
|
||||
if eq.quality == 0.0 {
|
||||
identity_rejected = true;
|
||||
} else {
|
||||
identity_explicitly_accepted = true;
|
||||
}
|
||||
}
|
||||
ENCODING_WILDCARD => {
|
||||
if eq.quality == 0.0 {
|
||||
wildcard_rejected = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if wildcard_rejected && !identity_explicitly_accepted {
|
||||
identity_rejected = true;
|
||||
}
|
||||
|
||||
(encodings, identity_rejected)
|
||||
}
|
||||
|
||||
pub fn is_identity_acceptable<B>(r: &Request<B>) -> bool {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("accept-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let (_, rejected) = parse_accept_encoding_header(header);
|
||||
!rejected
|
||||
}
|
||||
|
||||
pub fn parse_accept_encoding<B>(r: &Request<B>) -> Result<&'static str, String> {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("accept-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if header.is_empty() {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
let (mut encodings, identity_rejected) = parse_accept_encoding_header(header);
|
||||
encodings.sort_by(|a, b| b.quality.partial_cmp(&a.quality).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
for eq in &encodings {
|
||||
if eq.quality == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if eq.encoding == ENCODING_IDENTITY {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if eq.encoding == ENCODING_WILDCARD {
|
||||
if identity_rejected && !SUPPORTED_ENCODINGS.is_empty() {
|
||||
return Ok(SUPPORTED_ENCODINGS[0]);
|
||||
}
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if eq.encoding == ENCODING_GZIP {
|
||||
return Ok(ENCODING_GZIP);
|
||||
}
|
||||
}
|
||||
|
||||
if !identity_rejected {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
Err(format!("no acceptable encoding found, supported: {SUPPORTED_ENCODINGS:?}"))
|
||||
}
|
||||
|
||||
pub fn parse_content_encoding<B>(r: &Request<B>) -> Result<&'static str, String> {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if header.is_empty() {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
let encoding = header.trim().to_ascii_lowercase();
|
||||
if encoding == ENCODING_IDENTITY {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if SUPPORTED_ENCODINGS.contains(&encoding.as_str()) {
|
||||
return Ok(ENCODING_GZIP);
|
||||
}
|
||||
|
||||
Err(format!("unsupported Content-Encoding: {header}, supported: {SUPPORTED_ENCODINGS:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
|
||||
fn req_with_accept(v: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.header("accept-encoding", v)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn req_with_content(v: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.header("content-encoding", v)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn req_no_headers() -> Request<()> {
|
||||
Request::builder().body(()).unwrap()
|
||||
}
|
||||
|
||||
// parse_encoding_with_quality
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_default_1() {
|
||||
let eq = parse_encoding_with_quality("gzip");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_explicit() {
|
||||
let eq = parse_encoding_with_quality("gzip;q=0.8");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_case_insensitive() {
|
||||
let eq = parse_encoding_with_quality("GZIP;Q=0.5");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_zero() {
|
||||
let eq = parse_encoding_with_quality("gzip;q=0");
|
||||
assert_eq!(eq.quality, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_whitespace_trimmed() {
|
||||
let eq = parse_encoding_with_quality(" gzip ; q=0.9 ");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.9);
|
||||
}
|
||||
|
||||
// parse_accept_encoding_header
|
||||
|
||||
#[test]
|
||||
fn accept_header_empty() {
|
||||
let (encs, rejected) = parse_accept_encoding_header("");
|
||||
assert!(encs.is_empty());
|
||||
assert!(!rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_identity_q0_rejects() {
|
||||
let (_, rejected) = parse_accept_encoding_header("identity;q=0");
|
||||
assert!(rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_wildcard_q0_rejects_identity() {
|
||||
let (_, rejected) = parse_accept_encoding_header("*;q=0");
|
||||
assert!(rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_wildcard_q0_but_identity_explicit_accepted() {
|
||||
let (_, rejected) = parse_accept_encoding_header("*;q=0, identity");
|
||||
assert!(!rejected);
|
||||
}
|
||||
|
||||
// parse_accept_encoding (full)
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_no_header_returns_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_no_headers()).unwrap(), "identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_gzip() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("gzip")).unwrap(), "gzip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_identity_explicit() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("identity")).unwrap(), "identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_gzip_higher_quality() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity;q=0.1, gzip;q=0.9")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_wildcard_returns_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("*")).unwrap(), "identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_wildcard_identity_rejected_returns_gzip() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity;q=0, *")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_all_rejected_errors() {
|
||||
assert!(parse_accept_encoding(&req_with_accept("identity;q=0, *;q=0")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_unsupported_only_falls_to_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("br")).unwrap(), "identity");
|
||||
}
|
||||
|
||||
// is_identity_acceptable
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_no_header() {
|
||||
assert!(is_identity_acceptable(&req_no_headers()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_gzip_only() {
|
||||
assert!(is_identity_acceptable(&req_with_accept("gzip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_not_acceptable_identity_q0() {
|
||||
assert!(!is_identity_acceptable(&req_with_accept("identity;q=0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_not_acceptable_wildcard_q0() {
|
||||
assert!(!is_identity_acceptable(&req_with_accept("*;q=0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_wildcard_q0_but_identity_explicit() {
|
||||
assert!(is_identity_acceptable(&req_with_accept("*;q=0, identity")));
|
||||
}
|
||||
|
||||
// parse_content_encoding
|
||||
|
||||
#[test]
|
||||
fn content_encoding_empty_returns_identity() {
|
||||
assert_eq!(parse_content_encoding(&req_no_headers()).unwrap(), "identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_gzip() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("gzip")).unwrap(), "gzip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_identity_explicit() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("identity")).unwrap(), "identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_unsupported_errors() {
|
||||
assert!(parse_content_encoding(&req_with_content("br")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_case_insensitive() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("GZIP")).unwrap(), "gzip");
|
||||
}
|
||||
}
|
||||
@ -18,5 +18,8 @@ pub async fn get_envs(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||
.collect();
|
||||
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(envs))
|
||||
(
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
Json(envs),
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,30 +1,22 @@
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::extract::{FromRequest, Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::auth::signing;
|
||||
use crate::execcontext;
|
||||
use crate::http::encoding;
|
||||
use crate::permissions::path::{ensure_dirs, expand_and_resolve};
|
||||
use crate::permissions::user::lookup_user;
|
||||
use crate::state::AppState;
|
||||
|
||||
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
||||
|
||||
/// Monotonic counter for unique temp-file names within this process. Combined
|
||||
/// with the pid it guarantees concurrent uploads never collide on the staging
|
||||
/// path before the atomic rename.
|
||||
static UPLOAD_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileParams {
|
||||
pub path: Option<String>,
|
||||
@ -70,10 +62,7 @@ fn validate_file_signing(
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /files — download a file, streamed from disk.
|
||||
///
|
||||
/// The body is streamed straight off the filesystem so large files never get
|
||||
/// buffered into memory. Identity encoding only — no gzip, no range support.
|
||||
/// GET /files — download a file
|
||||
pub async fn get_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<FileParams>,
|
||||
@ -83,11 +72,13 @@ pub async fn get_files(
|
||||
let header_token = extract_header_token(&req);
|
||||
|
||||
let default_user = state.defaults.user();
|
||||
let username =
|
||||
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let username = match execcontext::resolve_default_username(
|
||||
params.username.as_deref(),
|
||||
&default_user,
|
||||
) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
|
||||
if let Err(e) = validate_file_signing(
|
||||
&state,
|
||||
@ -107,12 +98,13 @@ pub async fn get_files(
|
||||
|
||||
let home_dir = user.dir.to_string_lossy().to_string();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
let resolved = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
let resolved = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref())
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
|
||||
let meta = match tokio::fs::metadata(&resolved).await {
|
||||
let meta = match std::fs::metadata(&resolved) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return json_error(
|
||||
@ -142,12 +134,34 @@ pub async fn get_files(
|
||||
);
|
||||
}
|
||||
|
||||
let file = match tokio::fs::File::open(&resolved).await {
|
||||
Ok(f) => f,
|
||||
let accept_enc = match encoding::parse_accept_encoding(&req) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return json_error(StatusCode::NOT_ACCEPTABLE, &e),
|
||||
};
|
||||
|
||||
let has_range_or_conditional = req.headers().get("range").is_some()
|
||||
|| req.headers().get("if-modified-since").is_some()
|
||||
|| req.headers().get("if-none-match").is_some()
|
||||
|| req.headers().get("if-range").is_some();
|
||||
|
||||
let use_encoding = if has_range_or_conditional {
|
||||
if !encoding::is_identity_acceptable(&req) {
|
||||
return json_error(
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"identity encoding not acceptable for Range or conditional request",
|
||||
);
|
||||
}
|
||||
"identity"
|
||||
} else {
|
||||
accept_enc
|
||||
};
|
||||
|
||||
let file_data = match std::fs::read(&resolved) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error opening file: {e}"),
|
||||
&format!("error reading file: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -156,42 +170,68 @@ pub async fn get_files(
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content_disposition = format!("inline; filename=\"{}\"", filename);
|
||||
|
||||
let body = Body::from_stream(ReaderStream::new(file));
|
||||
let content_disposition = format!("inline; filename=\"{}\"", filename);
|
||||
let content_type = mime_guess::from_path(&resolved)
|
||||
.first_raw()
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
if use_encoding == "gzip" {
|
||||
let mut encoder =
|
||||
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
if let Err(e) = encoder.write_all(&file_data) {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("gzip encoding error: {e}"),
|
||||
);
|
||||
}
|
||||
let compressed = match encoder.finish() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("gzip finish error: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_ENCODING, "gzip")
|
||||
.header(header::CONTENT_DISPOSITION, content_disposition)
|
||||
.header(header::VARY, "Accept-Encoding")
|
||||
.body(Body::from(compressed))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, content_disposition)
|
||||
.header(header::CONTENT_LENGTH, meta.len())
|
||||
.body(body)
|
||||
.header(header::VARY, "Accept-Encoding")
|
||||
.header(header::CONTENT_LENGTH, file_data.len())
|
||||
.body(Body::from(file_data))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// PUT /files — upload a single file, streamed to disk.
|
||||
///
|
||||
/// The request body is the raw file content (no multipart, no encoding). It is
|
||||
/// streamed to a temporary staging file in the destination directory, then
|
||||
/// atomically renamed into place — concurrent writers to the same path never
|
||||
/// observe a torn file, and the last rename wins.
|
||||
pub async fn put_files(
|
||||
/// POST /files — upload file(s) via multipart
|
||||
pub async fn post_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<FileParams>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
let path_str = params.path.as_deref().unwrap_or("");
|
||||
if path_str.is_empty() {
|
||||
return json_error(StatusCode::BAD_REQUEST, "missing required 'path' parameter");
|
||||
}
|
||||
let header_token = extract_header_token(&req);
|
||||
|
||||
let default_user = state.defaults.user();
|
||||
let username =
|
||||
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let username = match execcontext::resolve_default_username(
|
||||
params.username.as_deref(),
|
||||
&default_user,
|
||||
) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
|
||||
if let Err(e) = validate_file_signing(
|
||||
&state,
|
||||
@ -212,42 +252,108 @@ pub async fn put_files(
|
||||
let home_dir = user.dir.to_string_lossy().to_string();
|
||||
let uid = user.uid;
|
||||
let gid = user.gid;
|
||||
let default_workdir = state.defaults.workdir();
|
||||
|
||||
let file_path = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
let content_enc = match encoding::parse_content_encoding(&req) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
|
||||
if let Err((status, msg)) = stream_to_file(req.into_body(), &file_path, uid, gid).await {
|
||||
return json_error(status, &msg);
|
||||
let mut multipart = match axum::extract::Multipart::from_request(req, &()).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error parsing multipart: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mut uploaded: Vec<EntryInfo> = Vec::new();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
if field_name != "file" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_path = if !path_str.is_empty() {
|
||||
match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
}
|
||||
} else {
|
||||
let fname = field
|
||||
.file_name()
|
||||
.unwrap_or("upload")
|
||||
.to_string();
|
||||
match expand_and_resolve(&fname, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
}
|
||||
};
|
||||
|
||||
if uploaded.iter().any(|e| e.path == file_path) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("cannot upload multiple files to same path '{}'", file_path),
|
||||
);
|
||||
}
|
||||
|
||||
let raw_bytes = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error reading field: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let data = if content_enc == "gzip" {
|
||||
use std::io::Read;
|
||||
let mut decoder = flate2::read::GzDecoder::new(&raw_bytes[..]);
|
||||
let mut buf = Vec::new();
|
||||
match decoder.read_to_end(&mut buf) {
|
||||
Ok(_) => buf,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("gzip decompression failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
raw_bytes.to_vec()
|
||||
};
|
||||
|
||||
if let Err(e) = process_file(&file_path, &data, uid, gid) {
|
||||
let (status, msg) = e;
|
||||
return json_error(status, &msg);
|
||||
}
|
||||
|
||||
let name = Path::new(&file_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
uploaded.push(EntryInfo {
|
||||
path: file_path,
|
||||
name,
|
||||
r#type: "file",
|
||||
});
|
||||
}
|
||||
|
||||
let name = Path::new(&file_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
axum::Json(EntryInfo {
|
||||
path: file_path,
|
||||
name,
|
||||
r#type: "file",
|
||||
})
|
||||
.into_response()
|
||||
axum::Json(uploaded).into_response()
|
||||
}
|
||||
|
||||
/// Stream a request body to `path` via a temp file + atomic rename. The staging
|
||||
/// file is created with mode 0o666 and chowned to (uid, gid) before the rename,
|
||||
/// so the destination appears atomically with the correct owner.
|
||||
async fn stream_to_file(
|
||||
body: Body,
|
||||
fn process_file(
|
||||
path: &str,
|
||||
data: &[u8],
|
||||
uid: nix::unistd::Uid,
|
||||
gid: nix::unistd::Gid,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let target = Path::new(path);
|
||||
|
||||
let dir = target
|
||||
let dir = Path::new(path)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
@ -261,89 +367,46 @@ async fn stream_to_file(
|
||||
})?;
|
||||
}
|
||||
|
||||
// Reject writing over an existing directory before staging anything.
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("path is a directory: {path}"),
|
||||
));
|
||||
let can_pre_chown = match std::fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.is_dir() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("path is a directory: {path}"),
|
||||
));
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error getting file info: {e}"),
|
||||
));
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mut chowned = false;
|
||||
if can_pre_chown {
|
||||
match std::os::unix::fs::chown(path, Some(uid.as_raw()), Some(gid.as_raw())) {
|
||||
Ok(()) => chowned = true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage in the destination directory so the rename stays on one filesystem.
|
||||
let seq = UPLOAD_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp_name = format!(".envd-upload.{}.{}", std::process::id(), seq);
|
||||
let tmp_path = if dir.is_empty() {
|
||||
tmp_name
|
||||
} else {
|
||||
format!("{dir}/{tmp_name}")
|
||||
};
|
||||
|
||||
let map_open_err = |e: std::io::Error| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
"not enough disk space available".to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening file: {e}"),
|
||||
)
|
||||
};
|
||||
|
||||
let std_file = std::fs::OpenOptions::new()
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o666)
|
||||
.open(&tmp_path)
|
||||
.map_err(map_open_err)?;
|
||||
|
||||
// From here on, any failure must clean up the staging file.
|
||||
let result = write_body_to_tmp(body, std_file, &tmp_path, uid, gid).await;
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return result.map(|_| ());
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp_path, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error finalizing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_body_to_tmp(
|
||||
body: Body,
|
||||
std_file: std::fs::File,
|
||||
tmp_path: &str,
|
||||
uid: nix::unistd::Uid,
|
||||
gid: nix::unistd::Gid,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let mut file = tokio::fs::File::from_std(std_file);
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("error reading request body: {e}"),
|
||||
)
|
||||
})?;
|
||||
file.write_all(&bytes).await.map_err(|e| {
|
||||
.open(path)
|
||||
.map_err(|e| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
@ -352,26 +415,33 @@ async fn write_body_to_tmp(
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error writing file: {e}"),
|
||||
format!("error opening file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !chowned {
|
||||
std::os::unix::fs::chown(path, Some(uid.as_raw()), Some(gid.as_raw())).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
file.flush().await.map_err(|e| {
|
||||
file.write_all(data).map_err(|e| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
"not enough disk space available".to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error flushing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// chown the staging file so it lands at the destination already owned by
|
||||
// the target user.
|
||||
std::os::unix::fs::chown(tmp_path, Some(uid.as_raw()), Some(gid.as_raw())).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
format!("error writing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
@ -26,9 +26,6 @@ pub struct InitRequest {
|
||||
pub volume_mounts: Option<Vec<VolumeMount>>,
|
||||
pub sandbox_id: Option<String>,
|
||||
pub template_id: Option<String>,
|
||||
/// Public proxy domain (e.g. "wrenn.dev"). Used by `envd ports` to build
|
||||
/// the {port}-{sandbox_id}.{domain} URLs.
|
||||
pub proxy_domain: Option<String>,
|
||||
/// New lifecycle identifier for this resume. When it changes between
|
||||
/// /init calls, envd treats the call as a post-resume hook: port
|
||||
/// forwarder is restarted and NFS mounts are refreshed.
|
||||
@ -86,20 +83,10 @@ pub async fn post_init(
|
||||
tracing::info!("lifecycle changed, restarting port subsystem");
|
||||
port_sub.restart();
|
||||
}
|
||||
// Instant wall-clock step on resume. The host wall time arrives in
|
||||
// init_req.timestamp; chrony's PHC refclock needs several poll cycles
|
||||
// (poll 2 = 4s) before it has a valid offset to step to, so makestep
|
||||
// alone leaves the clock stale for seconds after a resume. Set
|
||||
// CLOCK_REALTIME directly here for an immediate jump, then let chronyd
|
||||
// keep disciplining drift against /dev/ptp0.
|
||||
if let Some(ref ts_str) = init_req.timestamp {
|
||||
if let Ok(nanos) = parse_timestamp_to_nanos(ts_str) {
|
||||
step_realtime_clock(nanos);
|
||||
}
|
||||
}
|
||||
// Also nudge chrony to re-sync its internal offset against the
|
||||
// now-correct clock + PHC immediately, bypassing its slew period.
|
||||
// Best effort — the direct step above already corrected wall time.
|
||||
// Force chrony to step the clock immediately. chronyd is launched by
|
||||
// wrenn-init.sh and disciplines against PHC (/dev/ptp0), so the host
|
||||
// wall time is already available — `makestep` just bypasses chrony's
|
||||
// normal slewing and snaps the clock in one go. Best effort.
|
||||
tokio::spawn(async {
|
||||
match tokio::process::Command::new("chronyc")
|
||||
.args(["makestep"])
|
||||
@ -122,8 +109,7 @@ pub async fn post_init(
|
||||
|
||||
// Idempotent timestamp check. Run after lifecycle handling so a
|
||||
// stale-timestamp /init still gets to refresh ports + step clock.
|
||||
// The actual clock step happens in the lifecycle block above; this
|
||||
// only gates the rest of the apply path on monotonic timestamps.
|
||||
// No userspace clock_settime here — chrony owns time discipline.
|
||||
if let Some(ref ts_str) = init_req.timestamp {
|
||||
if let Ok(ts) = parse_timestamp_to_nanos(ts_str) {
|
||||
if !state.last_set_time.set_to_greater(ts) {
|
||||
@ -197,32 +183,14 @@ pub async fn post_init(
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_SANDBOX_ID", id) };
|
||||
write_run_file(".WRENN_SANDBOX_ID", id);
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_SANDBOX_ID".into(), id.clone());
|
||||
state.defaults.env_vars.insert("WRENN_SANDBOX_ID".into(), id.clone());
|
||||
}
|
||||
if let Some(ref id) = init_req.template_id {
|
||||
tracing::debug!(template_id = %id, "setting template ID from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_TEMPLATE_ID", id) };
|
||||
write_run_file(".WRENN_TEMPLATE_ID", id);
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_TEMPLATE_ID".into(), id.clone());
|
||||
}
|
||||
if let Some(ref domain) = init_req.proxy_domain {
|
||||
if !domain.is_empty() {
|
||||
tracing::debug!(proxy_domain = %domain, "setting proxy domain from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_PROXY_DOMAIN", domain) };
|
||||
write_run_file(".WRENN_PROXY_DOMAIN", domain);
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_PROXY_DOMAIN".into(), domain.clone());
|
||||
}
|
||||
state.defaults.env_vars.insert("WRENN_TEMPLATE_ID".into(), id.clone());
|
||||
}
|
||||
|
||||
(
|
||||
@ -234,10 +202,7 @@ pub async fn post_init(
|
||||
|
||||
async fn validate_init_access_token(state: &AppState, request_token: &str) -> Result<(), String> {
|
||||
// Fast path: matches existing token
|
||||
if state.access_token.is_set()
|
||||
&& !request_token.is_empty()
|
||||
&& state.access_token.equals(request_token)
|
||||
{
|
||||
if state.access_token.is_set() && !request_token.is_empty() && state.access_token.equals(request_token) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -276,7 +241,10 @@ async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
env_vars.insert("WRENN_EVENTS_ADDRESS".into(), format!("http://{address}"));
|
||||
env_vars.insert(
|
||||
"WRENN_EVENTS_ADDRESS".into(),
|
||||
format!("http://{address}"),
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_nfs(nfs_target: &str, path: &str) {
|
||||
@ -319,7 +287,7 @@ async fn setup_nfs(nfs_target: &str, path: &str) {
|
||||
}
|
||||
|
||||
fn write_run_file(name: &str, value: &str) {
|
||||
let dir = std::path::Path::new(crate::config::WRENN_RUN_DIR);
|
||||
let dir = std::path::Path::new("/run/wrenn");
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
tracing::warn!(error = %e, "failed to create /run/wrenn");
|
||||
return;
|
||||
@ -329,30 +297,6 @@ fn write_run_file(name: &str, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard-steps CLOCK_REALTIME to `nanos` since the Unix epoch. Requires
|
||||
/// CAP_SYS_TIME, which envd has as PID 1 in the guest. Best effort — on
|
||||
/// failure the clock is left for chrony to discipline against the PHC.
|
||||
// libc::time_t is deprecated pending musl 1.2's 64-bit switch, but the
|
||||
// timespec.tv_sec field is still typed as time_t on this target.
|
||||
#[allow(deprecated)]
|
||||
fn step_realtime_clock(nanos: i64) {
|
||||
let ts = libc::timespec {
|
||||
tv_sec: (nanos / 1_000_000_000) as libc::time_t,
|
||||
tv_nsec: (nanos % 1_000_000_000) as libc::c_long,
|
||||
};
|
||||
// SAFETY: ts is a valid timespec; CLOCK_REALTIME is settable as root.
|
||||
let rc = unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &ts) };
|
||||
if rc != 0 {
|
||||
tracing::warn!(error = %std::io::Error::last_os_error(),
|
||||
"clock_settime(CLOCK_REALTIME) failed");
|
||||
} else {
|
||||
tracing::info!(
|
||||
nanos,
|
||||
"stepped CLOCK_REALTIME from host timestamp on resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a host-provided timestamp into nanoseconds since the Unix epoch.
|
||||
/// Accepts either RFC3339 (`2026-05-17T16:13:03.123456Z`) or a float-seconds
|
||||
/// string (legacy callers).
|
||||
@ -365,3 +309,4 @@ fn parse_timestamp_to_nanos(ts: &str) -> Result<i64, ()> {
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
pub mod activity;
|
||||
pub mod encoding;
|
||||
pub mod envs;
|
||||
pub mod error;
|
||||
pub mod files;
|
||||
@ -13,8 +13,8 @@ use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use http::Method;
|
||||
use http::header::{CACHE_CONTROL, HeaderName};
|
||||
use http::Method;
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::config::CORS_MAX_AGE;
|
||||
@ -47,7 +47,6 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health::get_health))
|
||||
.route("/activity", get(activity::get_activity))
|
||||
.route("/metrics", get(metrics::get_metrics))
|
||||
.route("/envs", get(envs::get_envs))
|
||||
.route("/init", post(init::post_init))
|
||||
@ -60,7 +59,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
"/memory/preload/cancel",
|
||||
post(memory::post_memory_preload_cancel),
|
||||
)
|
||||
.route("/files", get(files::get_files).put(files::put_files))
|
||||
.route("/files", get(files::get_files).post(files::post_files))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@ -46,12 +46,11 @@ pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl I
|
||||
tracing::warn!(error = %e, "drop_caches (second pass) failed (continuing)");
|
||||
}
|
||||
|
||||
// No balloon settle window here: free-page reporting drains asynchronously,
|
||||
// so any pages not yet hole-punched by the host at snapshot time are written
|
||||
// verbatim — but with init_on_free=1 the guest zeroes them on free, and the
|
||||
// host-side background zero-page punch reclaims them off the pause critical
|
||||
// path. Trading a fixed ~1s of pause latency for a slightly larger artifact
|
||||
// that the async punch later shrinks anyway.
|
||||
// Free-page reporting drains asynchronously: the balloon driver hands
|
||||
// freed pages to the host in batches and CH punches holes in the backing
|
||||
// memfile. Without a brief settle window most of the pages freed by the
|
||||
// drop_caches passes above would still be present in the snapshot.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
tracing::info!("snapshot/prepare: quiesced");
|
||||
(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
mod auth;
|
||||
mod cgroups;
|
||||
mod cmd;
|
||||
mod config;
|
||||
mod conntracker;
|
||||
mod crypto;
|
||||
@ -40,10 +39,6 @@ const COMMIT: &str = {
|
||||
#[derive(Parser)]
|
||||
#[command(name = "envd", about = "Wrenn guest agent daemon")]
|
||||
struct Cli {
|
||||
/// Client subcommand. When omitted, envd runs as the guest daemon.
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
@ -60,12 +55,6 @@ struct Cli {
|
||||
cgroup_root: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Commands {
|
||||
/// List externally-reachable open ports and the URL each is served at.
|
||||
Ports(cmd::ports::PortsArgs),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
@ -79,11 +68,6 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Client subcommands are short-lived: run and exit before any daemon setup.
|
||||
if let Some(Commands::Ports(args)) = &cli.command {
|
||||
std::process::exit(cmd::ports::run(args));
|
||||
}
|
||||
|
||||
logging::init(true);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(WRENN_RUN_DIR) {
|
||||
@ -101,35 +85,36 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Cgroup manager
|
||||
let cgroup_manager: Arc<dyn cgroups::CgroupManager> = match cgroups::Cgroup2Manager::new(
|
||||
&cli.cgroup_root,
|
||||
&[
|
||||
(
|
||||
cgroups::ProcessType::Pty,
|
||||
"wrenn/pty",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::User,
|
||||
"wrenn/user",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::Socat,
|
||||
"wrenn/socat",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
],
|
||||
) {
|
||||
Ok(m) => {
|
||||
tracing::info!("cgroup2 manager initialized");
|
||||
Arc::new(m)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
||||
Arc::new(cgroups::NoopCgroupManager)
|
||||
}
|
||||
};
|
||||
let cgroup_manager: Arc<dyn cgroups::CgroupManager> =
|
||||
match cgroups::Cgroup2Manager::new(
|
||||
&cli.cgroup_root,
|
||||
&[
|
||||
(
|
||||
cgroups::ProcessType::Pty,
|
||||
"wrenn/pty",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::User,
|
||||
"wrenn/user",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::Socat,
|
||||
"wrenn/socat",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
],
|
||||
) {
|
||||
Ok(m) => {
|
||||
tracing::info!("cgroup2 manager initialized");
|
||||
Arc::new(m)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
||||
Arc::new(cgroups::NoopCgroupManager)
|
||||
}
|
||||
};
|
||||
|
||||
// Port subsystem
|
||||
let port_subsystem = Arc::new(PortSubsystem::new(Arc::clone(&cgroup_manager)));
|
||||
@ -153,7 +138,8 @@ async fn main() {
|
||||
// RPC services (Connect protocol — serves Connect + gRPC + gRPC-Web on same port)
|
||||
let connect_router = rpc::rpc_router(Arc::clone(&state));
|
||||
|
||||
let app = http::router(Arc::clone(&state)).fallback_service(connect_router.into_axum_service());
|
||||
let app = http::router(Arc::clone(&state))
|
||||
.fallback_service(connect_router.into_axum_service());
|
||||
|
||||
// --cmd: spawn initial process if specified
|
||||
if !cli.start_cmd.is_empty() {
|
||||
@ -165,12 +151,7 @@ async fn main() {
|
||||
}
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
|
||||
tracing::info!(
|
||||
port = cli.port,
|
||||
version = VERSION,
|
||||
commit = COMMIT,
|
||||
"envd starting"
|
||||
);
|
||||
tracing::info!(port = cli.port, version = VERSION, commit = COMMIT, "envd starting");
|
||||
|
||||
let listener = TcpListener::bind(addr).await.expect("failed to bind");
|
||||
|
||||
@ -205,7 +186,9 @@ fn spawn_initial_command(cmd: &str, state: &AppState) {
|
||||
|
||||
let home = user.dir.to_string_lossy().to_string();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
let cwd = default_workdir.as_deref().unwrap_or(&home);
|
||||
let cwd = default_workdir
|
||||
.as_deref()
|
||||
.unwrap_or(&home);
|
||||
|
||||
match process_handler::spawn_process(
|
||||
cmd,
|
||||
@ -252,7 +235,8 @@ fn memory_reclaimer(_state: Arc<AppState>) {
|
||||
} else {
|
||||
let mut sys2 = sysinfo::System::new();
|
||||
sys2.refresh_memory();
|
||||
let freed_mb = sys2.available_memory().saturating_sub(available) / (1024 * 1024);
|
||||
let freed_mb =
|
||||
sys2.available_memory().saturating_sub(available) / (1024 * 1024);
|
||||
tracing::info!(used_pct, freed_mb, "page cache dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
pub mod path;
|
||||
pub mod user;
|
||||
pub mod path;
|
||||
|
||||
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use nix::unistd::{Gid, Uid};
|
||||
|
||||
pub(crate) fn expand_tilde(path: &str, home_dir: &str) -> Result<String, String> {
|
||||
fn expand_tilde(path: &str, home_dir: &str) -> Result<String, String> {
|
||||
if path.is_empty() || !path.starts_with('~') {
|
||||
return Ok(path.to_string());
|
||||
}
|
||||
@ -94,10 +94,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_path() {
|
||||
assert_eq!(
|
||||
expand_tilde("~/docs", "/home/user").unwrap(),
|
||||
"/home/user/docs"
|
||||
);
|
||||
assert_eq!(expand_tilde("~/docs", "/home/user").unwrap(), "/home/user/docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -112,59 +109,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tilde_relative_no_tilde() {
|
||||
assert_eq!(
|
||||
expand_tilde("relative/path", "/home/u").unwrap(),
|
||||
"relative/path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_cmd_like() {
|
||||
assert_eq!(
|
||||
expand_tilde("~/bin/myapp", "/home/user").unwrap(),
|
||||
"/home/user/bin/myapp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_bare_path_arg() {
|
||||
assert_eq!(expand_tilde("~", "/home/user").unwrap(), "/home/user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_only() {
|
||||
assert_eq!(expand_tilde("~/", "/home/u").unwrap(), "/home/u/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_embedded_not_expanded() {
|
||||
assert_eq!(expand_tilde("/a/~/b", "/home/u").unwrap(), "/a/~/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_long_home_dir() {
|
||||
assert_eq!(
|
||||
expand_tilde("~/code/project", "/very/long/home/directory/path").unwrap(),
|
||||
"/very/long/home/directory/path/code/project"
|
||||
);
|
||||
assert_eq!(expand_tilde("relative/path", "/home/u").unwrap(), "relative/path");
|
||||
}
|
||||
|
||||
// expand_and_resolve
|
||||
|
||||
#[test]
|
||||
fn resolve_absolute_passthrough() {
|
||||
assert_eq!(
|
||||
expand_and_resolve("/abs/path", "/home", None).unwrap(),
|
||||
"/abs/path"
|
||||
);
|
||||
assert_eq!(expand_and_resolve("/abs/path", "/home", None).unwrap(), "/abs/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_empty_uses_default() {
|
||||
assert_eq!(
|
||||
expand_and_resolve("", "/home", Some("/default")).unwrap(),
|
||||
"/default"
|
||||
);
|
||||
assert_eq!(expand_and_resolve("", "/home", Some("/default")).unwrap(), "/default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -176,10 +133,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_tilde_expands() {
|
||||
assert_eq!(
|
||||
expand_and_resolve("~/dir", "/home/u", None).unwrap(),
|
||||
"/home/u/dir"
|
||||
);
|
||||
assert_eq!(expand_and_resolve("~/dir", "/home/u", None).unwrap(), "/home/u/dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -37,36 +37,6 @@ pub fn read_tcp_connections() -> Vec<ConnStat> {
|
||||
conns
|
||||
}
|
||||
|
||||
/// Returns the TCP ports in LISTEN state that are reachable from outside the
|
||||
/// guest through the host proxy. A port qualifies when it is bound to a
|
||||
/// wildcard address (`0.0.0.0`/`::`, directly reachable on the TAP interface)
|
||||
/// or to loopback (`127.0.0.1`/`::1`, bridged to the TAP IP by the socat
|
||||
/// forwarder). Ports bound to any other specific address are not routable from
|
||||
/// the host and are excluded, as is `exclude_port` (envd's own control port).
|
||||
/// The result is deduplicated and sorted ascending.
|
||||
pub fn reachable_listening_ports(exclude_port: u32) -> Vec<u32> {
|
||||
filter_reachable_ports(&read_tcp_connections(), exclude_port)
|
||||
}
|
||||
|
||||
fn filter_reachable_ports(conns: &[ConnStat], exclude_port: u32) -> Vec<u32> {
|
||||
let mut ports: Vec<u32> = conns
|
||||
.iter()
|
||||
.filter(|c| c.status == "LISTEN")
|
||||
.filter(|c| is_reachable_bind(&c.local_ip))
|
||||
.map(|c| c.local_port)
|
||||
.filter(|p| *p != exclude_port)
|
||||
.collect();
|
||||
ports.sort_unstable();
|
||||
ports.dedup();
|
||||
ports
|
||||
}
|
||||
|
||||
/// A bind address is reachable from the host when it is a wildcard (directly
|
||||
/// routed via the TAP interface) or loopback (socat-forwarded to the TAP IP).
|
||||
fn is_reachable_bind(ip: &str) -> bool {
|
||||
matches!(ip, "0.0.0.0" | "::" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
fn parse_proc_net_tcp(path: &str, family: u32) -> io::Result<Vec<ConnStat>> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = io::BufReader::new(file);
|
||||
@ -122,10 +92,7 @@ fn parse_hex_addr(s: &str, family: u32) -> Option<(String, u32)> {
|
||||
if ip_bytes.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
format!(
|
||||
"{}.{}.{}.{}",
|
||||
ip_bytes[3], ip_bytes[2], ip_bytes[1], ip_bytes[0]
|
||||
)
|
||||
format!("{}.{}.{}.{}", ip_bytes[3], ip_bytes[2], ip_bytes[1], ip_bytes[0])
|
||||
} else {
|
||||
if ip_bytes.len() != 16 {
|
||||
return None;
|
||||
@ -290,76 +257,4 @@ mod tests {
|
||||
fn parse_nonexistent_file_errors() {
|
||||
assert!(parse_proc_net_tcp("/nonexistent/path", libc::AF_INET as u32).is_err());
|
||||
}
|
||||
|
||||
// reachable port filtering
|
||||
|
||||
fn conn(ip: &str, port: u32, status: &str) -> ConnStat {
|
||||
ConnStat {
|
||||
local_ip: ip.to_string(),
|
||||
local_port: port,
|
||||
status: status.to_string(),
|
||||
family: libc::AF_INET as u32,
|
||||
inode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_bind_accepts_wildcard_and_loopback() {
|
||||
assert!(is_reachable_bind("0.0.0.0"));
|
||||
assert!(is_reachable_bind("::"));
|
||||
assert!(is_reachable_bind("127.0.0.1"));
|
||||
assert!(is_reachable_bind("::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_bind_rejects_specific_address() {
|
||||
assert!(!is_reachable_bind("192.168.1.5"));
|
||||
assert!(!is_reachable_bind("169.254.0.21"));
|
||||
assert!(!is_reachable_bind("10.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_only_listen_state() {
|
||||
let conns = vec![
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 9000, "ESTABLISHED"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_excludes_unreachable_binds() {
|
||||
let conns = vec![
|
||||
conn("127.0.0.1", 8000, "LISTEN"),
|
||||
conn("169.254.0.21", 8001, "LISTEN"), // socat's own listener
|
||||
conn("192.168.1.5", 8002, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_excludes_envd_control_port() {
|
||||
let conns = vec![
|
||||
conn("0.0.0.0", 49983, "LISTEN"),
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dedups_and_sorts() {
|
||||
// Same port on IPv4 wildcard and IPv6 loopback collapses to one entry.
|
||||
let conns = vec![
|
||||
conn("::1", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 3000, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![3000, 8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_empty_when_no_listeners() {
|
||||
let conns = vec![conn("0.0.0.0", 8000, "ESTABLISHED")];
|
||||
assert!(filter_reachable_ports(&conns, 49983).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,7 +53,9 @@ pub fn build_entry_info(path: &str) -> Result<EntryInfo, ConnectError> {
|
||||
Err(_) => FileType::FILE_TYPE_UNSPECIFIED,
|
||||
};
|
||||
|
||||
let target_mode = std::fs::metadata(p).map(|m| m.mode() & 0o7777).unwrap_or(0);
|
||||
let target_mode = std::fs::metadata(p)
|
||||
.map(|m| m.mode() & 0o7777)
|
||||
.unwrap_or(0);
|
||||
|
||||
(target_type, target_mode, Some(target))
|
||||
} else {
|
||||
|
||||
@ -98,7 +98,8 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
ensure_dirs(&path, user.uid, user.gid)
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
@ -122,7 +123,8 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
let destination = self.resolve_path(request.destination, &ctx)?;
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
if let Some(parent) = Path::new(&destination).parent() {
|
||||
ensure_dirs(&parent.to_string_lossy(), user.uid, user.gid)
|
||||
@ -204,12 +206,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
RemoveResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((RemoveResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
|
||||
async fn watch_dir(
|
||||
@ -250,8 +247,8 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
let events: Arc<Mutex<Vec<FilesystemEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_cb = Arc::clone(&events);
|
||||
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||
let mut watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let event_type = match event.kind {
|
||||
notify::EventKind::Create(_) => EventType::EVENT_TYPE_CREATE,
|
||||
@ -278,13 +275,11 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| {
|
||||
ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("failed to create watcher: {e}"),
|
||||
)
|
||||
})?;
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("failed to create watcher: {e}"))
|
||||
})?;
|
||||
|
||||
let mode = if recursive {
|
||||
RecursiveMode::Recursive
|
||||
@ -347,12 +342,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
) -> Result<(RemoveWatcherResponse, Context), ConnectError> {
|
||||
let watcher_id: &str = request.watcher_id;
|
||||
self.watchers.remove(watcher_id);
|
||||
Ok((
|
||||
RemoveWatcherResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((RemoveWatcherResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
pub mod entry;
|
||||
pub mod filesystem_service;
|
||||
pub mod pb;
|
||||
pub mod entry;
|
||||
pub mod process_handler;
|
||||
pub mod process_service;
|
||||
pub mod filesystem_service;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::rpc::filesystem_service::FilesystemServiceImpl;
|
||||
use crate::rpc::process_service::ProcessServiceImpl;
|
||||
use crate::rpc::filesystem_service::FilesystemServiceImpl;
|
||||
use crate::state::AppState;
|
||||
|
||||
use pb::filesystem::FilesystemExt;
|
||||
use pb::process::ProcessExt;
|
||||
use pb::filesystem::FilesystemExt;
|
||||
|
||||
/// Build the connect-rust Router with both RPC services registered.
|
||||
pub fn rpc_router(state: Arc<AppState>) -> connectrpc::Router {
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
#![allow(
|
||||
dead_code,
|
||||
non_camel_case_types,
|
||||
unused_imports,
|
||||
clippy::derivable_impls
|
||||
)]
|
||||
#![allow(dead_code, non_camel_case_types, unused_imports, clippy::derivable_impls)]
|
||||
|
||||
use ::buffa;
|
||||
use ::buffa_types;
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use connectrpc::{ConnectError, ErrorCode};
|
||||
use nix::pty::{Winsize, openpty};
|
||||
use nix::pty::{openpty, Winsize};
|
||||
use nix::sys::signal::{self, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use tokio::sync::broadcast;
|
||||
@ -18,20 +15,6 @@ const STD_CHUNK_SIZE: usize = 32768;
|
||||
const PTY_CHUNK_SIZE: usize = 16384;
|
||||
const BROADCAST_CAPACITY: usize = 4096;
|
||||
|
||||
// Coalescing window for output reads. After the first read of a burst we keep
|
||||
// draining whatever is already available on the fd for up to COALESCE_FLUSH_MS
|
||||
// (or until COALESCE_CAP bytes accumulate), then publish one merged chunk. This
|
||||
// collapses a full-screen TUI redraw — which the app emits as many small writes
|
||||
// — into a single stream message, cutting per-message framing/encoding overhead
|
||||
// across the whole path. Total added latency per flush is bounded by the window.
|
||||
const COALESCE_FLUSH_MS: u64 = 4;
|
||||
const COALESCE_CAP: usize = 64 * 1024;
|
||||
|
||||
// Upper bound on the per-process output kept for replay. A late Connect gets
|
||||
// the most recent OUTPUT_LOG_CAPACITY bytes (older output is evicted) so the
|
||||
// buffer can never grow without bound for a chatty long-running process.
|
||||
const OUTPUT_LOG_CAPACITY: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DataEvent {
|
||||
Stdout(Vec<u8>),
|
||||
@ -47,103 +30,6 @@ pub struct EndEvent {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Bounded ring of recent output, kept so a late Connect can replay what it
|
||||
/// missed. Evicts oldest events once the retained bytes exceed the cap.
|
||||
#[derive(Default)]
|
||||
struct OutputLog {
|
||||
events: VecDeque<DataEvent>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl OutputLog {
|
||||
fn push(&mut self, ev: &DataEvent) {
|
||||
self.bytes += ev_len(ev);
|
||||
self.events.push_back(ev.clone());
|
||||
while self.bytes > OUTPUT_LOG_CAPACITY {
|
||||
match self.events.pop_front() {
|
||||
Some(old) => self.bytes -= ev_len(&old),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<DataEvent> {
|
||||
self.events.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn ev_len(ev: &DataEvent) -> usize {
|
||||
match ev {
|
||||
DataEvent::Stdout(d) | DataEvent::Stderr(d) | DataEvent::Pty(d) => d.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking read loop that coalesces bursts: once a read returns, keep draining
|
||||
/// whatever is already available on the fd (bounded by [`COALESCE_FLUSH_MS`] and
|
||||
/// [`COALESCE_CAP`]) before handing the accumulated bytes to `publish`. A full
|
||||
/// TUI redraw — emitted by the app as many small writes — collapses into one
|
||||
/// output message instead of dozens. Latency added per flush is bounded by the
|
||||
/// window, so interactive feel is preserved even for a slow trickle of output.
|
||||
fn coalesce_read_loop<R, F>(mut reader: R, chunk_size: usize, mut publish: F)
|
||||
where
|
||||
R: Read + AsRawFd,
|
||||
F: FnMut(Vec<u8>),
|
||||
{
|
||||
let fd = reader.as_raw_fd();
|
||||
let mut rbuf = vec![0u8; chunk_size];
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut rbuf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
acc.extend_from_slice(&rbuf[..n]);
|
||||
let deadline = Instant::now() + Duration::from_millis(COALESCE_FLUSH_MS);
|
||||
while acc.len() < COALESCE_CAP {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let mut pfd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
|
||||
if r < 0 {
|
||||
// Interrupted by a signal (envd runs as PID 1, so SIGCHLD
|
||||
// et al. land here): retry within the remaining window
|
||||
// instead of cutting the coalesce burst short.
|
||||
let errno = std::io::Error::last_os_error().raw_os_error();
|
||||
if errno == Some(libc::EINTR) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if r == 0 || (pfd.revents & libc::POLLIN) == 0 {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut rbuf) {
|
||||
Ok(0) => {
|
||||
if !acc.is_empty() {
|
||||
publish(std::mem::take(&mut acc));
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(m) => acc.extend_from_slice(&rbuf[..m]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
publish(std::mem::take(&mut acc));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if !acc.is_empty() {
|
||||
publish(acc);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProcessHandle {
|
||||
pub config: ProcessConfig,
|
||||
pub tag: Option<String>,
|
||||
@ -152,7 +38,6 @@ pub struct ProcessHandle {
|
||||
data_tx: broadcast::Sender<DataEvent>,
|
||||
end_tx: broadcast::Sender<EndEvent>,
|
||||
ended: Mutex<Option<EndEvent>>,
|
||||
output_log: Mutex<OutputLog>,
|
||||
|
||||
stdin: Mutex<Option<std::process::ChildStdin>>,
|
||||
pty_master: Mutex<Option<std::fs::File>>,
|
||||
@ -163,26 +48,6 @@ impl ProcessHandle {
|
||||
self.data_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Append a chunk to the replay buffer and broadcast it live, under one
|
||||
/// lock. The shared lock is what makes [`subscribe_data_replay`] race-free:
|
||||
/// a concurrent attach sees this chunk either in its snapshot or on its live
|
||||
/// receiver — never both, never neither.
|
||||
pub fn publish_data(&self, ev: DataEvent) {
|
||||
let mut log = self.output_log.lock().unwrap();
|
||||
log.push(&ev);
|
||||
let _ = self.data_tx.send(ev);
|
||||
}
|
||||
|
||||
/// Snapshot the buffered output and subscribe to live output atomically, so
|
||||
/// a late Connect replays what it missed and then continues live with no gap
|
||||
/// or duplicate across the handoff.
|
||||
pub fn subscribe_data_replay(&self) -> (Vec<DataEvent>, broadcast::Receiver<DataEvent>) {
|
||||
let log = self.output_log.lock().unwrap();
|
||||
let snapshot = log.snapshot();
|
||||
let rx = self.data_tx.subscribe();
|
||||
(snapshot, rx)
|
||||
}
|
||||
|
||||
pub fn subscribe_end(&self) -> broadcast::Receiver<EndEvent> {
|
||||
self.end_tx.subscribe()
|
||||
}
|
||||
@ -295,9 +160,6 @@ pub fn spawn_process(
|
||||
env.push(("HOME".into(), home));
|
||||
env.push(("USER".into(), user.name.clone()));
|
||||
env.push(("LOGNAME".into(), user.name.clone()));
|
||||
if !user.shell.as_os_str().is_empty() {
|
||||
env.push(("SHELL".into(), user.shell.to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
default_env_vars.iter().for_each(|entry| {
|
||||
env.push((entry.key().clone(), entry.value().clone()));
|
||||
@ -315,47 +177,14 @@ pub fn spawn_process(
|
||||
// commands run as a non-root user. Writing 100 to the process's own
|
||||
// oom_score_adj is always permitted (raising the score).
|
||||
let nice_delta = 0 - current_nice();
|
||||
let profile_source = r#"test -f /etc/profile && . /etc/profile
|
||||
test -f "${HOME}/.bashrc" && . "${HOME}/.bashrc""#;
|
||||
|
||||
// Resolve the user's login shell, falling back to /bin/sh. Commands without
|
||||
// explicit args are interpreted by this shell so pipes, quoting, escape
|
||||
// sequences, backslash line-continuations, and other shell syntax work
|
||||
// without the caller having to wrap them in `sh -c` themselves.
|
||||
let shell = {
|
||||
let s = user.shell.to_string_lossy();
|
||||
if s.is_empty() {
|
||||
"/bin/sh".to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// What the wrapper finally exec's, after the optional `nice` prefix.
|
||||
// - no args: run cmd_str as a shell command line via the login shell
|
||||
// ($1 is cmd_str; $0 of the inner shell is the shell path).
|
||||
// - with args: exec the program + args directly, no shell interpretation
|
||||
// (backward-compatible program/argv form).
|
||||
let target = if cmd_str.is_empty() && args.is_empty() {
|
||||
// No command at all (e.g. an interactive PTY session with no explicit
|
||||
// command): launch the user's login shell directly. Under a pty its
|
||||
// stdin is a tty, so it starts interactively.
|
||||
format!(r#""{shell}""#)
|
||||
} else if args.is_empty() {
|
||||
format!(r#""{shell}" -c "$1" "{shell}""#)
|
||||
let oom_script = if nice_delta > 0 {
|
||||
format!(
|
||||
r#"echo 100 > /proc/$$/oom_score_adj && exec /usr/bin/nice -n {} "${{@}}""#,
|
||||
nice_delta
|
||||
)
|
||||
} else {
|
||||
r#""$@""#.to_string()
|
||||
r#"echo 100 > /proc/$$/oom_score_adj && exec "$@""#.to_string()
|
||||
};
|
||||
let nice_prefix = if nice_delta > 0 {
|
||||
format!("/usr/bin/nice -n {nice_delta} ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let oom_script = format!(
|
||||
r#"echo 100 > /proc/$$/oom_score_adj
|
||||
{profile_source}
|
||||
exec {nice_prefix}{target}"#
|
||||
);
|
||||
let mut wrapper_args = vec![
|
||||
"-c".to_string(),
|
||||
oom_script,
|
||||
@ -393,7 +222,7 @@ exec {nice_prefix}{target}"#
|
||||
let master_fd = pty_result.master;
|
||||
let slave_fd = pty_result.slave;
|
||||
|
||||
let mut command = std::process::Command::new(&shell);
|
||||
let mut command = std::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(&wrapper_args)
|
||||
.env_clear()
|
||||
@ -426,10 +255,7 @@ exec {nice_prefix}{target}"#
|
||||
command.stderr(Stdio::null());
|
||||
|
||||
let child = command.spawn().map_err(|e| {
|
||||
ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error starting pty process: {e}"),
|
||||
)
|
||||
ConnectError::new(ErrorCode::Internal, format!("error starting pty process: {e}"))
|
||||
})?;
|
||||
|
||||
drop(slave_fd);
|
||||
@ -445,7 +271,6 @@ exec {nice_prefix}{target}"#
|
||||
data_tx: data_tx.clone(),
|
||||
end_tx: end_tx.clone(),
|
||||
ended: Mutex::new(None),
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(None),
|
||||
pty_master: Mutex::new(Some(master_file)),
|
||||
});
|
||||
@ -453,11 +278,19 @@ exec {nice_prefix}{target}"#
|
||||
let data_rx = handle.subscribe_data();
|
||||
let end_rx = handle.subscribe_end();
|
||||
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
let data_tx_clone = data_tx.clone();
|
||||
let pty_reader = std::thread::spawn(move || {
|
||||
coalesce_read_loop(master_clone, PTY_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Pty(chunk));
|
||||
});
|
||||
let mut master = master_clone;
|
||||
let mut buf = vec![0u8; PTY_CHUNK_SIZE];
|
||||
loop {
|
||||
match master.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = data_tx_clone.send(DataEvent::Pty(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let end_tx_clone = end_tx.clone();
|
||||
@ -487,13 +320,9 @@ exec {nice_prefix}{target}"#
|
||||
});
|
||||
|
||||
tracing::info!(pid, cmd = cmd_str, "process started (pty)");
|
||||
Ok(SpawnedProcess {
|
||||
handle,
|
||||
data_rx,
|
||||
end_rx,
|
||||
})
|
||||
Ok(SpawnedProcess { handle, data_rx, end_rx })
|
||||
} else {
|
||||
let mut command = std::process::Command::new(&shell);
|
||||
let mut command = std::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(&wrapper_args)
|
||||
.env_clear()
|
||||
@ -537,7 +366,6 @@ exec {nice_prefix}{target}"#
|
||||
data_tx: data_tx.clone(),
|
||||
end_tx: end_tx.clone(),
|
||||
ended: Mutex::new(None),
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(stdin),
|
||||
pty_master: Mutex::new(None),
|
||||
});
|
||||
@ -547,21 +375,35 @@ exec {nice_prefix}{target}"#
|
||||
|
||||
let mut output_readers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
|
||||
if let Some(out) = stdout {
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
if let Some(mut out) = stdout {
|
||||
let tx = data_tx.clone();
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
coalesce_read_loop(out, STD_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Stdout(chunk));
|
||||
});
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match out.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = tx.send(DataEvent::Stdout(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(err_pipe) = stderr {
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
if let Some(mut err_pipe) = stderr {
|
||||
let tx = data_tx.clone();
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
coalesce_read_loop(err_pipe, STD_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Stderr(chunk));
|
||||
});
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match err_pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = tx.send(DataEvent::Stderr(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@ -593,11 +435,7 @@ exec {nice_prefix}{target}"#
|
||||
});
|
||||
|
||||
tracing::info!(pid, cmd = cmd_str, "process started (pipe)");
|
||||
Ok(SpawnedProcess {
|
||||
handle,
|
||||
data_rx,
|
||||
end_rx,
|
||||
})
|
||||
Ok(SpawnedProcess { handle, data_rx, end_rx })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,9 @@ use std::sync::Arc;
|
||||
|
||||
use connectrpc::{ConnectError, Context, ErrorCode};
|
||||
use dashmap::DashMap;
|
||||
use futures::{Stream, StreamExt};
|
||||
use tokio::sync::broadcast;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::permissions::path::{expand_and_resolve, expand_tilde};
|
||||
use crate::permissions::path::expand_and_resolve;
|
||||
use crate::permissions::user::lookup_user;
|
||||
use crate::rpc::pb::process::*;
|
||||
use crate::rpc::process_handler::{self, DataEvent, ProcessHandle};
|
||||
@ -72,16 +71,12 @@ impl ProcessServiceImpl {
|
||||
ConnectError::new(ErrorCode::InvalidArgument, "process config required")
|
||||
})?;
|
||||
|
||||
// Per-request user overrides the sandbox default when provided.
|
||||
let username = if proc_config.user.is_empty() {
|
||||
self.state.defaults.user()
|
||||
} else {
|
||||
proc_config.user.to_string()
|
||||
};
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let username = self.state.defaults.user();
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
let cmd_raw: &str = proc_config.cmd;
|
||||
let args_raw: Vec<String> = proc_config.args.iter().map(|s| s.to_string()).collect();
|
||||
let cmd: &str = proc_config.cmd;
|
||||
let args: Vec<String> = proc_config.args.iter().map(|s| s.to_string()).collect();
|
||||
let envs: HashMap<String, String> = proc_config
|
||||
.envs
|
||||
.iter()
|
||||
@ -89,14 +84,6 @@ impl ProcessServiceImpl {
|
||||
.collect();
|
||||
|
||||
let home_dir = user.dir.to_string_lossy().to_string();
|
||||
|
||||
let cmd = expand_tilde(cmd_raw, &home_dir)
|
||||
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))?;
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, &home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
|
||||
let cwd_str: &str = proc_config.cwd.unwrap_or("");
|
||||
let default_workdir = self.state.defaults.workdir();
|
||||
let cwd = expand_and_resolve(cwd_str, &home_dir, default_workdir.as_deref())
|
||||
@ -131,7 +118,7 @@ impl ProcessServiceImpl {
|
||||
);
|
||||
|
||||
let spawned = process_handler::spawn_process(
|
||||
&cmd,
|
||||
cmd,
|
||||
&args,
|
||||
&envs,
|
||||
effective_cwd,
|
||||
@ -142,8 +129,7 @@ impl ProcessServiceImpl {
|
||||
&self.state.defaults.env_vars,
|
||||
)?;
|
||||
|
||||
self.processes
|
||||
.insert(spawned.handle.pid, Arc::clone(&spawned.handle));
|
||||
self.processes.insert(spawned.handle.pid, Arc::clone(&spawned.handle));
|
||||
|
||||
let processes = Arc::clone(&self.processes);
|
||||
let pid = spawned.handle.pid;
|
||||
@ -210,10 +196,50 @@ impl Process for ProcessServiceImpl {
|
||||
let spawned = self.spawn_from_request(&request)?;
|
||||
let pid = spawned.handle.pid;
|
||||
|
||||
// Start subscribes before any output is produced, so there is nothing to
|
||||
// replay and the process cannot have ended yet.
|
||||
let stream = process_event_stream(pid, Vec::new(), spawned.data_rx, spawned.end_rx, None)
|
||||
.map(|r| r.map(wrap_start_response));
|
||||
let mut data_rx = spawned.data_rx;
|
||||
let mut end_rx = spawned.end_rx;
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
yield Ok(make_start_response(pid));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => yield Ok(make_data_start_response(ev)),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and its
|
||||
// handle was dropped. The end event is published
|
||||
// before the handle drop, so it is still buffered
|
||||
// — emit it rather than losing the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(make_end_start_response(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers
|
||||
// before sending this event, so every byte is already
|
||||
// in the data channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_start_response(ev)),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(make_end_start_response(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok((Box::pin(stream), ctx))
|
||||
}
|
||||
@ -235,17 +261,81 @@ impl Process for ProcessServiceImpl {
|
||||
let handle = self.get_process_by_selector(selector)?;
|
||||
let pid = handle.pid;
|
||||
|
||||
// Snapshot buffered output + subscribe live atomically, then read the
|
||||
// exit state. Ordering matters: end_rx must be subscribed before
|
||||
// cached_end is read so a process that exits in the window is still
|
||||
// observed (via the channel if subscribed in time, via cached_end
|
||||
// otherwise).
|
||||
let (replay, data_rx) = handle.subscribe_data_replay();
|
||||
let end_rx = handle.subscribe_end();
|
||||
let mut data_rx = handle.subscribe_data();
|
||||
let mut end_rx = handle.subscribe_end();
|
||||
let cached_end = handle.cached_end();
|
||||
|
||||
let stream = process_event_stream(pid, replay, data_rx, end_rx, cached_end)
|
||||
.map(|r| r.map(wrap_connect_response));
|
||||
let stream = async_stream::stream! {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent { pid, ..Default::default() },
|
||||
))),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(end) = cached_end {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and
|
||||
// its handle was dropped. The end event is
|
||||
// published before the handle drop, so it is
|
||||
// still buffered — emit it rather than losing
|
||||
// the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers
|
||||
// before sending this event, so every byte is already
|
||||
// in the data channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
}),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok((Box::pin(stream), ctx))
|
||||
}
|
||||
@ -266,12 +356,7 @@ impl Process for ProcessServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
UpdateResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((UpdateResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
|
||||
async fn stream_input(
|
||||
@ -280,11 +365,11 @@ impl Process for ProcessServiceImpl {
|
||||
mut requests: Pin<
|
||||
Box<
|
||||
dyn Stream<
|
||||
Item = Result<
|
||||
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
||||
ConnectError,
|
||||
>,
|
||||
> + Send,
|
||||
Item = Result<
|
||||
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
||||
ConnectError,
|
||||
>,
|
||||
> + Send,
|
||||
>,
|
||||
>,
|
||||
) -> Result<(StreamInputResponse, Context), ConnectError> {
|
||||
@ -313,12 +398,7 @@ impl Process for ProcessServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
StreamInputResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((StreamInputResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
|
||||
async fn send_input(
|
||||
@ -335,12 +415,7 @@ impl Process for ProcessServiceImpl {
|
||||
write_input(&handle, input)?;
|
||||
}
|
||||
|
||||
Ok((
|
||||
SendInputResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((SendInputResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
|
||||
async fn send_signal(
|
||||
@ -360,17 +435,12 @@ impl Process for ProcessServiceImpl {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::InvalidArgument,
|
||||
"invalid or unspecified signal",
|
||||
));
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
handle.send_signal(sig)?;
|
||||
Ok((
|
||||
SendSignalResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((SendSignalResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
|
||||
async fn close_stdin(
|
||||
@ -383,12 +453,7 @@ impl Process for ProcessServiceImpl {
|
||||
})?;
|
||||
let handle = self.get_process_by_selector(selector)?;
|
||||
handle.close_stdin()?;
|
||||
Ok((
|
||||
CloseStdinResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
Ok((CloseStdinResponse { ..Default::default() }, ctx))
|
||||
}
|
||||
}
|
||||
|
||||
@ -400,106 +465,17 @@ fn write_input(handle: &ProcessHandle, input: &ProcessInputView) -> Result<(), C
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared event pump for `Start` and `Connect`. Yields a leading start event,
|
||||
/// replays any buffered output (empty for `Start`), then forwards live output
|
||||
/// and the final exit event. The caller wraps each `ProcessEvent` into its own
|
||||
/// response envelope, so the streaming logic lives in exactly one place.
|
||||
fn process_event_stream(
|
||||
pid: u32,
|
||||
replay: Vec<DataEvent>,
|
||||
mut data_rx: broadcast::Receiver<DataEvent>,
|
||||
mut end_rx: broadcast::Receiver<process_handler::EndEvent>,
|
||||
cached_end: Option<process_handler::EndEvent>,
|
||||
) -> impl Stream<Item = Result<ProcessEvent, ConnectError>> {
|
||||
use broadcast::error::{RecvError, TryRecvError};
|
||||
|
||||
async_stream::stream! {
|
||||
yield Ok(make_start_event(pid));
|
||||
|
||||
for ev in replay {
|
||||
yield Ok(make_data_event(ev));
|
||||
}
|
||||
|
||||
// Process already exited before we attached. The snapshot above covers
|
||||
// output up to the attach point; drain anything the live receiver
|
||||
// buffered after the snapshot, then emit the cached exit. end_rx may
|
||||
// never deliver here — a broadcast receiver only sees events sent after
|
||||
// it subscribed, and the exit can predate that — so cached_end is the
|
||||
// source of truth.
|
||||
if let Some(end) = cached_end {
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
yield Ok(make_end_event(end));
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and its
|
||||
// handle was dropped. The end event is published
|
||||
// before the handle drop, so it is still buffered —
|
||||
// emit it rather than losing the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(make_end_event(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers before
|
||||
// sending this event, so every byte is already in the data
|
||||
// channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(make_end_event(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_start_response(event: ProcessEvent) -> StartResponse {
|
||||
fn make_start_response(pid: u32) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(event),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_connect_response(event: ProcessEvent) -> ConnectResponse {
|
||||
ConnectResponse {
|
||||
event: buffa::MessageField::some(event),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_start_event(pid: u32) -> ProcessEvent {
|
||||
ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent {
|
||||
pid,
|
||||
..Default::default()
|
||||
},
|
||||
))),
|
||||
event: buffa::MessageField::some(ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent {
|
||||
pid,
|
||||
..Default::default()
|
||||
},
|
||||
))),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@ -521,6 +497,13 @@ fn make_data_event(ev: DataEvent) -> ProcessEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_data_start_response(ev: DataEvent) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
||||
ProcessEvent {
|
||||
event: Some(process_event::Event::End(Box::new(
|
||||
@ -536,110 +519,9 @@ fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cmd_expands_tilde_slash() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("~/bin/mytool", home_dir).unwrap();
|
||||
assert_eq!(result, "/home/testuser/bin/mytool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_expands_bare_tilde() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("~", home_dir).unwrap();
|
||||
assert_eq!(result, "/home/testuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_passthrough_absolute() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("/usr/bin/env", home_dir).unwrap();
|
||||
assert_eq!(result, "/usr/bin/env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_passthrough_relative_no_tilde() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("bin/tool", home_dir).unwrap();
|
||||
assert_eq!(result, "bin/tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_errors_on_other_user() {
|
||||
let home_dir = "/home/testuser";
|
||||
assert!(expand_tilde("~other/bin/tool", home_dir).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_expands_tilde_slash() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("~/hi", home_dir).unwrap();
|
||||
assert_eq!(result, "/home/testuser/hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_expands_bare_tilde() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("~", home_dir).unwrap();
|
||||
assert_eq!(result, "/home/testuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_other_user_left_literal() {
|
||||
let home_dir = "/home/testuser";
|
||||
let args_raw = vec!["~other".to_string(), "~other/path".to_string()];
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert_eq!(args, vec!["~other", "~other/path"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_passthrough_absolute() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("/tmp/file", home_dir).unwrap();
|
||||
assert_eq!(result, "/tmp/file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_passthrough_relative_no_tilde() {
|
||||
let home_dir = "/home/testuser";
|
||||
let result = expand_tilde("relative/path", home_dir).unwrap();
|
||||
assert_eq!(result, "relative/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_mixed_expands_tilde_keeps_rest() {
|
||||
let home_dir = "/home/testuser";
|
||||
let args_raw = vec![
|
||||
"-p".to_string(),
|
||||
"~/data".to_string(),
|
||||
"/tmp/out".to_string(),
|
||||
"~other".to_string(),
|
||||
];
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-p", "/home/testuser/data", "/tmp/out", "~other"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_empty_passthrough() {
|
||||
let home_dir = "/home/testuser";
|
||||
let args_raw: Vec<String> = vec![];
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert!(args.is_empty());
|
||||
fn make_end_start_response(end: process_handler::EndEvent) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::auth::token::SecureToken;
|
||||
@ -17,11 +17,6 @@ pub struct AppState {
|
||||
pub port_subsystem: Option<Arc<PortSubsystem>>,
|
||||
pub cpu_used_pct: AtomicU32,
|
||||
pub cpu_count: AtomicU32,
|
||||
/// Whole-VM IO throughput, bytes/sec, sampled over the last 1s tick. Used
|
||||
/// by the host activity sampler to keep IO-bound-but-CPU-idle workloads
|
||||
/// (e.g. a long download) from being mistaken for inactive.
|
||||
pub net_bps: AtomicU64,
|
||||
pub disk_bps: AtomicU64,
|
||||
|
||||
/// Memory preload coordination. The host agent POSTs /memory/preload after
|
||||
/// a snapshot restore to materialise every physical page (so the next
|
||||
@ -61,8 +56,6 @@ impl AppState {
|
||||
port_subsystem,
|
||||
cpu_used_pct: AtomicU32::new(0),
|
||||
cpu_count: AtomicU32::new(0),
|
||||
net_bps: AtomicU64::new(0),
|
||||
disk_bps: AtomicU64::new(0),
|
||||
mem_preload_started: AtomicBool::new(false),
|
||||
mem_preload_done: AtomicBool::new(false),
|
||||
mem_preload_cancel: AtomicBool::new(false),
|
||||
@ -77,7 +70,7 @@ impl AppState {
|
||||
|
||||
let state_clone = Arc::clone(&state);
|
||||
std::thread::spawn(move || {
|
||||
activity_sampler(state_clone);
|
||||
cpu_sampler(state_clone);
|
||||
});
|
||||
|
||||
state
|
||||
@ -91,14 +84,6 @@ impl AppState {
|
||||
self.cpu_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn net_bps(&self) -> u64 {
|
||||
self.net_bps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn disk_bps(&self) -> u64 {
|
||||
self.disk_bps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Records a new lifecycle ID, returning true if it changed (i.e. this
|
||||
/// is the first /init since a resume). First-ever call returns false:
|
||||
/// boot-time /init doesn't need port-subsystem restart since the
|
||||
@ -114,16 +99,12 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn activity_sampler(state: Arc<AppState>) {
|
||||
fn cpu_sampler(state: Arc<AppState>) {
|
||||
use sysinfo::System;
|
||||
|
||||
let mut sys = System::new();
|
||||
sys.refresh_cpu_all();
|
||||
|
||||
// Cumulative IO counters from the previous tick. None until the first read.
|
||||
let mut prev_net: Option<u64> = read_net_bytes();
|
||||
let mut prev_disk: Option<u64> = read_disk_bytes();
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
@ -142,73 +123,5 @@ fn activity_sampler(state: Arc<AppState>) {
|
||||
state
|
||||
.cpu_count
|
||||
.store(sys.cpus().len() as u32, Ordering::Relaxed);
|
||||
|
||||
// Throughput = cumulative-counter delta over the ~1s tick. Counters can
|
||||
// reset across a snapshot restore; a wrapped/negative delta reads as 0.
|
||||
let cur_net = read_net_bytes();
|
||||
let net_bps = match (prev_net, cur_net) {
|
||||
(Some(p), Some(c)) => c.saturating_sub(p),
|
||||
_ => 0,
|
||||
};
|
||||
prev_net = cur_net;
|
||||
|
||||
let cur_disk = read_disk_bytes();
|
||||
let disk_bps = match (prev_disk, cur_disk) {
|
||||
(Some(p), Some(c)) => c.saturating_sub(p),
|
||||
_ => 0,
|
||||
};
|
||||
prev_disk = cur_disk;
|
||||
|
||||
state.net_bps.store(net_bps, Ordering::Relaxed);
|
||||
state.disk_bps.store(disk_bps, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum of rx+tx bytes across all non-loopback interfaces, from /proc/net/dev.
|
||||
/// Returns None if the file can't be read/parsed.
|
||||
fn read_net_bytes() -> Option<u64> {
|
||||
let content = std::fs::read_to_string("/proc/net/dev").ok()?;
|
||||
let mut total: u64 = 0;
|
||||
// First two lines are headers.
|
||||
for line in content.lines().skip(2) {
|
||||
let Some((iface, rest)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
if iface.trim() == "lo" {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||
// Column 0 = rx bytes, column 8 = tx bytes.
|
||||
if let Some(rx) = fields.first().and_then(|v| v.parse::<u64>().ok()) {
|
||||
total = total.saturating_add(rx);
|
||||
}
|
||||
if let Some(tx) = fields.get(8).and_then(|v| v.parse::<u64>().ok()) {
|
||||
total = total.saturating_add(tx);
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Sum of sectors read+written across all block devices, ×512, from
|
||||
/// /proc/diskstats. Skips partitions and loop/ram devices to avoid double
|
||||
/// counting. Returns None if the file can't be read/parsed.
|
||||
fn read_disk_bytes() -> Option<u64> {
|
||||
let content = std::fs::read_to_string("/proc/diskstats").ok()?;
|
||||
let mut sectors: u64 = 0;
|
||||
for line in content.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
// 0=major 1=minor 2=name ... 5=sectors read ... 9=sectors written.
|
||||
if fields.len() < 10 {
|
||||
continue;
|
||||
}
|
||||
let name = fields[2];
|
||||
if name.starts_with("loop") || name.starts_with("ram") {
|
||||
continue;
|
||||
}
|
||||
let read = fields[5].parse::<u64>().unwrap_or(0);
|
||||
let written = fields[9].parse::<u64>().unwrap_or(0);
|
||||
sectors = sectors.saturating_add(read).saturating_add(written);
|
||||
}
|
||||
// Linux reports diskstats sectors in fixed 512-byte units.
|
||||
Some(sectors.saturating_mul(512))
|
||||
}
|
||||
|
||||
@ -23,10 +23,12 @@ impl AtomicMax {
|
||||
if new <= current {
|
||||
return false;
|
||||
}
|
||||
match self
|
||||
.val
|
||||
.compare_exchange_weak(current, new, Ordering::Release, Ordering::Relaxed)
|
||||
{
|
||||
match self.val.compare_exchange_weak(
|
||||
current,
|
||||
new,
|
||||
Ordering::Release,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return true,
|
||||
Err(_) => continue,
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type BuildLogEntry = {
|
||||
step: number;
|
||||
phase: string; // "pre-build", "recipe", "post-build", or "healthcheck"
|
||||
phase: string; // "pre-build", "recipe", or "post-build"
|
||||
cmd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
|
||||
@ -39,6 +39,8 @@ export type Capsule = {
|
||||
vcpus: number;
|
||||
memory_mb: number;
|
||||
timeout_sec: number;
|
||||
guest_ip?: string;
|
||||
host_ip?: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
last_active_at?: string;
|
||||
@ -61,6 +63,7 @@ export type CreateCapsuleParams = {
|
||||
template?: string;
|
||||
vcpus?: number;
|
||||
memory_mb?: number;
|
||||
disk_size_mb?: number;
|
||||
timeout_sec?: number;
|
||||
};
|
||||
|
||||
|
||||
@ -103,11 +103,7 @@ export function createBuildConsole(buildId: string) {
|
||||
exit: ev.exit ?? 0,
|
||||
elapsedMs: ev.elapsed_ms ?? 0
|
||||
});
|
||||
// The healthcheck is shown as a trailing pseudo-step but is not
|
||||
// counted in total_steps, so it must not advance the counter.
|
||||
if (ev.phase !== 'healthcheck' && typeof ev.step === 'number' && ev.step > currentStep) {
|
||||
currentStep = ev.step;
|
||||
}
|
||||
if (typeof ev.step === 'number' && ev.step > currentStep) currentStep = ev.step;
|
||||
break;
|
||||
}
|
||||
case 'build-status':
|
||||
|
||||
@ -27,8 +27,6 @@
|
||||
return 'var(--color-text-bright)';
|
||||
case 'WORKDIR':
|
||||
return 'var(--color-text-tertiary)';
|
||||
case 'HEALTHCHECK':
|
||||
return 'var(--color-accent-bright)';
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
@ -113,8 +111,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<code class="mt-1.5 block truncate font-mono text-meta">
|
||||
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}{' '}<span
|
||||
class="text-[var(--color-text-secondary)]">{rest}</span>{/if}
|
||||
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}
|
||||
<span class="text-[var(--color-text-secondary)]">{rest}</span>
|
||||
{/if}
|
||||
</code>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@ -78,6 +78,19 @@
|
||||
brightWhite: '#eae7e2',
|
||||
};
|
||||
|
||||
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
|
||||
);
|
||||
}
|
||||
|
||||
// Binary-safe base64 decode (handles raw PTY bytes)
|
||||
function fromBase64(b64: string): string {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
|
||||
@ -91,16 +104,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Raw keystrokes go as binary frames (no base64/JSON wrapper); control
|
||||
// messages stay JSON text. Mirrors the binary output path.
|
||||
function wsSendBinary(ws: WebSocket | null, data: Uint8Array) {
|
||||
try {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
||||
} catch {
|
||||
// Connection closing — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
||||
const idx = sessions.findIndex(s => s.id === id);
|
||||
if (idx === -1) return;
|
||||
@ -227,7 +230,7 @@
|
||||
if (!int) return;
|
||||
int.inputFlushTimer = null;
|
||||
if (!int.inputBuffer) return;
|
||||
wsSendBinary(int.ws, new TextEncoder().encode(int.inputBuffer));
|
||||
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
|
||||
int.inputBuffer = '';
|
||||
}
|
||||
|
||||
@ -262,9 +265,6 @@
|
||||
|
||||
// Browser sends wrenn_sid cookie on the WS upgrade automatically (same-origin).
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
// PTY output arrives as raw binary frames (permessage-deflate compressed
|
||||
// by the server); control messages stay JSON text.
|
||||
ws.binaryType = 'arraybuffer';
|
||||
int.ws = ws;
|
||||
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||
|
||||
@ -278,18 +278,13 @@
|
||||
if (tag) {
|
||||
msg.tag = tag;
|
||||
} else {
|
||||
// No cmd: the server launches the user's default login shell.
|
||||
msg.cmd = '/bin/bash';
|
||||
msg.envs = { TERM: 'xterm-256color' };
|
||||
}
|
||||
wsSend(ws, JSON.stringify(msg));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Binary frames are raw PTY output — write straight to the terminal.
|
||||
if (typeof event.data !== 'string') {
|
||||
int.term.write(new Uint8Array(event.data));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
@ -301,6 +296,9 @@
|
||||
});
|
||||
if (activeSessionId === id) int.term.focus();
|
||||
break;
|
||||
case 'output':
|
||||
if (msg.data) int.term.write(fromBase64(msg.data));
|
||||
break;
|
||||
case 'exit':
|
||||
closeSession(id);
|
||||
break;
|
||||
|
||||
@ -53,15 +53,14 @@
|
||||
let byocPageCount = $derived(Math.max(1, Math.ceil(flatByocHosts.length / PAGE_SIZE)));
|
||||
let byocPageHosts = $derived(flatByocHosts.slice(byocPage * PAGE_SIZE, (byocPage + 1) * PAGE_SIZE));
|
||||
|
||||
// Aggregated stats — platform hosts only (admin needs a heads-up on
|
||||
// platform capacity; BYOC capacity belongs to individual teams).
|
||||
let onlineCount = $derived(platformHosts.filter((h) => h.status === 'online').length);
|
||||
let pendingCount = $derived(platformHosts.filter((h) => h.status === 'pending').length);
|
||||
let totalCount = $derived(platformHosts.length);
|
||||
let totalCpuCores = $derived(platformHosts.reduce((sum, h) => sum + (h.cpu_cores ?? 0), 0));
|
||||
let totalMemoryMb = $derived(platformHosts.reduce((sum, h) => sum + (h.memory_mb ?? 0), 0));
|
||||
let totalRunningVcpus = $derived(platformHosts.reduce((sum, h) => sum + h.running_vcpus, 0));
|
||||
let totalRunningMemoryMb = $derived(platformHosts.reduce((sum, h) => sum + h.running_memory_mb, 0));
|
||||
// Stats across all hosts
|
||||
let onlineCount = $derived(allHosts.filter((h) => h.status === 'online').length);
|
||||
let pendingCount = $derived(allHosts.filter((h) => h.status === 'pending').length);
|
||||
let totalCount = $derived(allHosts.length);
|
||||
let totalCpuCores = $derived(allHosts.reduce((sum, h) => sum + (h.cpu_cores ?? 0), 0));
|
||||
let totalMemoryMb = $derived(allHosts.reduce((sum, h) => sum + (h.memory_mb ?? 0), 0));
|
||||
let totalRunningVcpus = $derived(allHosts.reduce((sum, h) => sum + h.running_vcpus, 0));
|
||||
let totalRunningMemoryMb = $derived(allHosts.reduce((sum, h) => sum + h.running_memory_mb, 0));
|
||||
|
||||
function formatMem(mb: number): string {
|
||||
return mb >= 1024 ? `${(mb / 1024).toFixed(0)} GB` : `${mb} MB`;
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Capsules</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-[var(--color-bg-0)]">
|
||||
<!-- Header area -->
|
||||
{#if $page.params.id}
|
||||
|
||||
@ -225,7 +225,7 @@
|
||||
}
|
||||
}
|
||||
capsules = capsules;
|
||||
} else if (event.event === 'capsule.create' || event.event === 'capsule.state.changed') {
|
||||
} else if (event.event === 'capsule.create') {
|
||||
capsules = [event.sandbox, ...capsules];
|
||||
newCapsuleId = sandboxId;
|
||||
setTimeout(() => { newCapsuleId = null; }, 1600);
|
||||
@ -256,10 +256,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Capsules</title>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
@keyframes capsule-born {
|
||||
0%, 25% { background-color: rgba(94, 140, 88, 0.1); }
|
||||
|
||||
@ -205,7 +205,7 @@
|
||||
class="relative z-10 mt-10 font-mono text-ui uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||
style="animation: fadeUp 0.35s ease 0.2s both"
|
||||
>
|
||||
Runtime where AI engineers live.
|
||||
Isolated VMs. Milliseconds to live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -62,19 +62,12 @@ func requireRunningSandbox(w http.ResponseWriter, r *http.Request, queries *db.Q
|
||||
// SDK clients via X-API-Key. Requests without an auth context are rejected
|
||||
// with a 401 before the upgrade.
|
||||
func upgradeAndAuthenticate(w http.ResponseWriter, r *http.Request) (*websocket.Conn, auth.AuthContext, error) {
|
||||
return upgradeAndAuthenticateWith(w, r, &upgrader)
|
||||
}
|
||||
|
||||
// upgradeAndAuthenticateWith is upgradeAndAuthenticate with a caller-supplied
|
||||
// upgrader, used by the PTY handler to negotiate per-message compression on that
|
||||
// endpoint alone without affecting the other WebSocket routes.
|
||||
func upgradeAndAuthenticateWith(w http.ResponseWriter, r *http.Request, up *websocket.Upgrader) (*websocket.Conn, auth.AuthContext, error) {
|
||||
ac, hasAuth := auth.FromContext(r.Context())
|
||||
if !hasAuth {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
|
||||
return nil, auth.AuthContext{}, fmt.Errorf("unauthenticated")
|
||||
}
|
||||
conn, err := up.Upgrade(w, r, nil)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return nil, auth.AuthContext{}, fmt.Errorf("websocket upgrade: %w", err)
|
||||
}
|
||||
|
||||
@ -130,8 +130,22 @@ func (h *execStreamHandler) runExecStream(ctx context.Context, conn *websocket.C
|
||||
|
||||
// Forward stream events to WebSocket.
|
||||
for stream.Receive() {
|
||||
if m, ok := procRespToWSMsg(stream.Msg()); ok {
|
||||
writeWSJSON(conn, m)
|
||||
resp := stream.Msg()
|
||||
switch ev := resp.Event.(type) {
|
||||
case *pb.ExecStreamResponse_Start:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "start", PID: ev.Start.Pid})
|
||||
|
||||
case *pb.ExecStreamResponse_Data:
|
||||
switch o := ev.Data.Output.(type) {
|
||||
case *pb.ExecStreamData_Stdout:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "stdout", Data: string(o.Stdout)})
|
||||
case *pb.ExecStreamData_Stderr:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "stderr", Data: string(o.Stderr)})
|
||||
}
|
||||
|
||||
case *pb.ExecStreamResponse_End:
|
||||
exitCode := ev.End.ExitCode
|
||||
writeWSJSON(conn, wsOutMsg{Type: "exit", ExitCode: &exitCode})
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,38 +159,6 @@ func (h *execStreamHandler) runExecStream(ctx context.Context, conn *websocket.C
|
||||
updateLastActive(h.db, sandboxID, sandboxIDStr)
|
||||
}
|
||||
|
||||
// procStreamResp is satisfied by both *pb.ExecStreamResponse and
|
||||
// *pb.ConnectProcessResponse: their oneof events carry the same inner messages,
|
||||
// so the wire-to-WS mapping below is shared between the exec-stream and
|
||||
// connect-process handlers.
|
||||
type procStreamResp interface {
|
||||
GetStart() *pb.ExecStreamStart
|
||||
GetData() *pb.ExecStreamData
|
||||
GetEnd() *pb.ExecStreamEnd
|
||||
}
|
||||
|
||||
// procRespToWSMsg maps one process stream response to the WS message to send.
|
||||
// The bool is false when the response carries nothing to forward.
|
||||
func procRespToWSMsg(resp procStreamResp) (wsOutMsg, bool) {
|
||||
if s := resp.GetStart(); s != nil {
|
||||
return wsOutMsg{Type: "start", PID: s.Pid}, true
|
||||
}
|
||||
if d := resp.GetData(); d != nil {
|
||||
switch o := d.Output.(type) {
|
||||
case *pb.ExecStreamData_Stdout:
|
||||
return wsOutMsg{Type: "stdout", Data: string(o.Stdout)}, true
|
||||
case *pb.ExecStreamData_Stderr:
|
||||
return wsOutMsg{Type: "stderr", Data: string(o.Stderr)}, true
|
||||
}
|
||||
return wsOutMsg{}, false
|
||||
}
|
||||
if e := resp.GetEnd(); e != nil {
|
||||
exitCode := e.ExitCode
|
||||
return wsOutMsg{Type: "exit", ExitCode: &exitCode}, true
|
||||
}
|
||||
return wsOutMsg{}, false
|
||||
}
|
||||
|
||||
func sendWSError(conn *websocket.Conn, msg string) {
|
||||
writeWSJSON(conn, wsOutMsg{Type: "error", Data: msg})
|
||||
}
|
||||
|
||||
@ -192,8 +192,22 @@ func (h *processHandler) runConnectProcess(ctx context.Context, conn *websocket.
|
||||
|
||||
// Forward stream events to WebSocket.
|
||||
for stream.Receive() {
|
||||
if m, ok := procRespToWSMsg(stream.Msg()); ok {
|
||||
writeWSJSON(conn, m)
|
||||
resp := stream.Msg()
|
||||
switch ev := resp.Event.(type) {
|
||||
case *pb.ConnectProcessResponse_Start:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "start", PID: ev.Start.Pid})
|
||||
|
||||
case *pb.ConnectProcessResponse_Data:
|
||||
switch o := ev.Data.Output.(type) {
|
||||
case *pb.ExecStreamData_Stdout:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "stdout", Data: string(o.Stdout)})
|
||||
case *pb.ExecStreamData_Stderr:
|
||||
writeWSJSON(conn, wsOutMsg{Type: "stderr", Data: string(o.Stderr)})
|
||||
}
|
||||
|
||||
case *pb.ConnectProcessResponse_End:
|
||||
exitCode := ev.End.ExitCode
|
||||
writeWSJSON(conn, wsOutMsg{Type: "exit", ExitCode: &exitCode})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -23,19 +24,11 @@ import (
|
||||
|
||||
const (
|
||||
ptyKeepaliveInterval = 30 * time.Second
|
||||
ptyDefaultCmd = "/bin/bash"
|
||||
ptyDefaultCols = 80
|
||||
ptyDefaultRows = 24
|
||||
)
|
||||
|
||||
// ptyUpgrader enables permessage-deflate (RFC 7692) for the PTY WebSocket only.
|
||||
// TUI output is highly compressible (repeated escape sequences, runs of spaces,
|
||||
// repeated SGR color codes); the browser negotiates compression automatically.
|
||||
// The other WebSocket endpoints keep the default uncompressed upgrader.
|
||||
var ptyUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
EnableCompression: true,
|
||||
}
|
||||
|
||||
type ptyHandler struct {
|
||||
db *db.Queries
|
||||
pool *lifecycle.HostClientPool
|
||||
@ -47,12 +40,9 @@ func newPtyHandler(db *db.Queries, pool *lifecycle.HostClientPool) *ptyHandler {
|
||||
|
||||
// --- WebSocket message types ---
|
||||
|
||||
// wsPtyIn is an inbound message from the client. Control messages (start,
|
||||
// connect, resize, kill) arrive as JSON text frames. Keystroke input arrives as
|
||||
// raw binary frames and is represented here with Type "input" and the bytes in
|
||||
// inputBytes (never JSON-encoded).
|
||||
// wsPtyIn is the inbound message from the client.
|
||||
type wsPtyIn struct {
|
||||
Type string `json:"type"` // "start", "connect", "resize", "kill"
|
||||
Type string `json:"type"` // "start", "connect", "input", "resize", "kill"
|
||||
Cmd string `json:"cmd,omitempty"` // for "start"
|
||||
Args []string `json:"args,omitempty"` // for "start"
|
||||
Cols uint32 `json:"cols,omitempty"` // for "start", "resize"
|
||||
@ -61,17 +51,15 @@ type wsPtyIn struct {
|
||||
Cwd string `json:"cwd,omitempty"` // for "start"
|
||||
User string `json:"user,omitempty"` // for "start"
|
||||
Tag string `json:"tag,omitempty"` // for "connect"
|
||||
|
||||
inputBytes []byte // raw keystrokes from a binary frame (Type == "input")
|
||||
Data string `json:"data,omitempty"` // for "input" (base64)
|
||||
}
|
||||
|
||||
// wsPtyOut is an outbound control message to the client (JSON text frame). PTY
|
||||
// output is sent separately as raw binary frames, not through this struct.
|
||||
// wsPtyOut is the outbound message to the client.
|
||||
type wsPtyOut struct {
|
||||
Type string `json:"type"` // "started", "exit", "error", "ping"
|
||||
Type string `json:"type"` // "started", "output", "exit", "error"
|
||||
Tag string `json:"tag,omitempty"` // for "started"
|
||||
PID uint32 `json:"pid,omitempty"` // for "started"
|
||||
Data string `json:"data,omitempty"` // for "error"
|
||||
Data string `json:"data,omitempty"` // for "output" (base64), "error"
|
||||
ExitCode *int32 `json:"exit_code,omitempty"` // for "exit"
|
||||
Fatal bool `json:"fatal,omitempty"` // for "error"
|
||||
}
|
||||
@ -90,17 +78,6 @@ func (w *wsWriter) writeJSON(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeBinary sends raw PTY output as a binary WebSocket frame. Binary avoids
|
||||
// the ~33% base64 inflation of the JSON path; the negotiated permessage-deflate
|
||||
// compression then runs on the raw bytes for maximum reduction.
|
||||
func (w *wsWriter) writeBinary(data []byte) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if err := w.conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||
slog.Debug("pty websocket binary write error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PtySession handles WS /v1/capsules/{id}/pty.
|
||||
func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
@ -112,7 +89,7 @@ func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
conn, ac, err := upgradeAndAuthenticateWith(w, r, &ptyUpgrader)
|
||||
conn, ac, err := upgradeAndAuthenticate(w, r)
|
||||
if err != nil {
|
||||
slog.Error("pty websocket upgrade/auth failed", "error", err)
|
||||
return
|
||||
@ -170,9 +147,10 @@ func (h *ptyHandler) handleStart(
|
||||
sandboxIDStr string,
|
||||
msg wsPtyIn,
|
||||
) {
|
||||
// An empty cmd is intentional: envd launches the user's default login shell
|
||||
// (resolved from /etc/passwd) when no command is given.
|
||||
cmd := msg.Cmd
|
||||
if cmd == "" {
|
||||
cmd = ptyDefaultCmd
|
||||
}
|
||||
cols := msg.Cols
|
||||
if cols == 0 {
|
||||
cols = ptyDefaultCols
|
||||
@ -235,7 +213,6 @@ func (h *ptyHandler) handleConnect(
|
||||
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Tag: msg.Tag,
|
||||
Reconnect: true,
|
||||
}))
|
||||
if err != nil {
|
||||
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true})
|
||||
@ -274,7 +251,10 @@ func runPtyLoop(
|
||||
ws.writeJSON(wsPtyOut{Type: "started", Tag: ev.Started.Tag, PID: ev.Started.Pid})
|
||||
|
||||
case *pb.PtyAttachResponse_Output:
|
||||
ws.writeBinary(ev.Output.Data)
|
||||
ws.writeJSON(wsPtyOut{
|
||||
Type: "output",
|
||||
Data: base64.StdEncoding.EncodeToString(ev.Output.Data),
|
||||
})
|
||||
|
||||
case *pb.PtyAttachResponse_Exited:
|
||||
exitCode := ev.Exited.ExitCode
|
||||
@ -302,16 +282,13 @@ func runPtyLoop(
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
mt, raw, err := ws.conn.ReadMessage()
|
||||
_, raw, err := ws.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg wsPtyIn
|
||||
if mt == websocket.BinaryMessage {
|
||||
// Raw keystrokes — forward bytes verbatim.
|
||||
msg = wsPtyIn{Type: "input", inputBytes: raw}
|
||||
} else if json.Unmarshal(raw, &msg) != nil {
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -351,14 +328,15 @@ func runPtyLoop(
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
// Coalesce: drain any queued input messages into a single RPC.
|
||||
var data []byte
|
||||
data, pending = coalescePtyInput(inputCh, msg.inputBytes)
|
||||
if len(data) == 0 {
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||
if err != nil {
|
||||
rpcCancel()
|
||||
continue
|
||||
}
|
||||
|
||||
// Coalesce: drain any queued input messages into a single RPC.
|
||||
data, pending = coalescePtyInput(inputCh, data)
|
||||
|
||||
if _, err := agent.PtySendInput(rpcCtx, connect.NewRequest(&pb.PtySendInputRequest{
|
||||
SandboxId: sandboxID,
|
||||
Tag: tag,
|
||||
@ -435,7 +413,11 @@ func coalescePtyInput(ch <-chan wsPtyIn, buf []byte) ([]byte, *wsPtyIn) {
|
||||
if msg.Type != "input" {
|
||||
return buf, &msg
|
||||
}
|
||||
buf = append(buf, msg.inputBytes...)
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, data...)
|
||||
default:
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
@ -24,11 +24,10 @@ func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sand
|
||||
}
|
||||
|
||||
type createSandboxRequest struct {
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
}
|
||||
|
||||
type sandboxResponse struct {
|
||||
@ -40,6 +39,8 @@ type sandboxResponse struct {
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
DiskSizeMB int32 `json:"disk_size_mb"`
|
||||
DiskUsedMB *int64 `json:"disk_used_mb,omitempty"`
|
||||
GuestIP string `json:"guest_ip,omitempty"`
|
||||
HostIP string `json:"host_ip,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
StartedAt *string `json:"started_at,omitempty"`
|
||||
LastActiveAt *string `json:"last_active_at,omitempty"`
|
||||
@ -56,6 +57,8 @@ func sandboxToResponse(sb db.Sandbox) sandboxResponse {
|
||||
MemoryMB: sb.MemoryMb,
|
||||
TimeoutSec: sb.TimeoutSec,
|
||||
DiskSizeMB: sb.DiskSizeMb,
|
||||
GuestIP: sb.GuestIp,
|
||||
HostIP: sb.HostIp,
|
||||
}
|
||||
if len(sb.Metadata) > 0 {
|
||||
var meta map[string]string
|
||||
@ -100,7 +103,6 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
VCPUs: req.VCPUs,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, req.Template, err)
|
||||
if err != nil {
|
||||
|
||||
@ -60,10 +60,6 @@ func agentErrToHTTP(err error) (int, string, string) {
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case connect.CodeUnimplemented:
|
||||
return http.StatusNotImplemented, "agent_error", err.Error()
|
||||
case connect.CodeDeadlineExceeded:
|
||||
return http.StatusGatewayTimeout, "timeout", "command timed out"
|
||||
case connect.CodeInternal:
|
||||
return http.StatusInternalServerError, "agent_error", err.Error()
|
||||
default:
|
||||
return http.StatusBadGateway, "agent_error", err.Error()
|
||||
}
|
||||
@ -119,8 +115,6 @@ func serviceErrToHTTP(err error) (int, string, string) {
|
||||
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
|
||||
strings.Contains(msg, "no host has sufficient resources"):
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case strings.HasPrefix(msg, "invalid metadata: "):
|
||||
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
|
||||
case strings.Contains(msg, "invalid"):
|
||||
return http.StatusBadRequest, "invalid_request", "invalid request"
|
||||
default:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -144,7 +144,7 @@ func (c *SandboxEventConsumer) handleMessage(ctx context.Context, msg redis.XMes
|
||||
}
|
||||
case events.CapsulePause:
|
||||
if event.Outcome == events.OutcomeSuccess {
|
||||
c.handleAutoPaused(ctx, sandboxID, event)
|
||||
c.handleAutoPaused(ctx, sandboxID)
|
||||
}
|
||||
case events.CapsuleDestroy:
|
||||
if event.Outcome == events.OutcomeSuccess {
|
||||
@ -226,35 +226,12 @@ func (c *SandboxEventConsumer) handleStarted(ctx context.Context, sandboxID pgty
|
||||
}
|
||||
}
|
||||
|
||||
// handleAutoPaused reflects an autonomous (TTL reaper / shutdown) pause in the
|
||||
// DB and writes the audit row for it. The audit write happens only when the
|
||||
// status flip actually applied, so a stream redelivery does not double-count,
|
||||
// and so the HostMonitor host_state_sync fallback (which audits the
|
||||
// callback-lost case) stays mutually exclusive with this path.
|
||||
//
|
||||
// Uses audit.Log (row only) — NOT LogSandboxAutoPause, which republishes a
|
||||
// CapsulePause/system event that would loop straight back into this consumer.
|
||||
func (c *SandboxEventConsumer) handleAutoPaused(ctx context.Context, sandboxID pgtype.UUID, event events.Event) {
|
||||
func (c *SandboxEventConsumer) handleAutoPaused(ctx context.Context, sandboxID pgtype.UUID) {
|
||||
for _, fromStatus := range []string{"running", "pausing"} {
|
||||
if _, err := c.db.UpdateSandboxStatusIf(ctx, db.UpdateSandboxStatusIfParams{
|
||||
ID: sandboxID, Status: fromStatus, Status_2: "paused",
|
||||
}); err == nil {
|
||||
slog.Debug("sandbox event consumer: auto-paused applied", "sandbox_id", id.FormatSandboxID(sandboxID), "from", fromStatus)
|
||||
reason := event.Metadata["reason"]
|
||||
if reason == "" {
|
||||
reason = "ttl_expired"
|
||||
}
|
||||
teamID, _ := id.ParseTeamID(event.TeamID)
|
||||
c.audit.Log(ctx, audit.Entry{
|
||||
TeamID: teamID,
|
||||
ActorType: "system",
|
||||
ResourceType: "sandbox",
|
||||
ResourceID: id.FormatSandboxID(sandboxID),
|
||||
Action: "pause",
|
||||
Scope: "team",
|
||||
Status: "info",
|
||||
Metadata: map[string]any{"reason": reason},
|
||||
})
|
||||
slog.Debug("sandbox event consumer: auto-paused fallback applied", "sandbox_id", id.FormatSandboxID(sandboxID), "from", fromStatus)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,14 +104,6 @@ func (r *SSERelay) handleMessage(ctx context.Context, msg *redis.Message) {
|
||||
if err != nil {
|
||||
slog.Debug("sse relay: sandbox hydration failed (may be deleted)", "sandbox_id", event.Resource.ID, "error", err)
|
||||
} else {
|
||||
// Override the hydrated status with the status implied by the event
|
||||
// verb. Autonomous transitions (e.g. TTL auto-pause) flip the DB row
|
||||
// in a separate stream consumer that races this Pub/Sub read, so the
|
||||
// hydrated row may still carry the pre-transition status. The event
|
||||
// itself is authoritative for the resulting state.
|
||||
if status, ok := impliedSandboxStatus(event); ok {
|
||||
sb.Status = status
|
||||
}
|
||||
payload.Sandbox = sb
|
||||
}
|
||||
}
|
||||
@ -146,25 +138,6 @@ func (r *SSERelay) hydrateSandbox(ctx context.Context, sandboxIDStr string) (*sa
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// impliedSandboxStatus maps a successful capsule lifecycle event to the
|
||||
// sandbox status it results in. Used to override a hydrated DB row that may
|
||||
// still carry the pre-transition status because the reconciliation consumer
|
||||
// that flips it races this Pub/Sub read. Returns false for events with no
|
||||
// single deterministic resulting status (failures, destroy, state_changed).
|
||||
func impliedSandboxStatus(event events.Event) (string, bool) {
|
||||
if event.Outcome != events.OutcomeSuccess {
|
||||
return "", false
|
||||
}
|
||||
switch event.Event {
|
||||
case events.CapsulePause:
|
||||
return "paused", true
|
||||
case events.CapsuleResume, events.CapsuleCreate:
|
||||
return "running", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func isCapsuleEvent(eventType string) bool {
|
||||
switch eventType {
|
||||
case events.CapsuleCreate, events.CapsulePause, events.CapsuleResume, events.CapsuleDestroy, events.CapsuleStateChanged:
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@ -24,7 +25,6 @@ type Client struct {
|
||||
hostIP string
|
||||
base string
|
||||
healthURL string
|
||||
activityURL string
|
||||
httpClient *http.Client
|
||||
streamingClient *http.Client
|
||||
|
||||
@ -42,7 +42,6 @@ func New(hostIP string) *Client {
|
||||
hostIP: hostIP,
|
||||
base: base,
|
||||
healthURL: base + "/health",
|
||||
activityURL: base + "/activity",
|
||||
httpClient: httpClient,
|
||||
streamingClient: streamingClient,
|
||||
process: genconnect.NewProcessClient(streamingClient, base),
|
||||
@ -118,17 +117,36 @@ func (c *Client) Exec(ctx context.Context, cmd string, args []string, opts *Exec
|
||||
result := &ExecResult{}
|
||||
|
||||
for stream.Receive() {
|
||||
ev, ok := procEventToStreamEvent(stream.Msg().GetEvent())
|
||||
if !ok {
|
||||
msg := stream.Msg()
|
||||
if msg.Event == nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Type {
|
||||
case "stdout":
|
||||
result.Stdout = append(result.Stdout, ev.Data...)
|
||||
case "stderr":
|
||||
result.Stderr = append(result.Stderr, ev.Data...)
|
||||
case "end":
|
||||
result.ExitCode = ev.ExitCode
|
||||
|
||||
event := msg.Event.GetEvent()
|
||||
switch e := event.(type) {
|
||||
case *envdpb.ProcessEvent_Start:
|
||||
slog.Debug("process started", "pid", e.Start.GetPid())
|
||||
|
||||
case *envdpb.ProcessEvent_Data:
|
||||
output := e.Data.GetOutput()
|
||||
switch o := output.(type) {
|
||||
case *envdpb.ProcessEvent_DataEvent_Stdout:
|
||||
result.Stdout = append(result.Stdout, o.Stdout...)
|
||||
case *envdpb.ProcessEvent_DataEvent_Stderr:
|
||||
result.Stderr = append(result.Stderr, o.Stderr...)
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_End:
|
||||
result.ExitCode = e.End.GetExitCode()
|
||||
if e.End.Error != nil {
|
||||
slog.Debug("process ended with error",
|
||||
"exit_code", e.End.GetExitCode(),
|
||||
"error", e.End.GetError(),
|
||||
)
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_Keepalive:
|
||||
// Ignore keepalives.
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,76 +166,6 @@ type ExecStreamEvent struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// procEventToStreamEvent converts a raw envd ProcessEvent into an
|
||||
// ExecStreamEvent. The second return is false for events with no payload to
|
||||
// forward (nil event, keepalive, unknown data variant) so callers can skip
|
||||
// them. This is the single decoder shared by Exec, ExecStream and
|
||||
// ConnectProcess.
|
||||
func procEventToStreamEvent(pe *envdpb.ProcessEvent) (ExecStreamEvent, bool) {
|
||||
if pe == nil {
|
||||
return ExecStreamEvent{}, false
|
||||
}
|
||||
switch e := pe.GetEvent().(type) {
|
||||
case *envdpb.ProcessEvent_Start:
|
||||
return ExecStreamEvent{Type: "start", PID: e.Start.GetPid()}, true
|
||||
case *envdpb.ProcessEvent_Data:
|
||||
switch o := e.Data.GetOutput().(type) {
|
||||
case *envdpb.ProcessEvent_DataEvent_Stdout:
|
||||
return ExecStreamEvent{Type: "stdout", Data: o.Stdout}, true
|
||||
case *envdpb.ProcessEvent_DataEvent_Stderr:
|
||||
return ExecStreamEvent{Type: "stderr", Data: o.Stderr}, true
|
||||
}
|
||||
return ExecStreamEvent{}, false
|
||||
case *envdpb.ProcessEvent_End:
|
||||
ev := ExecStreamEvent{Type: "end", ExitCode: e.End.GetExitCode()}
|
||||
if e.End.Error != nil {
|
||||
ev.Error = e.End.GetError()
|
||||
}
|
||||
return ev, true
|
||||
}
|
||||
return ExecStreamEvent{}, false
|
||||
}
|
||||
|
||||
// procEventStream is the subset of a Connect server-stream that pumpProcessEvents
|
||||
// needs. Both *connect.ServerStreamForClient[StartResponse] and
|
||||
// [ConnectResponse] satisfy it.
|
||||
type procEventStream[T any] interface {
|
||||
Receive() bool
|
||||
Msg() *T
|
||||
Err() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// pumpProcessEvents drains a process server-stream into ch until the stream ends
|
||||
// or ctx is cancelled, closing ch on exit. getEvent extracts the ProcessEvent
|
||||
// from each message so the same loop works for both the Start and Connect RPCs.
|
||||
func pumpProcessEvents[T any](
|
||||
ctx context.Context,
|
||||
stream procEventStream[T],
|
||||
getEvent func(*T) *envdpb.ProcessEvent,
|
||||
ch chan<- ExecStreamEvent,
|
||||
logLabel string,
|
||||
) {
|
||||
defer close(ch)
|
||||
defer stream.Close()
|
||||
|
||||
for stream.Receive() {
|
||||
ev, ok := procEventToStreamEvent(getEvent(stream.Msg()))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case ch <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil && err != io.EOF {
|
||||
slog.Debug(logLabel, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecStream runs a command inside the sandbox and returns a channel of output events.
|
||||
// The channel is closed when the process ends or the context is cancelled.
|
||||
func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-chan ExecStreamEvent, error) {
|
||||
@ -236,24 +184,81 @@ func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-
|
||||
}
|
||||
|
||||
ch := make(chan ExecStreamEvent, 256)
|
||||
go pumpProcessEvents(ctx, stream, (*envdpb.StartResponse).GetEvent, ch, "exec stream error")
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer stream.Close()
|
||||
|
||||
for stream.Receive() {
|
||||
msg := stream.Msg()
|
||||
if msg.Event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ev ExecStreamEvent
|
||||
event := msg.Event.GetEvent()
|
||||
switch e := event.(type) {
|
||||
case *envdpb.ProcessEvent_Start:
|
||||
ev = ExecStreamEvent{Type: "start", PID: e.Start.GetPid()}
|
||||
|
||||
case *envdpb.ProcessEvent_Data:
|
||||
output := e.Data.GetOutput()
|
||||
switch o := output.(type) {
|
||||
case *envdpb.ProcessEvent_DataEvent_Stdout:
|
||||
ev = ExecStreamEvent{Type: "stdout", Data: o.Stdout}
|
||||
case *envdpb.ProcessEvent_DataEvent_Stderr:
|
||||
ev = ExecStreamEvent{Type: "stderr", Data: o.Stderr}
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_End:
|
||||
ev = ExecStreamEvent{Type: "end", ExitCode: e.End.GetExitCode()}
|
||||
if e.End.Error != nil {
|
||||
ev.Error = e.End.GetError()
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_Keepalive:
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil && err != io.EOF {
|
||||
slog.Debug("exec stream error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// WriteFile writes content to a file inside the sandbox via envd's REST endpoint.
|
||||
// envd expects PUT /files?path=...&username=root with the raw file content as the body.
|
||||
// envd expects POST /files?path=...&username=root with multipart/form-data (field name "file").
|
||||
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
part, err := writer.CreateFormFile("file", "upload")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create multipart: %w", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
return fmt.Errorf("write multipart: %w", err)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
|
||||
"path": {path},
|
||||
"username": {"root"},
|
||||
}.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(content))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -429,7 +434,7 @@ func (c *Client) CancelMemoryPreload(ctx context.Context) error {
|
||||
// post-restore initialization. sandbox_id and template_id are passed
|
||||
// so envd can set WRENN_SANDBOX_ID and WRENN_TEMPLATE_ID env vars.
|
||||
func (c *Client) PostInit(ctx context.Context) error {
|
||||
return c.PostInitWithDefaults(ctx, "", nil, "", "", "")
|
||||
return c.PostInitWithDefaults(ctx, "", nil, "", "")
|
||||
}
|
||||
|
||||
// PostInitWithDefaults calls envd's POST /init endpoint with optional default
|
||||
@ -439,7 +444,7 @@ func (c *Client) PostInit(ctx context.Context) error {
|
||||
// timestamp and lifecycle_id are always populated: envd uses them to snap
|
||||
// the guest clock to the host's wall time and to detect post-resume calls
|
||||
// (which trigger port-forwarder restart + NFS remount).
|
||||
func (c *Client) PostInitWithDefaults(ctx context.Context, defaultUser string, envVars map[string]string, sandboxID, templateID, proxyDomain string) error {
|
||||
func (c *Client) PostInitWithDefaults(ctx context.Context, defaultUser string, envVars map[string]string, sandboxID, templateID string) error {
|
||||
payload := map[string]any{
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"lifecycle_id": uuid.NewString(),
|
||||
@ -456,9 +461,6 @@ func (c *Client) PostInitWithDefaults(ctx context.Context, defaultUser string, e
|
||||
if templateID != "" {
|
||||
payload["template_id"] = templateID
|
||||
}
|
||||
if proxyDomain != "" {
|
||||
payload["proxy_domain"] = proxyDomain
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if len(payload) > 0 {
|
||||
|
||||
@ -81,42 +81,6 @@ func (c *Client) WaitUntilRPCReady(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Activity is envd's liveness snapshot: VM-wide CPU utilisation and IO
|
||||
// throughput sampled inside the guest. The host activity sampler uses it to
|
||||
// decide whether a sandbox is doing real work and should keep its TTL fresh.
|
||||
type Activity struct {
|
||||
CPUCount uint32 `json:"cpu_count"`
|
||||
CPUUsedPct float32 `json:"cpu_used_pct"`
|
||||
NetBps uint64 `json:"net_bps"`
|
||||
DiskBps uint64 `json:"disk_bps"`
|
||||
}
|
||||
|
||||
// FetchActivity polls envd's /activity endpoint. The endpoint serves straight
|
||||
// from in-guest atomics (no syscalls), so it is cheap to call frequently.
|
||||
func (c *Client) FetchActivity(ctx context.Context) (*Activity, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.activityURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build activity request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch envd activity: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("activity check returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data Activity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("decode activity response: %w", err)
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// healthCheck sends a single GET /health request to envd.
|
||||
func (c *Client) healthCheck(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil)
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
|
||||
@ -86,7 +87,52 @@ func (c *Client) ConnectProcess(ctx context.Context, pid uint32, tag string) (<-
|
||||
}
|
||||
|
||||
ch := make(chan ExecStreamEvent, 16)
|
||||
go pumpProcessEvents(ctx, stream, (*envdpb.ConnectResponse).GetEvent, ch, "connect process stream error")
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer stream.Close()
|
||||
|
||||
for stream.Receive() {
|
||||
msg := stream.Msg()
|
||||
if msg.Event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ev ExecStreamEvent
|
||||
switch e := msg.Event.GetEvent().(type) {
|
||||
case *envdpb.ProcessEvent_Start:
|
||||
ev = ExecStreamEvent{Type: "start", PID: e.Start.GetPid()}
|
||||
|
||||
case *envdpb.ProcessEvent_Data:
|
||||
switch o := e.Data.GetOutput().(type) {
|
||||
case *envdpb.ProcessEvent_DataEvent_Stdout:
|
||||
ev = ExecStreamEvent{Type: "stdout", Data: o.Stdout}
|
||||
case *envdpb.ProcessEvent_DataEvent_Stderr:
|
||||
ev = ExecStreamEvent{Type: "stderr", Data: o.Stderr}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_End:
|
||||
ev = ExecStreamEvent{Type: "end", ExitCode: e.End.GetExitCode()}
|
||||
if e.End.Error != nil {
|
||||
ev.Error = e.End.GetError()
|
||||
}
|
||||
|
||||
case *envdpb.ProcessEvent_Keepalive:
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := stream.Err(); err != nil && err != io.EOF {
|
||||
slog.Debug("connect process stream error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
@ -23,14 +23,14 @@ type PtyEvent struct {
|
||||
// PtyStart starts a new PTY process in the guest and returns a channel of events.
|
||||
// The tag is the stable identifier used to reconnect via PtyConnect.
|
||||
// The channel is closed when the process ends or ctx is cancelled.
|
||||
// An empty user runs as the sandbox default user.
|
||||
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string) (<-chan PtyEvent, error) {
|
||||
// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's
|
||||
// ProcessConfig proto. When envd adds user support, thread it through here.
|
||||
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) {
|
||||
stdin := true
|
||||
cfg := &envdpb.ProcessConfig{
|
||||
Cmd: cmd,
|
||||
Args: args,
|
||||
Envs: envs,
|
||||
User: user,
|
||||
}
|
||||
if cwd != "" {
|
||||
cfg.Cwd = &cwd
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -76,7 +77,7 @@ func (s *Server) CreateSandbox(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sb, diskSizeBytes, err := s.mgr.Create(ctx, msg.SandboxId, teamID, templateID,
|
||||
sb, err := s.mgr.Create(ctx, msg.SandboxId, teamID, templateID,
|
||||
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), int(msg.DiskSizeMb),
|
||||
msg.DefaultUser, msg.DefaultEnv)
|
||||
if err != nil {
|
||||
@ -87,11 +88,10 @@ func (s *Server) CreateSandbox(
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.CreateSandboxResponse{
|
||||
SandboxId: sb.ID,
|
||||
Status: string(sb.Status),
|
||||
HostIp: sb.HostIP.String(),
|
||||
Metadata: sb.Metadata,
|
||||
DiskSizeMb: int32(diskSizeBytes / (1024 * 1024)),
|
||||
SandboxId: sb.ID,
|
||||
Status: string(sb.Status),
|
||||
HostIp: sb.HostIP.String(),
|
||||
Metadata: sb.Metadata,
|
||||
}), nil
|
||||
}
|
||||
|
||||
@ -252,7 +252,7 @@ func (s *Server) Exec(
|
||||
|
||||
result, err := s.mgr.Exec(execCtx, msg.SandboxId, msg.Cmd, msg.Args, opts)
|
||||
if err != nil {
|
||||
return nil, envdErr("exec", err)
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("exec: %w", err))
|
||||
}
|
||||
|
||||
return connect.NewResponse(&pb.ExecResponse{
|
||||
@ -394,15 +394,31 @@ func (s *Server) ExecStream(
|
||||
}
|
||||
|
||||
for ev := range events {
|
||||
start, data, end := execEventParts(ev)
|
||||
var resp pb.ExecStreamResponse
|
||||
switch {
|
||||
case start != nil:
|
||||
resp.Event = &pb.ExecStreamResponse_Start{Start: start}
|
||||
case data != nil:
|
||||
resp.Event = &pb.ExecStreamResponse_Data{Data: data}
|
||||
case end != nil:
|
||||
resp.Event = &pb.ExecStreamResponse_End{End: end}
|
||||
switch ev.Type {
|
||||
case "start":
|
||||
resp.Event = &pb.ExecStreamResponse_Start{
|
||||
Start: &pb.ExecStreamStart{Pid: ev.PID},
|
||||
}
|
||||
case "stdout":
|
||||
resp.Event = &pb.ExecStreamResponse_Data{
|
||||
Data: &pb.ExecStreamData{
|
||||
Output: &pb.ExecStreamData_Stdout{Stdout: ev.Data},
|
||||
},
|
||||
}
|
||||
case "stderr":
|
||||
resp.Event = &pb.ExecStreamResponse_Data{
|
||||
Data: &pb.ExecStreamData{
|
||||
Output: &pb.ExecStreamData_Stderr{Stderr: ev.Data},
|
||||
},
|
||||
}
|
||||
case "end":
|
||||
resp.Event = &pb.ExecStreamResponse_End{
|
||||
End: &pb.ExecStreamEnd{
|
||||
ExitCode: ev.ExitCode,
|
||||
Error: ev.Error,
|
||||
},
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@ -414,24 +430,6 @@ func (s *Server) ExecStream(
|
||||
return nil
|
||||
}
|
||||
|
||||
// execEventParts maps a streaming exec event to its proto inner message.
|
||||
// Exactly one return value is non-nil; all-nil means the event carries nothing
|
||||
// to forward. Shared by ExecStream and ConnectProcess, which differ only in the
|
||||
// response envelope wrapping these inner messages.
|
||||
func execEventParts(ev envdclient.ExecStreamEvent) (*pb.ExecStreamStart, *pb.ExecStreamData, *pb.ExecStreamEnd) {
|
||||
switch ev.Type {
|
||||
case "start":
|
||||
return &pb.ExecStreamStart{Pid: ev.PID}, nil, nil
|
||||
case "stdout":
|
||||
return nil, &pb.ExecStreamData{Output: &pb.ExecStreamData_Stdout{Stdout: ev.Data}}, nil
|
||||
case "stderr":
|
||||
return nil, &pb.ExecStreamData{Output: &pb.ExecStreamData_Stderr{Stderr: ev.Data}}, nil
|
||||
case "end":
|
||||
return nil, nil, &pb.ExecStreamEnd{ExitCode: ev.ExitCode, Error: ev.Error}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (s *Server) WriteFileStream(
|
||||
ctx context.Context,
|
||||
stream *connect.ClientStream[pb.WriteFileStreamRequest],
|
||||
@ -455,46 +453,52 @@ func (s *Server) WriteFileStream(
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
|
||||
// Use io.Pipe to stream raw chunks into envd's REST endpoint body.
|
||||
// Use io.Pipe to stream chunks into a multipart body for envd's REST endpoint.
|
||||
pr, pw := io.Pipe()
|
||||
mpWriter := multipart.NewWriter(pw)
|
||||
|
||||
// Pump chunks from the Connect stream into the request body in a goroutine.
|
||||
// Write multipart data in a goroutine.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
part, err := mpWriter.CreateFormFile("file", "upload")
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("create multipart: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for stream.Receive() {
|
||||
chunk := stream.Msg().GetChunk()
|
||||
if len(chunk) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := pw.Write(chunk); err != nil {
|
||||
pw.CloseWithError(err)
|
||||
if _, err := part.Write(chunk); err != nil {
|
||||
errCh <- fmt.Errorf("write chunk: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
pw.CloseWithError(err)
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
pw.Close()
|
||||
mpWriter.Close()
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
// Send the raw streaming body to envd.
|
||||
// Send the streaming multipart body to envd.
|
||||
base := client.BaseURL()
|
||||
u := fmt.Sprintf("%s/files?%s", base, url.Values{
|
||||
"path": {meta.Path},
|
||||
"username": {"root"},
|
||||
}.Encode())
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, pr)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, pr)
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
httpReq.Header.Set("Content-Type", mpWriter.FormDataContentType())
|
||||
|
||||
resp, err := client.StreamingHTTPClient().Do(httpReq)
|
||||
if err != nil {
|
||||
@ -682,7 +686,7 @@ func (s *Server) PtyAttach(
|
||||
) error {
|
||||
msg := req.Msg
|
||||
|
||||
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
|
||||
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
|
||||
}
|
||||
@ -907,15 +911,31 @@ func (s *Server) ConnectProcess(
|
||||
}
|
||||
|
||||
for ev := range events {
|
||||
start, data, end := execEventParts(ev)
|
||||
var resp pb.ConnectProcessResponse
|
||||
switch {
|
||||
case start != nil:
|
||||
resp.Event = &pb.ConnectProcessResponse_Start{Start: start}
|
||||
case data != nil:
|
||||
resp.Event = &pb.ConnectProcessResponse_Data{Data: data}
|
||||
case end != nil:
|
||||
resp.Event = &pb.ConnectProcessResponse_End{End: end}
|
||||
switch ev.Type {
|
||||
case "start":
|
||||
resp.Event = &pb.ConnectProcessResponse_Start{
|
||||
Start: &pb.ExecStreamStart{Pid: ev.PID},
|
||||
}
|
||||
case "stdout":
|
||||
resp.Event = &pb.ConnectProcessResponse_Data{
|
||||
Data: &pb.ExecStreamData{
|
||||
Output: &pb.ExecStreamData_Stdout{Stdout: ev.Data},
|
||||
},
|
||||
}
|
||||
case "stderr":
|
||||
resp.Event = &pb.ConnectProcessResponse_Data{
|
||||
Data: &pb.ExecStreamData{
|
||||
Output: &pb.ExecStreamData_Stderr{Stderr: ev.Data},
|
||||
},
|
||||
}
|
||||
case "end":
|
||||
resp.Event = &pb.ConnectProcessResponse_End{
|
||||
End: &pb.ExecStreamEnd{
|
||||
ExitCode: ev.ExitCode,
|
||||
Error: ev.Error,
|
||||
},
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
@ -21,33 +21,30 @@ type ExecContext struct {
|
||||
var envRegex = regexp.MustCompile(`\$\$|\$\{([a-zA-Z0-9_]*)\}|\$([a-zA-Z0-9_]+)`)
|
||||
|
||||
// WrappedCommand returns the full shell command for a RUN step with context
|
||||
// applied. The result is handed to the exec layer as a bare command (no
|
||||
// "-c" wrapper), so the user's default login shell — resolved by envd from
|
||||
// /etc/passwd inside the VM — interprets it.
|
||||
// applied. The result is passed as the argument to /bin/sh -c.
|
||||
//
|
||||
// If WORKDIR and/or ENV are set, they are prepended as a shell preamble:
|
||||
//
|
||||
// cd '/the/dir' && export KEY='val' && original command
|
||||
// cd '/the/dir' && KEY='val' /bin/sh -c 'original command'
|
||||
//
|
||||
// If USER is set to a non-root user, the entire command is wrapped with su.
|
||||
// Dropping `-s` lets su run it under that user's login shell rather than
|
||||
// forcing /bin/sh:
|
||||
// If USER is set to a non-root user, the entire command is wrapped with su:
|
||||
//
|
||||
// su <user> -c '<preamble + command>'
|
||||
// su <user> -s /bin/sh -c '<preamble + command>'
|
||||
func (c *ExecContext) WrappedCommand(cmd string) string {
|
||||
inner := c.innerCommand(cmd)
|
||||
if c.User != "" && c.User != "root" {
|
||||
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
|
||||
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// innerCommand applies the workdir/env preamble to cmd without user wrapping.
|
||||
// The preamble cds into WORKDIR and exports ENV vars, then the command runs in
|
||||
// the same (login) shell — no nested shell is named, so the user's default
|
||||
// shell interprets the command and any pipes/operators within it.
|
||||
// innerCommand builds the command with workdir/env preamble but without user wrapping.
|
||||
func (c *ExecContext) innerCommand(cmd string) string {
|
||||
return c.shellPrefix() + cmd
|
||||
prefix := c.shellPrefix()
|
||||
if prefix == "" {
|
||||
return cmd
|
||||
}
|
||||
return prefix + "/bin/sh -c " + shellescape(cmd)
|
||||
}
|
||||
|
||||
// StartCommand returns the shell command for a START step. The process is
|
||||
@ -58,22 +55,16 @@ func (c *ExecContext) innerCommand(cmd string) string {
|
||||
// Multiple START steps can be issued to run several background processes
|
||||
// simultaneously before a healthcheck is evaluated.
|
||||
func (c *ExecContext) StartCommand(cmd string) string {
|
||||
// Launch the background process under the user's login shell. $SHELL is set
|
||||
// to that shell by su (non-root) or by envd (root), with /bin/sh as a safe
|
||||
// fallback if it is somehow unset.
|
||||
prefix := c.shellPrefix()
|
||||
inner := prefix + `nohup "${SHELL:-/bin/sh}" -c ` + shellescape(cmd) + " >/dev/null 2>&1 &"
|
||||
inner := prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &"
|
||||
if c.User != "" && c.User != "root" {
|
||||
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
|
||||
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// shellPrefix builds the "cd '/dir' && export KEY='val' && " preamble for a
|
||||
// shell command. ENV vars are exported (not just assignment-prefixed) so they
|
||||
// apply to the whole command — including any pipes or && chains — and to child
|
||||
// processes, matching Dockerfile ENV semantics. Returns an empty string when no
|
||||
// context is set.
|
||||
// shellPrefix builds the "cd ... && KEY=val " preamble for a shell command.
|
||||
// Returns an empty string when no context is set.
|
||||
func (c *ExecContext) shellPrefix() string {
|
||||
if c.WorkDir == "" && len(c.EnvVars) == 0 {
|
||||
return ""
|
||||
@ -84,20 +75,16 @@ func (c *ExecContext) shellPrefix() string {
|
||||
sb.WriteString(shellescape(c.WorkDir))
|
||||
sb.WriteString(" && ")
|
||||
}
|
||||
if len(c.EnvVars) > 0 {
|
||||
keys := make([]string, 0, len(c.EnvVars))
|
||||
for k := range c.EnvVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
sb.WriteString("export")
|
||||
for _, k := range keys {
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(shellescape(c.EnvVars[k]))
|
||||
}
|
||||
sb.WriteString(" && ")
|
||||
keys := make([]string, 0, len(c.EnvVars))
|
||||
for k := range c.EnvVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(shellescape(c.EnvVars[k]))
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@ -20,31 +20,31 @@ func TestExecContext_WrappedCommand(t *testing.T) {
|
||||
name: "workdir only",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "npm install",
|
||||
want: "cd '/app' && npm install",
|
||||
want: "cd '/app' && /bin/sh -c 'npm install'",
|
||||
},
|
||||
{
|
||||
name: "env only",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"PORT": "8080"}},
|
||||
cmd: "node server.js",
|
||||
want: "export PORT='8080' && node server.js",
|
||||
want: "PORT='8080' /bin/sh -c 'node server.js'",
|
||||
},
|
||||
{
|
||||
name: "workdir with space",
|
||||
ctx: ExecContext{WorkDir: "/my project"},
|
||||
cmd: "make build",
|
||||
want: "cd '/my project' && make build",
|
||||
want: "cd '/my project' && /bin/sh -c 'make build'",
|
||||
},
|
||||
{
|
||||
name: "command with single quotes",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "echo 'hello'",
|
||||
want: "cd '/app' && echo 'hello'",
|
||||
want: "cd '/app' && /bin/sh -c 'echo '\\''hello'\\'''",
|
||||
},
|
||||
{
|
||||
name: "env value with single quotes",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"MSG": "it's fine"}},
|
||||
cmd: "echo $MSG",
|
||||
want: "export MSG='it'\\''s fine' && echo $MSG",
|
||||
want: "MSG='it'\\''s fine' /bin/sh -c 'echo $MSG'",
|
||||
},
|
||||
{
|
||||
name: "env expansion with pre-expanded PATH",
|
||||
@ -52,25 +52,7 @@ func TestExecContext_WrappedCommand(t *testing.T) {
|
||||
EnvVars: map[string]string{"PATH": "/usr/bin", "FOO": "/opt/venv/bin:/usr/bin"},
|
||||
},
|
||||
cmd: "make build",
|
||||
want: "export FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' && make build",
|
||||
},
|
||||
{
|
||||
name: "non-root user wraps with su login shell",
|
||||
ctx: ExecContext{User: "wrenn-user"},
|
||||
cmd: "whoami",
|
||||
want: "su 'wrenn-user' -c 'whoami'",
|
||||
},
|
||||
{
|
||||
name: "non-root user with workdir and env",
|
||||
ctx: ExecContext{User: "wrenn-user", WorkDir: "/app", EnvVars: map[string]string{"PORT": "8080"}},
|
||||
cmd: "node server.js",
|
||||
want: "su 'wrenn-user' -c 'cd '\\''/app'\\'' && export PORT='\\''8080'\\'' && node server.js'",
|
||||
},
|
||||
{
|
||||
name: "root user is not su-wrapped",
|
||||
ctx: ExecContext{User: "root", WorkDir: "/app"},
|
||||
cmd: "ls",
|
||||
want: "cd '/app' && ls",
|
||||
want: "FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' /bin/sh -c 'make build'",
|
||||
},
|
||||
}
|
||||
|
||||
@ -106,25 +88,19 @@ func TestExecContext_StartCommand(t *testing.T) {
|
||||
name: "no context",
|
||||
ctx: ExecContext{},
|
||||
cmd: "python3 app.py",
|
||||
want: `nohup "${SHELL:-/bin/sh}" -c 'python3 app.py' >/dev/null 2>&1 &`,
|
||||
want: "nohup /bin/sh -c 'python3 app.py' >/dev/null 2>&1 &",
|
||||
},
|
||||
{
|
||||
name: "with workdir",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "python3 server.py",
|
||||
want: `cd '/app' && nohup "${SHELL:-/bin/sh}" -c 'python3 server.py' >/dev/null 2>&1 &`,
|
||||
want: "cd '/app' && nohup /bin/sh -c 'python3 server.py' >/dev/null 2>&1 &",
|
||||
},
|
||||
{
|
||||
name: "with env",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"PORT": "9000"}},
|
||||
cmd: "node index.js",
|
||||
want: `export PORT='9000' && nohup "${SHELL:-/bin/sh}" -c 'node index.js' >/dev/null 2>&1 &`,
|
||||
},
|
||||
{
|
||||
name: "non-root user wraps start with su",
|
||||
ctx: ExecContext{User: "wrenn-user"},
|
||||
cmd: "python3 app.py",
|
||||
want: `su 'wrenn-user' -c 'nohup "${SHELL:-/bin/sh}" -c '\''python3 app.py'\'' >/dev/null 2>&1 &'`,
|
||||
want: "PORT='9000' nohup /bin/sh -c 'node index.js' >/dev/null 2>&1 &",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -183,7 +183,8 @@ func execRun(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: bctx.WrappedCommand(st.Shell),
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", bctx.WrappedCommand(st.Shell)},
|
||||
TimeoutSec: int32(timeout.Seconds()),
|
||||
}))
|
||||
|
||||
@ -359,7 +360,8 @@ func execRawShell(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: shellCmd,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", shellCmd},
|
||||
TimeoutSec: int32(timeout.Seconds()),
|
||||
}))
|
||||
|
||||
@ -396,7 +398,8 @@ func execStart(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: bctx.StartCommand(st.Shell),
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", bctx.StartCommand(st.Shell)},
|
||||
TimeoutSec: 10,
|
||||
}))
|
||||
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
)
|
||||
|
||||
func TestIsBusy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
act envdclient.Activity
|
||||
want bool
|
||||
}{
|
||||
// Default thresholds (zero cfg → defaults: cpu 5%, net 16K, disk 32K).
|
||||
{"idle", Config{}, envdclient.Activity{CPUUsedPct: 0.5, NetBps: 100, DiskBps: 200}, false},
|
||||
{"cpu just below", Config{}, envdclient.Activity{CPUUsedPct: 4.99}, false},
|
||||
{"cpu at threshold", Config{}, envdclient.Activity{CPUUsedPct: 5.0}, true},
|
||||
{"cpu above", Config{}, envdclient.Activity{CPUUsedPct: 80.0}, true},
|
||||
{"net just below", Config{}, envdclient.Activity{NetBps: 16*1024 - 1}, false},
|
||||
{"net at floor", Config{}, envdclient.Activity{NetBps: 16 * 1024}, true},
|
||||
{"disk just below", Config{}, envdclient.Activity{DiskBps: 32*1024 - 1}, false},
|
||||
{"disk at floor", Config{}, envdclient.Activity{DiskBps: 32 * 1024}, true},
|
||||
{"download: low cpu, high net", Config{}, envdclient.Activity{CPUUsedPct: 1.0, NetBps: 5 * 1024 * 1024}, true},
|
||||
|
||||
// Explicit overrides take precedence over defaults.
|
||||
{
|
||||
"custom cpu threshold met",
|
||||
Config{CPUBusyPct: 20.0},
|
||||
envdclient.Activity{CPUUsedPct: 25.0},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"custom cpu threshold not met",
|
||||
Config{CPUBusyPct: 20.0},
|
||||
envdclient.Activity{CPUUsedPct: 10.0},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"custom net floor not met",
|
||||
Config{NetFloorBps: 1024 * 1024},
|
||||
envdclient.Activity{NetBps: 16 * 1024},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Manager{cfg: tt.cfg}
|
||||
if got := m.isBusy(&tt.act); got != tt.want {
|
||||
t.Errorf("isBusy(%+v) = %v, want %v", tt.act, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBusySample(t *testing.T) {
|
||||
// Debounce requires busyDebounceSamples consecutive busy samples before the
|
||||
// first bump. Verify the streak math and bump timing.
|
||||
if busyDebounceSamples != 2 {
|
||||
t.Skip("test written for busyDebounceSamples=2")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startStreak int
|
||||
busy bool
|
||||
wantStreak int
|
||||
wantBump bool
|
||||
}{
|
||||
{"first busy, no bump yet", 0, true, 1, false},
|
||||
{"second consecutive busy, bump", 1, true, 2, true},
|
||||
{"sustained busy keeps bumping, streak held", 2, true, 2, true},
|
||||
{"single noise spike from idle, no bump", 0, false, 0, false},
|
||||
{"idle resets a building streak", 1, false, 0, false},
|
||||
{"idle resets a saturated streak", 2, false, 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotStreak, gotBump := applyBusySample(tt.startStreak, tt.busy)
|
||||
if gotStreak != tt.wantStreak || gotBump != tt.wantBump {
|
||||
t.Errorf("applyBusySample(%d, %v) = (%d, %v), want (%d, %v)",
|
||||
tt.startStreak, tt.busy, gotStreak, gotBump, tt.wantStreak, tt.wantBump)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBusySample_NoiseScenario walks a realistic sample sequence: brief
|
||||
// noise never crosses the debounce, but sustained work does and then a return
|
||||
// to idle resets — proving an isolated spike cannot keep a sandbox alive.
|
||||
func TestApplyBusySample_NoiseScenario(t *testing.T) {
|
||||
if busyDebounceSamples != 2 {
|
||||
t.Skip("test written for busyDebounceSamples=2")
|
||||
}
|
||||
|
||||
samples := []bool{true, false, false, true, true, true, false}
|
||||
wantBumps := []bool{false, false, false, false, true, true, false}
|
||||
|
||||
streak := 0
|
||||
for i, busy := range samples {
|
||||
var bump bool
|
||||
streak, bump = applyBusySample(streak, busy)
|
||||
if bump != wantBumps[i] {
|
||||
t.Errorf("sample %d (busy=%v): bump = %v, want %v (streak=%d)",
|
||||
i, busy, bump, wantBumps[i], streak)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -37,44 +37,44 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
vcpus, memoryMB, timeoutSec, diskSizeMB int,
|
||||
defaultUser string,
|
||||
defaultEnv map[string]string,
|
||||
) (*models.Sandbox, int64, error) {
|
||||
) (*models.Sandbox, error) {
|
||||
templateDir := layout.TemplateDir(m.cfg.WrennDir, teamID, templateID)
|
||||
baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
|
||||
|
||||
meta, err := readSnapshotMeta(templateDir)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read snapshot meta: %w", err)
|
||||
return nil, fmt.Errorf("read snapshot meta: %w", err)
|
||||
}
|
||||
if meta.SandboxDir == "" {
|
||||
// CH's saved config.json hardcodes a tmpfs disk path; meta.SandboxDir
|
||||
// is that exact path. A snapshot template without it cannot be launched.
|
||||
return nil, 0, fmt.Errorf("snapshot template %s missing sandbox_dir in meta", templateDir)
|
||||
return nil, fmt.Errorf("snapshot template %s missing sandbox_dir in meta", templateDir)
|
||||
}
|
||||
|
||||
// Acquire shared read-only loop on the flattened rootfs. Many sandboxes
|
||||
// can share this loop concurrently — refcounted in LoopRegistry.
|
||||
originLoop, err := m.loops.Acquire(baseRootfs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("acquire loop: %w", err)
|
||||
return nil, fmt.Errorf("acquire loop: %w", err)
|
||||
}
|
||||
originSize, err := devicemapper.OriginSizeBytes(originLoop)
|
||||
if err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("origin size: %w", err)
|
||||
return nil, fmt.Errorf("origin size: %w", err)
|
||||
}
|
||||
|
||||
// Per-sandbox CoW on top of the shared origin.
|
||||
dmName := "wrenn-" + sandboxID
|
||||
if err := os.MkdirAll(layout.SandboxDir(m.cfg.WrennDir, sandboxID), 0o755); err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("create sandbox dir: %w", err)
|
||||
return nil, fmt.Errorf("create sandbox dir: %w", err)
|
||||
}
|
||||
cowPath := layout.SandboxCowPath(m.cfg.WrennDir, sandboxID)
|
||||
cowSize := max(int64(diskSizeMB)*1024*1024, originSize)
|
||||
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
|
||||
if err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("create dm-snapshot: %w", err)
|
||||
return nil, fmt.Errorf("create dm-snapshot: %w", err)
|
||||
}
|
||||
|
||||
res := &createResources{
|
||||
@ -89,14 +89,14 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
slotIdx, err := m.slots.Allocate()
|
||||
if err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("allocate network slot: %w", err)
|
||||
return nil, fmt.Errorf("allocate network slot: %w", err)
|
||||
}
|
||||
res.slotIdx = slotIdx
|
||||
slot := network.NewSlot(slotIdx)
|
||||
|
||||
if err := network.CreateNetwork(slot); err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("create network: %w", err)
|
||||
return nil, fmt.Errorf("create network: %w", err)
|
||||
}
|
||||
res.slot = slot
|
||||
|
||||
@ -119,7 +119,7 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
client, err := m.launchRestoredVM(ctx, vmCfg, slot.HostIP.String())
|
||||
if err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, err
|
||||
return nil, err
|
||||
}
|
||||
res.vm = m.vm
|
||||
|
||||
@ -172,7 +172,7 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
"dm_device", dmDev.DevicePath,
|
||||
)
|
||||
|
||||
return &sb.Sandbox, cowSize, nil
|
||||
return &sb.Sandbox, nil
|
||||
}
|
||||
|
||||
// templateExists returns true if a snapshot template already lives at
|
||||
|
||||
@ -88,47 +88,14 @@ type Config struct {
|
||||
EnvdTimeout time.Duration
|
||||
DefaultRootfsSizeMB int // target size for template rootfs images; 0 → DefaultDiskSizeMB
|
||||
|
||||
// ProxyDomain is the public domain sandboxes are served under (e.g.
|
||||
// "wrenn.dev"). Injected into envd at /init so `envd ports` can build
|
||||
// {port}-{sandbox_id}.{domain} URLs.
|
||||
ProxyDomain string
|
||||
|
||||
// Resolved at startup by the host agent.
|
||||
KernelPath string // path to the latest vmlinux-x.y.z
|
||||
KernelVersion string // semver extracted from filename
|
||||
VMMBin string // path to the cloud-hypervisor binary
|
||||
VMMVersion string // semver from cloud-hypervisor --version
|
||||
AgentVersion string // host agent version (injected via ldflags)
|
||||
|
||||
// Activity sampler thresholds. The sampler polls each running sandbox's
|
||||
// guest liveness and refreshes its TTL when it is doing real work, so a
|
||||
// long-running but non-interactive job is not mistaken for inactive. A
|
||||
// sandbox counts as busy when guest CPU ≥ CPUBusyPct, or net/disk
|
||||
// throughput ≥ the respective floor (bytes/sec). Zero values fall back to
|
||||
// the package defaults at sampler start.
|
||||
ActivitySampleInterval time.Duration
|
||||
CPUBusyPct float32
|
||||
NetFloorBps uint64
|
||||
DiskFloorBps uint64
|
||||
}
|
||||
|
||||
// Activity sampler defaults. Thresholds sit clear of idle-VM background noise
|
||||
// (envd's own sampler thread, guest timers) so a parked sandbox still times
|
||||
// out; the debounce below guards against a lone noisy sample masquerading as
|
||||
// work. All are env-overridable on the host agent.
|
||||
const (
|
||||
defaultActivitySampleInterval = 5 * time.Second
|
||||
defaultCPUBusyPct = 5.0 // percent of total vCPU capacity
|
||||
defaultNetFloorBps = 16 * 1024 // 16 KB/s
|
||||
defaultDiskFloorBps = 32 * 1024 // 32 KB/s
|
||||
activityPollTimeout = 3 * time.Second
|
||||
activitySampleConcurrency = 16
|
||||
// busyDebounceSamples is how many consecutive busy samples are required
|
||||
// before the sandbox's TTL is refreshed. With a 5s interval, real work
|
||||
// registers within ~10s while isolated noise spikes are ignored.
|
||||
busyDebounceSamples = 2
|
||||
)
|
||||
|
||||
// LifecycleEvent describes an autonomous state change initiated by the agent.
|
||||
type LifecycleEvent struct {
|
||||
Event string
|
||||
@ -217,25 +184,11 @@ type sandboxState struct {
|
||||
memLoadDone chan struct{} // closed when background memory loader exits
|
||||
memLoadCancel context.CancelFunc // cancels the background loader goroutine
|
||||
|
||||
// Background zero-page punch state (set by Pause for paused sandboxes).
|
||||
// punchZeroPagesInDir runs off the pause critical path: the VM is already
|
||||
// destroyed and the snapshot dir swapped in, so the read-back-and-punch
|
||||
// pass need not block the user-perceived pause. Resume/Destroy cancel and
|
||||
// wait via waitForPunch before relaunching CH or removing the snapshot dir.
|
||||
punchDone chan struct{} // closed when the background punch exits
|
||||
punchCancel context.CancelFunc // cancels the background punch goroutine
|
||||
|
||||
// Metrics sampling state.
|
||||
vmmPID int // VMM process PID (child of unshare wrapper)
|
||||
ring *metricsRing // tiered ring buffers for CPU/mem/disk metrics
|
||||
samplerCancel context.CancelFunc // cancels the per-sandbox sampling goroutine
|
||||
samplerDone chan struct{} // closed when the sampling goroutine exits
|
||||
|
||||
// activityBusyStreak counts consecutive busy activity samples. A single
|
||||
// noisy sample (idle background CPU, a stray packet) must not refresh the
|
||||
// TTL, so LastActiveAt is only bumped once the streak reaches
|
||||
// busyDebounceSamples. Reset to 0 by any non-busy sample. Guarded by m.mu.
|
||||
activityBusyStreak int
|
||||
}
|
||||
|
||||
// buildMetadata constructs the metadata map with version information.
|
||||
@ -305,9 +258,9 @@ func (m *Manager) Create(
|
||||
vcpus, memoryMB, timeoutSec, diskSizeMB int,
|
||||
defaultUser string,
|
||||
defaultEnv map[string]string,
|
||||
) (*models.Sandbox, int64, error) {
|
||||
) (*models.Sandbox, error) {
|
||||
if m.draining.Load() {
|
||||
return nil, 0, ErrDraining
|
||||
return nil, ErrDraining
|
||||
}
|
||||
if sandboxID == "" {
|
||||
sandboxID = id.FormatSandboxID(id.NewSandboxID())
|
||||
@ -320,7 +273,7 @@ func (m *Manager) Create(
|
||||
memoryMB = 512
|
||||
}
|
||||
if diskSizeMB <= 0 {
|
||||
diskSizeMB = m.cfg.DefaultRootfsSizeMB
|
||||
diskSizeMB = 5120 // 5 GB default
|
||||
}
|
||||
timeoutSec = clampTimeout(timeoutSec)
|
||||
|
||||
@ -335,12 +288,12 @@ func (m *Manager) Create(
|
||||
if _, exists := m.boxes[sandboxID]; exists {
|
||||
m.mu.Unlock()
|
||||
cancelCreate()
|
||||
return nil, 0, fmt.Errorf("sandbox %s already exists", sandboxID)
|
||||
return nil, fmt.Errorf("sandbox %s already exists", sandboxID)
|
||||
}
|
||||
if _, inflight := m.creates[sandboxID]; inflight {
|
||||
m.mu.Unlock()
|
||||
cancelCreate()
|
||||
return nil, 0, fmt.Errorf("sandbox %s create already in progress", sandboxID)
|
||||
return nil, fmt.Errorf("sandbox %s create already in progress", sandboxID)
|
||||
}
|
||||
m.creates[sandboxID] = handle
|
||||
m.mu.Unlock()
|
||||
@ -371,19 +324,19 @@ func (m *Manager) Create(
|
||||
// Resolve base rootfs image.
|
||||
baseRootfs := layout.TemplateRootfs(m.cfg.WrennDir, teamID, templateID)
|
||||
if _, err := os.Stat(baseRootfs); err != nil {
|
||||
return nil, 0, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
|
||||
return nil, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
|
||||
}
|
||||
|
||||
// Acquire shared read-only loop device for the base image.
|
||||
originLoop, err := m.loops.Acquire(baseRootfs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("acquire loop device: %w", err)
|
||||
return nil, fmt.Errorf("acquire loop device: %w", err)
|
||||
}
|
||||
|
||||
originSize, err := devicemapper.OriginSizeBytes(originLoop)
|
||||
if err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("get origin size: %w", err)
|
||||
return nil, fmt.Errorf("get origin size: %w", err)
|
||||
}
|
||||
|
||||
// Create dm-snapshot with per-sandbox CoW file.
|
||||
@ -393,14 +346,14 @@ func (m *Manager) Create(
|
||||
dmName := "wrenn-" + sandboxID
|
||||
if err := os.MkdirAll(layout.SandboxDir(m.cfg.WrennDir, sandboxID), 0o755); err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("create sandbox dir: %w", err)
|
||||
return nil, fmt.Errorf("create sandbox dir: %w", err)
|
||||
}
|
||||
cowPath := layout.SandboxCowPath(m.cfg.WrennDir, sandboxID)
|
||||
cowSize := max(int64(diskSizeMB)*1024*1024, originSize)
|
||||
dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize, cowSize)
|
||||
if err != nil {
|
||||
m.loops.Release(baseRootfs)
|
||||
return nil, 0, fmt.Errorf("create dm-snapshot: %w", err)
|
||||
return nil, fmt.Errorf("create dm-snapshot: %w", err)
|
||||
}
|
||||
|
||||
res := &createResources{
|
||||
@ -416,7 +369,7 @@ func (m *Manager) Create(
|
||||
slotIdx, err := m.slots.Allocate()
|
||||
if err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("allocate network slot: %w", err)
|
||||
return nil, fmt.Errorf("allocate network slot: %w", err)
|
||||
}
|
||||
res.slotIdx = slotIdx
|
||||
slot := network.NewSlot(slotIdx)
|
||||
@ -424,7 +377,7 @@ func (m *Manager) Create(
|
||||
// Set up network.
|
||||
if err := network.CreateNetwork(slot); err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("create network: %w", err)
|
||||
return nil, fmt.Errorf("create network: %w", err)
|
||||
}
|
||||
res.slot = slot
|
||||
|
||||
@ -448,7 +401,7 @@ func (m *Manager) Create(
|
||||
|
||||
if _, err := m.vm.Create(ctx, vmCfg); err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("create VM: %w", err)
|
||||
return nil, fmt.Errorf("create VM: %w", err)
|
||||
}
|
||||
res.vm = m.vm
|
||||
|
||||
@ -460,20 +413,20 @@ func (m *Manager) Create(
|
||||
|
||||
if err := client.WaitUntilReady(waitCtx); err != nil {
|
||||
res.rollback()
|
||||
return nil, 0, fmt.Errorf("wait for envd: %w", err)
|
||||
return nil, fmt.Errorf("wait for envd: %w", err)
|
||||
}
|
||||
|
||||
// Fetch envd version (best-effort).
|
||||
envdVersion, _ := client.FetchVersion(ctx)
|
||||
|
||||
// Apply template defaults + sandbox identity via envd /init. Always called
|
||||
// on create so envd records its sandbox ID and proxy domain (used by
|
||||
// `envd ports`), even when the template specifies no user/env defaults.
|
||||
initCtx, initCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout)
|
||||
if err := client.PostInitWithDefaults(initCtx, defaultUser, defaultEnv, sandboxID, id.UUIDString(templateID), m.cfg.ProxyDomain); err != nil {
|
||||
slog.Warn("post-create PostInit failed", "id", sandboxID, "error", err)
|
||||
// Apply template defaults via envd /init (no-op when both empty).
|
||||
if defaultUser != "" || len(defaultEnv) > 0 {
|
||||
initCtx, initCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout)
|
||||
if err := client.PostInitWithDefaults(initCtx, defaultUser, defaultEnv, sandboxID, id.UUIDString(templateID)); err != nil {
|
||||
slog.Warn("post-create PostInit failed", "id", sandboxID, "error", err)
|
||||
}
|
||||
initCancel()
|
||||
}
|
||||
initCancel()
|
||||
|
||||
now := time.Now()
|
||||
sb := &sandboxState{
|
||||
@ -514,7 +467,7 @@ func (m *Manager) Create(
|
||||
"dm_device", dmDev.DevicePath,
|
||||
)
|
||||
|
||||
return &sb.Sandbox, cowSize, nil
|
||||
return &sb.Sandbox, nil
|
||||
}
|
||||
|
||||
// Destroy stops and cleans up a sandbox. If the sandbox is running, its VM,
|
||||
@ -587,9 +540,6 @@ func (m *Manager) Destroy(ctx context.Context, sandboxID string) error {
|
||||
|
||||
// cleanup tears down all resources for a sandbox.
|
||||
func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
||||
// Stop any background zero-page punch before removing the snapshot dir,
|
||||
// so a lingering goroutine can't keep writing to files we're deleting.
|
||||
m.waitForPunch(sb)
|
||||
if sb.memLoadCancel != nil {
|
||||
sb.memLoadCancel()
|
||||
if sb.memLoadDone != nil {
|
||||
@ -717,22 +667,20 @@ func (m *Manager) SetDefaults(ctx context.Context, sandboxID, defaultUser string
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.PostInitWithDefaults(ctx, defaultUser, defaultEnv, "", "", "")
|
||||
return c.PostInitWithDefaults(ctx, defaultUser, defaultEnv, "", "")
|
||||
}
|
||||
|
||||
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||
// When reconnect is true it reattaches to the process identified by tag;
|
||||
// otherwise it starts a new process (cmd may be empty to launch the user's
|
||||
// default login shell). user empty means the sandbox default user.
|
||||
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string, reconnect bool) (<-chan envdclient.PtyEvent, error) {
|
||||
// If cmd is non-empty, starts a new process. If empty, reconnects using tag.
|
||||
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {
|
||||
c, err := m.activeClient(sandboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reconnect {
|
||||
return c.PtyConnect(ctx, tag)
|
||||
if cmd != "" {
|
||||
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd)
|
||||
}
|
||||
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd, user)
|
||||
return c.PtyConnect(ctx, tag)
|
||||
}
|
||||
|
||||
// PtySendInput sends raw bytes to a PTY process in a sandbox.
|
||||
@ -814,11 +762,6 @@ func (m *Manager) AcquireProxyConn(sandboxID string) (net.IP, *ConnTracker, bool
|
||||
if !sb.connTracker.Acquire() {
|
||||
return nil, nil, false
|
||||
}
|
||||
// Inbound proxy traffic counts as activity: an idle web server reachable
|
||||
// through the proxy should not be auto-paused while it is serving requests.
|
||||
m.mu.Lock()
|
||||
sb.LastActiveAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
return sb.HostIP, sb.connTracker, true
|
||||
}
|
||||
|
||||
@ -929,146 +872,6 @@ func (m *Manager) reapExpired(_ context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// StartActivitySampler starts a background goroutine that polls each running
|
||||
// sandbox's guest liveness (CPU + net/disk IO) and refreshes LastActiveAt when
|
||||
// the sandbox is doing real work. This is what keeps a long-running but
|
||||
// non-interactive job (a build, a download) from being auto-paused by the TTL
|
||||
// reaper, while an idle workload (sleep, a parked shell) still times out.
|
||||
func (m *Manager) StartActivitySampler(ctx context.Context) {
|
||||
interval := m.cfg.ActivitySampleInterval
|
||||
if interval <= 0 {
|
||||
interval = defaultActivitySampleInterval
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.sampleActivity(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// activityTarget pairs a sandbox ID with the envd client to poll.
|
||||
type activityTarget struct {
|
||||
id string
|
||||
client *envdclient.Client
|
||||
}
|
||||
|
||||
func (m *Manager) sampleActivity(ctx context.Context) {
|
||||
// Snapshot the running sandboxes and their clients under the lock, then
|
||||
// poll over the network without holding it.
|
||||
m.mu.RLock()
|
||||
targets := make([]activityTarget, 0, len(m.boxes))
|
||||
for id, sb := range m.boxes {
|
||||
if sb.Status != models.StatusRunning {
|
||||
continue
|
||||
}
|
||||
// Skip sandboxes still loading memory after a resume — they are not
|
||||
// settled and their IO/CPU is preload noise, not user work.
|
||||
if sb.memLoadDone != nil {
|
||||
select {
|
||||
case <-sb.memLoadDone:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
c := sb.client.Load()
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, activityTarget{id: id, client: c})
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, activitySampleConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range targets {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(t activityTarget) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
m.pollAndBump(ctx, t)
|
||||
}(t)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// pollAndBump fetches one sandbox's activity and refreshes its TTL once it has
|
||||
// been busy for busyDebounceSamples consecutive samples. Poll failures are
|
||||
// treated as a non-busy sample: an unreachable envd is handled by the reaper /
|
||||
// heartbeat paths, and resetting the streak is the safe default.
|
||||
func (m *Manager) pollAndBump(ctx context.Context, t activityTarget) {
|
||||
pollCtx, cancel := context.WithTimeout(ctx, activityPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
act, err := t.client.FetchActivity(pollCtx)
|
||||
busy := err == nil && m.isBusy(act)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sb, ok := m.boxes[t.id]
|
||||
if !ok || sb.Status != models.StatusRunning {
|
||||
return
|
||||
}
|
||||
|
||||
streak, bump := applyBusySample(sb.activityBusyStreak, busy)
|
||||
sb.activityBusyStreak = streak
|
||||
if bump {
|
||||
sb.LastActiveAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// applyBusySample advances a debounce streak with the latest sample and
|
||||
// reports whether the TTL should be refreshed this tick. A non-busy sample
|
||||
// resets the streak; the bump fires once the streak reaches the debounce
|
||||
// threshold and on every busy tick thereafter (the streak is held at the
|
||||
// threshold rather than growing unbounded).
|
||||
func applyBusySample(streak int, busy bool) (newStreak int, bump bool) {
|
||||
if !busy {
|
||||
return 0, false
|
||||
}
|
||||
streak++
|
||||
if streak >= busyDebounceSamples {
|
||||
return busyDebounceSamples, true
|
||||
}
|
||||
return streak, false
|
||||
}
|
||||
|
||||
// isBusy reports whether a guest liveness snapshot represents real work.
|
||||
func (m *Manager) isBusy(act *envdclient.Activity) bool {
|
||||
cpuThreshold := m.cfg.CPUBusyPct
|
||||
if cpuThreshold <= 0 {
|
||||
cpuThreshold = defaultCPUBusyPct
|
||||
}
|
||||
netFloor := m.cfg.NetFloorBps
|
||||
if netFloor == 0 {
|
||||
netFloor = defaultNetFloorBps
|
||||
}
|
||||
diskFloor := m.cfg.DiskFloorBps
|
||||
if diskFloor == 0 {
|
||||
diskFloor = defaultDiskFloorBps
|
||||
}
|
||||
|
||||
return act.CPUUsedPct >= cpuThreshold ||
|
||||
act.NetBps >= netFloor ||
|
||||
act.DiskBps >= diskFloor
|
||||
}
|
||||
|
||||
// Shutdown gracefully drains the manager. Running sandboxes are paused so
|
||||
// their state survives across agent restarts; any sandboxes still holding
|
||||
// runtime resources after PauseAll (e.g. paused failed, or status was
|
||||
|
||||
@ -56,11 +56,8 @@ const (
|
||||
snapshotMetaFile = "wrenn-snapshot.json"
|
||||
|
||||
// drainTimeout is how long pause waits for in-flight proxy connections
|
||||
// to release before forcibly cancelling them. Kept tight: an idle capsule
|
||||
// has no in-flight connections so Drain returns immediately, and a busy
|
||||
// one is force-closed straight after — a long cap only adds dead latency
|
||||
// to the user-perceived pause.
|
||||
drainTimeout = 2 * time.Second
|
||||
// to release before forcibly cancelling them.
|
||||
drainTimeout = 5 * time.Second
|
||||
|
||||
// prepareSnapshotTimeout bounds the in-guest /snapshot/prepare call.
|
||||
// Short on purpose: envd PrepareSnapshot is best-effort, and a wedged
|
||||
@ -168,16 +165,6 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
return fmt.Errorf("%w: %s (status: %s)", ErrNotRunning, sandboxID, sb.Status)
|
||||
}
|
||||
|
||||
// Per-phase timing so the slow step in a pause is visible in logs without
|
||||
// a profiler. phase(name) logs the elapsed time since the previous phase.
|
||||
phaseStart := time.Now()
|
||||
lastPhase := phaseStart
|
||||
phase := func(name string) {
|
||||
now := time.Now()
|
||||
slog.Debug("pause phase", "id", sandboxID, "phase", name, "ms", now.Sub(lastPhase).Milliseconds())
|
||||
lastPhase = now
|
||||
}
|
||||
|
||||
// Wait for the post-resume memory loader to finish before snapshotting.
|
||||
// Without this, ch.snapshot's SEEK_DATA/SEEK_HOLE writer would emit holes
|
||||
// for any page not yet faulted in, which read back as zero on the next
|
||||
@ -185,7 +172,6 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
if err := m.waitForMemoryLoader(ctx, sb); err != nil {
|
||||
return fmt.Errorf("pause %s: %w", sandboxID, err)
|
||||
}
|
||||
phase("memory_loader_wait")
|
||||
|
||||
m.mu.Lock()
|
||||
sb.Status = models.StatusPausing
|
||||
@ -217,7 +203,6 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
if err := m.quiesceAndPauseCH(ctx, sb); err != nil {
|
||||
return rollbackToRunning(err, "quiesce")
|
||||
}
|
||||
phase("quiesce_and_pause")
|
||||
|
||||
// Memory materialisation is handled out-of-band by the background loader
|
||||
// kicked off by Resume after /init. We blocked on it above (waitForMemoryLoader)
|
||||
@ -230,12 +215,11 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
if err := m.vm.Snapshot(ctx, sandboxID, stageDir); err != nil {
|
||||
return rollbackToRunning(err, "snapshot")
|
||||
}
|
||||
phase("ch_snapshot")
|
||||
|
||||
// Zero-page punching is deferred to a background goroutine after the swap
|
||||
// (see startAsyncPunch below). It reads the whole memory-ranges file back
|
||||
// to reclaim zeros CH wrote verbatim — a full IO pass we keep off the
|
||||
// user-perceived pause critical path.
|
||||
// Punch zero pages CH wrote verbatim (guest had them dirty-then-free
|
||||
// without notifying the balloon driver). Best-effort; failures only
|
||||
// cost disk space.
|
||||
punchZeroPagesInDir(stageDir)
|
||||
|
||||
meta := &snapshotMeta{
|
||||
TeamID: id.UUIDString(pgtype.UUID{Bytes: sb.TemplateTeamID, Valid: true}),
|
||||
@ -285,66 +269,11 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
m.mu.Lock()
|
||||
sb.Status = models.StatusPaused
|
||||
m.mu.Unlock()
|
||||
phase("release_and_swap")
|
||||
|
||||
// Reclaim zero pages CH wrote verbatim (guest dirtied-then-freed them
|
||||
// without notifying the balloon driver). Deferred off the critical path:
|
||||
// the VM is destroyed and finalDir is in place, so nothing holds the file
|
||||
// open. Resume/Destroy cancel-and-wait via waitForPunch before reusing or
|
||||
// removing the dir; a punched all-zero block reads back as zero, so the
|
||||
// pass is safe to interrupt at any point.
|
||||
m.startAsyncPunch(sb, finalDir)
|
||||
|
||||
slog.Info("sandbox paused",
|
||||
"id", sandboxID,
|
||||
"snapshot_dir", finalDir,
|
||||
"total_ms", time.Since(phaseStart).Milliseconds())
|
||||
slog.Info("sandbox paused", "id", sandboxID, "snapshot_dir", finalDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startAsyncPunch launches the background zero-page punch for a just-paused
|
||||
// sandbox. Cancellable + joinable via sb.punchCancel / sb.punchDone, which
|
||||
// waitForPunch consumes. Best-effort: a failed or cancelled punch only leaves
|
||||
// the snapshot larger on disk, never incorrect.
|
||||
func (m *Manager) startAsyncPunch(sb *sandboxState, dir string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
m.mu.Lock()
|
||||
sb.punchCancel = cancel
|
||||
sb.punchDone = done
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
started := time.Now()
|
||||
punchZeroPagesInDir(ctx, dir)
|
||||
if ctx.Err() == nil {
|
||||
slog.Info("async zero-page punch complete",
|
||||
"id", sb.ID, "elapsed_ms", time.Since(started).Milliseconds())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// waitForPunch cancels and joins any in-flight background punch for sb, then
|
||||
// clears the handles. Idempotent and safe when no punch is running. Must be
|
||||
// called before Resume relaunches CH on the snapshot or Destroy removes it.
|
||||
func (m *Manager) waitForPunch(sb *sandboxState) {
|
||||
m.mu.Lock()
|
||||
cancel := sb.punchCancel
|
||||
done := sb.punchDone
|
||||
sb.punchCancel = nil
|
||||
sb.punchDone = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// swapDir atomically replaces final with stage. Any existing final dir is
|
||||
// moved aside to a uniquely-named trash dir before the swap so the rename
|
||||
// can succeed, then the trash is removed.
|
||||
@ -548,11 +477,6 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int,
|
||||
return nil, fmt.Errorf("%w: %s (status: %s)", ErrNotPaused, sandboxID, sb.Status)
|
||||
}
|
||||
|
||||
// Stop any background zero-page punch still scanning the snapshot before
|
||||
// CH reopens memory-ranges for --restore. Punching is safe to interrupt;
|
||||
// whatever it reclaimed stays valid, and the rest just stays on disk.
|
||||
m.waitForPunch(sb)
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
|
||||
meta, err := readSnapshotMeta(snapDir)
|
||||
if err != nil {
|
||||
@ -841,10 +765,7 @@ func (m *Manager) snapshotRunningToTemplate(ctx context.Context, sb *sandboxStat
|
||||
sb.connTracker.Reset()
|
||||
return 0, fmt.Errorf("vm.snapshot: %w", err)
|
||||
}
|
||||
// Template snapshots punch synchronously: this path resumes the VM right
|
||||
// after and produces a reusable artefact, so size matters more than the
|
||||
// few seconds saved, and there is no paused-sandbox handle to defer onto.
|
||||
punchZeroPagesInDir(ctx, stageDir)
|
||||
punchZeroPagesInDir(stageDir)
|
||||
|
||||
// Flatten dm-snapshot → rootfs.ext4. Reads through the dm device which is
|
||||
// stable while CH is paused.
|
||||
@ -920,12 +841,6 @@ func (m *Manager) snapshotRunningToTemplate(ctx context.Context, sb *sandboxStat
|
||||
// loader before snapshotting), so we copy those memory files verbatim and
|
||||
// flatten the persistent CoW into rootfs.ext4. The sandbox stays Paused.
|
||||
func (m *Manager) snapshotPausedToTemplate(ctx context.Context, sb *sandboxState, teamID, templateID pgtype.UUID, name string) (int64, error) {
|
||||
// Join the pause's background zero-page punch before copying memory-ranges.
|
||||
// Without this the template would link the un-punched file and report its
|
||||
// full (~2GB) size instead of the reclaimed (~650MB) one — the punch
|
||||
// shrinks the very file we hardlink here.
|
||||
m.waitForPunch(sb)
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sb.ID)
|
||||
meta, err := readSnapshotMeta(snapDir)
|
||||
if err != nil {
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -39,12 +38,7 @@ const (
|
||||
// CH writes its memory dump as one or more files prefixed "memory" inside
|
||||
// the snapshot directory; everything else (config.json, state.json) is
|
||||
// metadata and untouched.
|
||||
//
|
||||
// ctx cancellation aborts the scan between IO chunks so a Resume/Destroy that
|
||||
// supersedes a background punch returns promptly. A cancelled scan leaves the
|
||||
// file valid — already-punched holes read back identically to the zeros they
|
||||
// replaced.
|
||||
func punchZeroPagesInDir(ctx context.Context, dir string) {
|
||||
func punchZeroPagesInDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
slog.Warn("punch: read snapshot dir", "dir", dir, "error", err)
|
||||
@ -54,14 +48,8 @@ func punchZeroPagesInDir(ctx context.Context, dir string) {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "memory") {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
before, after, err := punchZeroPages(ctx, path)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
before, after, err := punchZeroPages(path)
|
||||
if err != nil {
|
||||
slog.Warn("punch: zero-page scan failed", "path", path, "error", err)
|
||||
continue
|
||||
@ -79,7 +67,7 @@ func punchZeroPagesInDir(ctx context.Context, dir string) {
|
||||
// skipped via SEEK_DATA so a partially-sparse input stays cheap to scan.
|
||||
//
|
||||
// Returns the file's disk allocation (st_blocks * 512) before and after.
|
||||
func punchZeroPages(ctx context.Context, path string) (int64, int64, error) {
|
||||
func punchZeroPages(path string) (int64, int64, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
@ -123,17 +111,6 @@ func punchZeroPages(ctx context.Context, path string) (int64, int64, error) {
|
||||
zeroStart := int64(-1)
|
||||
cur := off
|
||||
for cur < endData {
|
||||
if ctx.Err() != nil {
|
||||
// Flush any pending zero run before bailing so the partial
|
||||
// scan still reclaims what it found.
|
||||
if zeroStart >= 0 {
|
||||
if err := punch(f, zeroStart, cur-zeroStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
stAfter, _ := statBlocks(f)
|
||||
return stBefore, stAfter, ctx.Err()
|
||||
}
|
||||
toRead := min(int64(len(buf)), endData-cur)
|
||||
n, err := readAt(f, buf[:toRead], cur)
|
||||
if err != nil {
|
||||
|
||||
@ -110,7 +110,7 @@ func (m *Manager) initAndStartMemoryLoader(ctx context.Context, sb *sandboxState
|
||||
slog.Warn("post-restore PostInit skipped: envd client cleared", "id", sb.ID)
|
||||
return
|
||||
}
|
||||
if err := c.PostInitWithDefaults(initCtx, defaultUser, envVars, sb.ID, templateIDStr, m.cfg.ProxyDomain); err != nil {
|
||||
if err := c.PostInitWithDefaults(initCtx, defaultUser, envVars, sb.ID, templateIDStr); err != nil {
|
||||
slog.Warn("post-restore PostInit failed", "id", sb.ID, "error", err)
|
||||
}
|
||||
|
||||
|
||||
@ -410,23 +410,6 @@ func (q *Queries) UpdateLastActive(ctx context.Context, arg UpdateLastActivePara
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSandboxDiskSize = `-- name: UpdateSandboxDiskSize :exec
|
||||
UPDATE sandboxes
|
||||
SET disk_size_mb = $2,
|
||||
last_updated = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateSandboxDiskSizeParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
DiskSizeMb int32 `json:"disk_size_mb"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateSandboxDiskSize(ctx context.Context, arg UpdateSandboxDiskSizeParams) error {
|
||||
_, err := q.db.Exec(ctx, updateSandboxDiskSize, arg.ID, arg.DiskSizeMb)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateSandboxMetadata = `-- name: UpdateSandboxMetadata :exec
|
||||
UPDATE sandboxes
|
||||
SET metadata = $2,
|
||||
|
||||
@ -410,15 +410,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
|
||||
templateDefaultUser := bctx.User
|
||||
templateDefaultEnv := filterBuildEnv(bctx.EnvVars)
|
||||
|
||||
// Phase 3: Healthcheck — runs before cleanup so a failing healthcheck aborts
|
||||
// immediately and skips post-build (the sandbox is destroyed regardless). It
|
||||
// runs as the template's default user, against the app the recipe started.
|
||||
if !s.runHealthcheck(buildCtx, buildID, build, agent, sandboxIDStr, &logs, streamFn, templateDefaultUser, log) {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Post-build (as root) — cleanup. Only reached once the healthcheck
|
||||
// has passed, so we never clean up an image that fails verification.
|
||||
// Phase 3: Post-build (as root) — cleanup.
|
||||
bctx.User = "root"
|
||||
if !build.SkipPrePost {
|
||||
if !runPhase("post-build", postBuildSteps, 0) {
|
||||
@ -426,7 +418,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize: snapshot/flatten → persist template → mark success.
|
||||
// Finalize: healthcheck/snapshot/flatten → persist template → mark success.
|
||||
s.finalizeBuild(buildCtx, buildID, build, agent, sandboxIDStr, templateDefaultUser, templateDefaultEnv, sandboxMetadata, log)
|
||||
}
|
||||
|
||||
@ -473,7 +465,7 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
Vcpus: build.Vcpus,
|
||||
MemoryMb: build.MemoryMb,
|
||||
TimeoutSec: 0,
|
||||
DiskSizeMb: 0,
|
||||
DiskSizeMb: 5120,
|
||||
}))
|
||||
if err != nil {
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("create sandbox failed: %v", err))
|
||||
@ -496,7 +488,7 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
Vcpus: build.Vcpus,
|
||||
MemoryMb: build.MemoryMb,
|
||||
TimeoutSec: 0,
|
||||
DiskSizeMb: 0,
|
||||
DiskSizeMb: 5120,
|
||||
TemplateID: baseTemplateID,
|
||||
TemplateTeamID: baseTeamID,
|
||||
Metadata: []byte("{}"),
|
||||
@ -504,15 +496,6 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
log.Warn("failed to insert builder sandbox record", "error", err)
|
||||
}
|
||||
|
||||
if resp.Msg.DiskSizeMb > 0 {
|
||||
if err := s.DB.UpdateSandboxDiskSize(ctx, db.UpdateSandboxDiskSizeParams{
|
||||
ID: sandboxID,
|
||||
DiskSizeMb: resp.Msg.DiskSizeMb,
|
||||
}); err != nil {
|
||||
log.Warn("failed to update builder sandbox disk size", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
archive := s.takeArchive(buildIDStr)
|
||||
if len(archive) > 0 {
|
||||
if err := s.uploadAndExtractArchive(ctx, agent, sandboxIDStr, archive, buildIDStr); err != nil {
|
||||
@ -525,9 +508,8 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
return agent, sandboxIDStr, sandboxMetadata, nil
|
||||
}
|
||||
|
||||
// finalizeBuild snapshots (or flattens) the verified rootfs and persists the
|
||||
// template record. Called after the recipe, healthcheck, and post-build phases
|
||||
// all complete successfully.
|
||||
// finalizeBuild handles the healthcheck/snapshot/flatten step and persists the
|
||||
// template record. Called after all recipe phases complete successfully.
|
||||
func (s *BuildService) finalizeBuild(
|
||||
ctx context.Context,
|
||||
buildID pgtype.UUID,
|
||||
@ -541,7 +523,23 @@ func (s *BuildService) finalizeBuild(
|
||||
) {
|
||||
var sizeBytes int64
|
||||
if build.Healthcheck != "" {
|
||||
log.Info("creating snapshot")
|
||||
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
|
||||
if err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
|
||||
if err := s.waitForHealthcheck(ctx, agent, sandboxIDStr, hc, defaultUser); err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("healthcheck passed, creating snapshot")
|
||||
snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Name: build.Name,
|
||||
@ -623,115 +621,19 @@ func (s *BuildService) finalizeBuild(
|
||||
log.Info("template build completed successfully", "name", build.Name)
|
||||
}
|
||||
|
||||
// healthcheckResult captures what a healthcheck run produced, for recording as
|
||||
// a build pseudo-step. Output is the merged per-attempt console log (capped to
|
||||
// hcMaxOutputBytes); Exit is the final attempt's exit code.
|
||||
type healthcheckResult struct {
|
||||
Output string
|
||||
Exit int32
|
||||
Attempts int
|
||||
Elapsed int64 // total wall time in milliseconds
|
||||
}
|
||||
|
||||
// hcMaxOutputBytes bounds how much healthcheck output is retained for the cold
|
||||
// log/replay, so a long-polling check can't grow the persisted log unbounded.
|
||||
// The live stream is never truncated — this only caps what we store.
|
||||
const hcMaxOutputBytes = 32 * 1024
|
||||
|
||||
// runHealthcheck executes the configured healthcheck (if any) as a streamed
|
||||
// pseudo-step, before the post-build cleanup phase. It emits step-start /
|
||||
// output / step-end events live, appends the resulting log entry to *logs, and
|
||||
// persists it. The healthcheck is numbered after every recipe/post-build step
|
||||
// (total_steps+1) and deliberately does not advance current_step.
|
||||
//
|
||||
// Returns true when the build should continue (healthcheck passed or none was
|
||||
// configured), false when it must stop (invalid/failed healthcheck, or the
|
||||
// build was cancelled) — in which case the sandbox has been destroyed and the
|
||||
// build marked failed.
|
||||
func (s *BuildService) runHealthcheck(
|
||||
ctx context.Context,
|
||||
buildID pgtype.UUID,
|
||||
build db.TemplateBuild,
|
||||
agent buildAgentClient,
|
||||
sandboxIDStr string,
|
||||
logs *[]recipe.BuildLogEntry,
|
||||
streamFn recipe.StreamExecFunc,
|
||||
user string,
|
||||
log *slog.Logger,
|
||||
) bool {
|
||||
if build.Healthcheck == "" {
|
||||
return true
|
||||
}
|
||||
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
|
||||
if err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
|
||||
return false
|
||||
}
|
||||
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
|
||||
|
||||
hcStep := int(build.TotalSteps) + 1
|
||||
buildIDStr := id.FormatBuildID(buildID)
|
||||
hcCmd := "HEALTHCHECK " + hc.Cmd
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "step-start", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
})
|
||||
|
||||
// Forward each output chunk to the live console as it arrives.
|
||||
onChunk := func(data []byte) {
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "output", Step: hcStep, Data: base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
res, hcErr := s.streamHealthcheck(ctx, sandboxIDStr, hc, user, streamFn, onChunk)
|
||||
entry := recipe.BuildLogEntry{
|
||||
Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
Stdout: res.Output, Exit: res.Exit, Elapsed: res.Elapsed, Ok: hcErr == nil,
|
||||
}
|
||||
*logs = append(*logs, entry)
|
||||
// Persist while pinning current_step to total_steps so the progress counter
|
||||
// doesn't overshoot — the healthcheck is shown but not counted.
|
||||
s.updateLogs(ctx, buildID, int(build.TotalSteps), *logs)
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "step-end", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
Exit: entry.Exit, Ok: entry.Ok, ElapsedMs: entry.Elapsed,
|
||||
})
|
||||
|
||||
if hcErr != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", hcErr))
|
||||
return false
|
||||
}
|
||||
log.Info("healthcheck passed")
|
||||
return true
|
||||
}
|
||||
|
||||
// streamHealthcheck repeatedly runs the healthcheck command in a PTY inside the
|
||||
// waitForHealthcheck repeatedly executes the healthcheck command inside the
|
||||
// sandbox according to the config's interval, timeout, start-period, and
|
||||
// retries, forwarding output live via onChunk. During the start period,
|
||||
// failures are not counted toward the retry budget. Returns a nil error on the
|
||||
// first successful check, or an error if retries are exhausted, the deadline
|
||||
// passes, or the context is cancelled. The returned healthcheckResult always
|
||||
// carries the captured per-attempt output regardless of outcome.
|
||||
func (s *BuildService) streamHealthcheck(
|
||||
ctx context.Context,
|
||||
sandboxIDStr string,
|
||||
hc recipe.HealthcheckConfig,
|
||||
user string,
|
||||
streamFn recipe.StreamExecFunc,
|
||||
onChunk func(data []byte),
|
||||
) (result healthcheckResult, err error) {
|
||||
// retries.
|
||||
// During the start period, failures are not counted toward the retry budget.
|
||||
// Returns nil on the first successful check, or an error if retries are
|
||||
// exhausted, the deadline passes, or the context is cancelled.
|
||||
func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentClient, sandboxIDStr string, hc recipe.HealthcheckConfig, user string) error {
|
||||
// Wrap the healthcheck command with su when a non-root user is set, so that
|
||||
// ~ expands to the correct home directory and the process runs with the
|
||||
// right UID (matching the template's default user).
|
||||
cmd := hc.Cmd
|
||||
if user != "" && user != "root" {
|
||||
// Drop `-s` so su runs the check under the user's login shell.
|
||||
cmd = "su " + recipe.Shellescape(user) + " -c " + recipe.Shellescape(hc.Cmd)
|
||||
cmd = "su " + recipe.Shellescape(user) + " -s /bin/sh -c " + recipe.Shellescape(hc.Cmd)
|
||||
}
|
||||
ticker := time.NewTicker(hc.Interval)
|
||||
defer ticker.Stop()
|
||||
@ -747,82 +649,41 @@ func (s *BuildService) streamHealthcheck(
|
||||
|
||||
startedAt := time.Now()
|
||||
failCount := 0
|
||||
attempt := 0
|
||||
var output strings.Builder
|
||||
// emit sends a line to the live console and accumulates it (capped) for the
|
||||
// cold log used on reconnect/replay.
|
||||
emit := func(line string) {
|
||||
onChunk([]byte(line))
|
||||
if output.Len() < hcMaxOutputBytes {
|
||||
output.WriteString(line)
|
||||
}
|
||||
}
|
||||
// Populate the result on every return path.
|
||||
defer func() {
|
||||
result.Attempts = attempt
|
||||
result.Elapsed = time.Since(startedAt).Milliseconds()
|
||||
result.Output = strings.TrimRight(output.String(), "\r\n")
|
||||
}()
|
||||
|
||||
// runAttempt streams one healthcheck invocation, forwarding output, and
|
||||
// returns its exit code (or an error if the exec never completed).
|
||||
runAttempt := func() (int32, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
|
||||
defer cancel()
|
||||
ch, err := streamFn(attemptCtx, sandboxIDStr, cmd)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
var exit int32
|
||||
gotDone := false
|
||||
for chunk := range ch {
|
||||
if chunk.Err != nil {
|
||||
return -1, chunk.Err
|
||||
}
|
||||
if chunk.Done {
|
||||
exit, gotDone = chunk.Exit, true
|
||||
continue
|
||||
}
|
||||
emit(string(chunk.Data))
|
||||
}
|
||||
if !gotDone {
|
||||
return -1, fmt.Errorf("healthcheck stream ended without completion")
|
||||
}
|
||||
return exit, nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result, ctx.Err()
|
||||
return ctx.Err()
|
||||
case <-deadlineCh:
|
||||
return result, fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
|
||||
return fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
|
||||
case <-ticker.C:
|
||||
attempt++
|
||||
emit(fmt.Sprintf("\x1b[2m── attempt %d ──\x1b[0m\r\n", attempt))
|
||||
exit, attemptErr := runAttempt()
|
||||
result.Exit = exit
|
||||
execCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
|
||||
resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", cmd},
|
||||
TimeoutSec: int32(hc.Timeout.Seconds()),
|
||||
}))
|
||||
cancel()
|
||||
|
||||
if attemptErr != nil {
|
||||
emit(fmt.Sprintf("exec error: %v\r\n", attemptErr))
|
||||
slog.Debug("healthcheck exec error (retrying)", "error", attemptErr)
|
||||
if err != nil {
|
||||
slog.Debug("healthcheck exec error (retrying)", "error", err)
|
||||
if time.Since(startedAt) >= hc.StartPeriod {
|
||||
failCount++
|
||||
if hc.Retries > 0 && failCount >= hc.Retries {
|
||||
return result, fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, attemptErr)
|
||||
return fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
emit(fmt.Sprintf("\x1b[2m→ exit %d\x1b[0m\r\n", exit))
|
||||
if exit == 0 {
|
||||
return result, nil
|
||||
if resp.Msg.ExitCode == 0 {
|
||||
return nil
|
||||
}
|
||||
slog.Debug("healthcheck failed (retrying)", "exit_code", exit)
|
||||
slog.Debug("healthcheck failed (retrying)", "exit_code", resp.Msg.ExitCode)
|
||||
if time.Since(startedAt) >= hc.StartPeriod {
|
||||
failCount++
|
||||
if hc.Retries > 0 && failCount >= hc.Retries {
|
||||
return result, fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, exit)
|
||||
return fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, resp.Msg.ExitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -885,12 +746,10 @@ func (s *BuildService) ptyStreamExec(agent buildAgentClient) recipe.StreamExecFu
|
||||
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||
SandboxId: sandboxID,
|
||||
Tag: tag,
|
||||
// Bare command: envd wraps it in the user's login shell (resolved
|
||||
// from /etc/passwd), so build steps run under the image's default
|
||||
// shell instead of a forced /bin/sh.
|
||||
Cmd: shellCmd,
|
||||
Cols: buildPtyCols,
|
||||
Rows: buildPtyRows,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", shellCmd},
|
||||
Cols: buildPtyCols,
|
||||
Rows: buildPtyRows,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -968,7 +827,8 @@ func (s *BuildService) fetchSandboxEnv(ctx context.Context,
|
||||
agent buildAgentClient, sandboxIDStr string) (map[string]string, error) {
|
||||
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Cmd: "env",
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", "env"},
|
||||
TimeoutSec: 10,
|
||||
}))
|
||||
if err != nil {
|
||||
@ -1053,7 +913,8 @@ func (s *BuildService) uploadAndExtractArchive(
|
||||
|
||||
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: fullCmd,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", fullCmd},
|
||||
TimeoutSec: 120,
|
||||
}))
|
||||
if err != nil {
|
||||
|
||||
@ -49,9 +49,7 @@ type SandboxCreateParams struct {
|
||||
VCPUs int32
|
||||
MemoryMB int32
|
||||
TimeoutSec int32
|
||||
// Metadata holds user-supplied key/value labels attached at create-time.
|
||||
// Reserved system keys (kernel_version, etc.) are rejected by validation.
|
||||
Metadata map[string]string
|
||||
DiskSizeMB int32
|
||||
}
|
||||
|
||||
// MinTimeoutSec mirrors internal/sandbox.MinTimeoutSec. Sub-minute TTLs race
|
||||
@ -128,15 +126,15 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
if err := validate.SafeName(p.Template); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
|
||||
}
|
||||
if err := validate.Metadata(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
|
||||
}
|
||||
if p.VCPUs <= 0 {
|
||||
p.VCPUs = 1
|
||||
}
|
||||
if p.MemoryMB <= 0 {
|
||||
p.MemoryMB = 512
|
||||
}
|
||||
if p.DiskSizeMB <= 0 {
|
||||
p.DiskSizeMB = 5120 // 5 GB default
|
||||
}
|
||||
p.TimeoutSec = clampTimeout(p.TimeoutSec)
|
||||
|
||||
// Resolve template name → (teamID, templateID). System base templates are
|
||||
@ -167,7 +165,7 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
return db.Sandbox{}, fmt.Errorf("team not found: %w", err)
|
||||
}
|
||||
|
||||
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc, p.MemoryMB, 0)
|
||||
host, err := s.Scheduler.SelectHost(ctx, p.TeamID, team.IsByoc, p.MemoryMB, p.DiskSizeMB)
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("select host: %w", err)
|
||||
}
|
||||
@ -181,13 +179,6 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
sandboxIDStr := id.FormatSandboxID(sandboxID)
|
||||
hostIDStr := id.FormatHostID(host.ID)
|
||||
|
||||
metaJSON := []byte("{}")
|
||||
if len(p.Metadata) > 0 {
|
||||
if metaJSON, err = json.Marshal(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sb, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
|
||||
ID: sandboxID,
|
||||
TeamID: p.TeamID,
|
||||
@ -197,17 +188,16 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
Vcpus: p.VCPUs,
|
||||
MemoryMb: p.MemoryMB,
|
||||
TimeoutSec: p.TimeoutSec,
|
||||
DiskSizeMb: 0,
|
||||
DiskSizeMb: p.DiskSizeMB,
|
||||
TemplateID: templateID,
|
||||
TemplateTeamID: templateTeamID,
|
||||
Metadata: metaJSON,
|
||||
Metadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
|
||||
}
|
||||
|
||||
teamIDStr := id.FormatTeamID(p.TeamID)
|
||||
s.publishStateChanged(ctx, sandboxIDStr, teamIDStr, hostIDStr, "", "starting")
|
||||
go s.createInBackground(sandboxID, sandboxIDStr, hostIDStr, teamIDStr, agent, p, templateTeamID, templateID, templateDefaultUser, templateDefaultEnv)
|
||||
|
||||
return sb, nil
|
||||
@ -230,7 +220,7 @@ func (s *SandboxService) createInBackground(
|
||||
Vcpus: p.VCPUs,
|
||||
MemoryMb: p.MemoryMB,
|
||||
TimeoutSec: p.TimeoutSec,
|
||||
DiskSizeMb: 0,
|
||||
DiskSizeMb: p.DiskSizeMB,
|
||||
DefaultUser: defaultUser,
|
||||
DefaultEnv: defaultEnv,
|
||||
}))
|
||||
@ -250,15 +240,6 @@ func (s *SandboxService) createInBackground(
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Msg.DiskSizeMb > 0 {
|
||||
if err := s.DB.UpdateSandboxDiskSize(bgCtx, db.UpdateSandboxDiskSizeParams{
|
||||
ID: sandboxID,
|
||||
DiskSizeMb: resp.Msg.DiskSizeMb,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to update sandbox disk size", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, dbErr := s.DB.UpdateSandboxRunningIf(bgCtx, db.UpdateSandboxRunningIfParams{
|
||||
ID: sandboxID,
|
||||
@ -272,7 +253,14 @@ func (s *SandboxService) createInBackground(
|
||||
slog.Warn("failed to update sandbox running after create", "id", sandboxIDStr, "error", dbErr)
|
||||
}
|
||||
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, p.Metadata, resp.Msg.Metadata)
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
Event: "sandbox.started", SandboxID: sandboxIDStr, TeamID: teamIDStr, HostID: hostIDStr,
|
||||
@ -281,33 +269,6 @@ func (s *SandboxService) createInBackground(
|
||||
})
|
||||
}
|
||||
|
||||
// persistSystemMetadata merges agent-provided system metadata over the given
|
||||
// user labels and writes the result to the sandbox row. System keys
|
||||
// (kernel_version, etc.) are applied last so they always win — users can never
|
||||
// override protected fields even if create-time validation were bypassed.
|
||||
// userMeta may be nil. No-op when the agent returned no system metadata.
|
||||
func (s *SandboxService) persistSystemMetadata(
|
||||
ctx context.Context, sandboxID pgtype.UUID, sandboxIDStr string,
|
||||
userMeta, systemMeta map[string]string,
|
||||
) {
|
||||
if len(systemMeta) == 0 {
|
||||
return
|
||||
}
|
||||
merged := make(map[string]string, len(userMeta)+len(systemMeta))
|
||||
for k, v := range userMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range systemMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
metaJSON, _ := json.Marshal(merged)
|
||||
if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List returns active sandboxes (excludes stopped/error) belonging to the given team.
|
||||
func (s *SandboxService) List(ctx context.Context, teamID pgtype.UUID) ([]db.Sandbox, error) {
|
||||
return s.DB.ListSandboxesByTeam(ctx, teamID)
|
||||
@ -481,14 +442,13 @@ func (s *SandboxService) resumeInBackground(
|
||||
slog.Warn("failed to update sandbox to running after resume", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
|
||||
// Refresh system metadata but preserve the user's labels — read the current
|
||||
// row and merge agent keys over it rather than overwriting wholesale.
|
||||
if len(resp.Msg.Metadata) > 0 {
|
||||
var existing map[string]string
|
||||
if cur, err := s.DB.GetSandbox(bgCtx, sandboxID); err == nil && len(cur.Metadata) > 0 {
|
||||
_ = json.Unmarshal(cur.Metadata, &existing)
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata after resume", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, existing, resp.Msg.Metadata)
|
||||
}
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// metadataKeyRe matches user metadata keys: alphanumeric start, then
|
||||
// alphanumeric, dash, underscore, or dot. Max 64 characters. Mirrors the
|
||||
// SafeName allowlist so keys stay predictable across the API and UI.
|
||||
var metadataKeyRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
const (
|
||||
// MaxMetadataKeys caps how many user labels a sandbox may carry.
|
||||
MaxMetadataKeys = 20
|
||||
// MaxMetadataValueLen caps the length (in characters) of a label value.
|
||||
MaxMetadataValueLen = 64
|
||||
)
|
||||
|
||||
// reservedMetadataKeys are written by the host agent after a VM boots and must
|
||||
// not be set by users — they carry the immutable system facts about the VM.
|
||||
// Keep in sync with (*sandbox.Manager).buildMetadata: if a key is added there
|
||||
// but not here, a user could set it at create-time and watch it silently get
|
||||
// overwritten by the agent on boot. (validate cannot import internal/sandbox,
|
||||
// hence the duplication — same pattern as service.MinTimeoutSec.)
|
||||
var reservedMetadataKeys = map[string]struct{}{
|
||||
"kernel_version": {},
|
||||
"vmm_version": {},
|
||||
"agent_version": {},
|
||||
"envd_version": {},
|
||||
}
|
||||
|
||||
// Metadata validates a user-supplied sandbox metadata map. It rejects reserved
|
||||
// system keys, enforces a key count limit, and constrains key/value shape so
|
||||
// the data stays safe to render and store. A nil/empty map is valid.
|
||||
func Metadata(meta map[string]string) error {
|
||||
if len(meta) > MaxMetadataKeys {
|
||||
return fmt.Errorf("too many metadata keys: %d (max %d)", len(meta), MaxMetadataKeys)
|
||||
}
|
||||
for k, v := range meta {
|
||||
if _, reserved := reservedMetadataKeys[k]; reserved {
|
||||
return fmt.Errorf("metadata key %q is reserved for system use", k)
|
||||
}
|
||||
if !metadataKeyRe.MatchString(k) {
|
||||
return fmt.Errorf("metadata key %q is invalid (max 64 chars, must match %s)", k, metadataKeyRe.String())
|
||||
}
|
||||
if utf8.RuneCountInString(v) > MaxMetadataValueLen {
|
||||
return fmt.Errorf("metadata value for key %q is too long (max %d chars)", k, MaxMetadataValueLen)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]string{}, false},
|
||||
{"simple", map[string]string{"env": "prod", "owner": "alice"}, false},
|
||||
{"dotted-key", map[string]string{"team.name": "infra"}, false},
|
||||
{"empty-value", map[string]string{"flag": ""}, false},
|
||||
{"max-keys", makeKeys(MaxMetadataKeys), false},
|
||||
|
||||
{"reserved-kernel", map[string]string{"kernel_version": "6.1.0"}, true},
|
||||
{"reserved-vmm", map[string]string{"vmm_version": "x"}, true},
|
||||
{"reserved-agent", map[string]string{"agent_version": "x"}, true},
|
||||
{"reserved-envd", map[string]string{"envd_version": "x"}, true},
|
||||
{"too-many-keys", makeKeys(MaxMetadataKeys + 1), true},
|
||||
{"bad-key-leading-dot", map[string]string{".hidden": "v"}, true},
|
||||
{"bad-key-space", map[string]string{"my key": "v"}, true},
|
||||
{"key-too-long", map[string]string{strings.Repeat("a", 65): "v"}, true},
|
||||
{"value-too-long", map[string]string{"k": strings.Repeat("a", MaxMetadataValueLen+1)}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Metadata(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Metadata(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeKeys(n int) map[string]string {
|
||||
m := make(map[string]string, n)
|
||||
for i := range n {
|
||||
m[fmt.Sprintf("key%d", i)] = "v"
|
||||
}
|
||||
return m
|
||||
}
|
||||
@ -117,13 +117,11 @@ func (x *PTY) GetSize() *PTY_Size {
|
||||
}
|
||||
|
||||
type ProcessConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
|
||||
// User to run the process as. Empty means the sandbox default user.
|
||||
User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -186,13 +184,6 @@ func (x *ProcessConfig) GetCwd() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ProcessConfig) GetUser() string {
|
||||
if x != nil {
|
||||
return x.User
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@ -1722,13 +1713,12 @@ const file_process_proto_rawDesc = "" +
|
||||
"\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" +
|
||||
"\x04Size\x12\x12\n" +
|
||||
"\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" +
|
||||
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xd7\x01\n" +
|
||||
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" +
|
||||
"\rProcessConfig\x12\x10\n" +
|
||||
"\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" +
|
||||
"\x04args\x18\x02 \x03(\tR\x04args\x124\n" +
|
||||
"\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" +
|
||||
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x12\x12\n" +
|
||||
"\x04user\x18\x05 \x01(\tR\x04user\x1a7\n" +
|
||||
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" +
|
||||
"\tEnvsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" +
|
||||
|
||||
@ -37,9 +37,6 @@ message ProcessConfig {
|
||||
|
||||
map<string, string> envs = 3;
|
||||
optional string cwd = 4;
|
||||
|
||||
// User to run the process as. Empty means the sandbox default user.
|
||||
string user = 5;
|
||||
}
|
||||
|
||||
message ListRequest {}
|
||||
|
||||
@ -34,7 +34,8 @@ type CreateSandboxRequest struct {
|
||||
// TTL in seconds. Sandbox is auto-paused after this duration of
|
||||
// inactivity. 0 means no auto-pause.
|
||||
TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
|
||||
// Deprecated: disk size is now determined by the host agent.
|
||||
// Disk size in MB for the rootfs. Base images are expanded to this size
|
||||
// at host agent startup. Default: 5120 (5 GB).
|
||||
DiskSizeMb int32 `protobuf:"varint,6,opt,name=disk_size_mb,json=diskSizeMb,proto3" json:"disk_size_mb,omitempty"`
|
||||
// Team UUID that owns the template (hex string). All-zeros = platform.
|
||||
TeamId string `protobuf:"bytes,7,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"`
|
||||
@ -155,10 +156,7 @@ type CreateSandboxResponse struct {
|
||||
HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"`
|
||||
// Runtime metadata collected during sandbox creation (e.g. envd_version,
|
||||
// kernel_version, vmm_version, agent_version).
|
||||
Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
// Actual disk size in MB allocated for the sandbox rootfs.
|
||||
// Determined by the host agent (max of requested size and origin rootfs size).
|
||||
DiskSizeMb int32 `protobuf:"varint,5,opt,name=disk_size_mb,json=diskSizeMb,proto3" json:"disk_size_mb,omitempty"`
|
||||
Metadata map[string]string `protobuf:"bytes,4,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -221,13 +219,6 @@ func (x *CreateSandboxResponse) GetMetadata() map[string]string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CreateSandboxResponse) GetDiskSizeMb() int32 {
|
||||
if x != nil {
|
||||
return x.DiskSizeMb
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type DestroySandboxRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
|
||||
@ -2985,8 +2976,8 @@ type PtyAttachRequest struct {
|
||||
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
|
||||
// Chosen by the caller and used to reconnect later.
|
||||
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||
// Command to run for a new session. May be empty to launch the user's
|
||||
// default login shell. Ignored when reconnect is true.
|
||||
// If cmd is non-empty, a new process is started. If empty, reconnects to
|
||||
// the existing process identified by tag.
|
||||
Cmd string `protobuf:"bytes,3,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Cols uint32 `protobuf:"varint,5,opt,name=cols,proto3" json:"cols,omitempty"`
|
||||
@ -2996,11 +2987,7 @@ type PtyAttachRequest struct {
|
||||
// Working directory. Empty means default.
|
||||
Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"`
|
||||
// User to run as. Empty means default (root).
|
||||
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
|
||||
// If true, reconnect to the existing process identified by tag instead of
|
||||
// starting a new one. Distinguishes reconnect from a start whose cmd is
|
||||
// empty (login-shell default).
|
||||
Reconnect bool `protobuf:"varint,10,opt,name=reconnect,proto3" json:"reconnect,omitempty"`
|
||||
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -3098,13 +3085,6 @@ func (x *PtyAttachRequest) GetUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PtyAttachRequest) GetReconnect() bool {
|
||||
if x != nil {
|
||||
return x.Reconnect
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PtyAttachResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Event:
|
||||
@ -4280,15 +4260,13 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"defaultEnv\x1a=\n" +
|
||||
"\x0fDefaultEnvEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x95\x02\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xf3\x01\n" +
|
||||
"\x15CreateSandboxResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" +
|
||||
"\ahost_ip\x18\x03 \x01(\tR\x06hostIp\x12M\n" +
|
||||
"\bmetadata\x18\x04 \x03(\v21.hostagent.v1.CreateSandboxResponse.MetadataEntryR\bmetadata\x12 \n" +
|
||||
"\fdisk_size_mb\x18\x05 \x01(\x05R\n" +
|
||||
"diskSizeMb\x1a;\n" +
|
||||
"\bmetadata\x18\x04 \x03(\v21.hostagent.v1.CreateSandboxResponse.MetadataEntryR\bmetadata\x1a;\n" +
|
||||
"\rMetadataEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"6\n" +
|
||||
@ -4501,7 +4479,7 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"templateId\"8\n" +
|
||||
"\x17GetTemplateSizeResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xcc\x02\n" +
|
||||
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xae\x02\n" +
|
||||
"\x10PtyAttachRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
|
||||
@ -4512,9 +4490,7 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"\x04rows\x18\x06 \x01(\rR\x04rows\x12<\n" +
|
||||
"\x04envs\x18\a \x03(\v2(.hostagent.v1.PtyAttachRequest.EnvsEntryR\x04envs\x12\x10\n" +
|
||||
"\x03cwd\x18\b \x01(\tR\x03cwd\x12\x12\n" +
|
||||
"\x04user\x18\t \x01(\tR\x04user\x12\x1c\n" +
|
||||
"\treconnect\x18\n" +
|
||||
" \x01(\bR\treconnect\x1a7\n" +
|
||||
"\x04user\x18\t \x01(\tR\x04user\x1a7\n" +
|
||||
"\tEnvsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" +
|
||||
|
||||
@ -128,7 +128,8 @@ message CreateSandboxRequest {
|
||||
// inactivity. 0 means no auto-pause.
|
||||
int32 timeout_sec = 4;
|
||||
|
||||
// Deprecated: disk size is now determined by the host agent.
|
||||
// Disk size in MB for the rootfs. Base images are expanded to this size
|
||||
// at host agent startup. Default: 5120 (5 GB).
|
||||
int32 disk_size_mb = 6;
|
||||
|
||||
// Team UUID that owns the template (hex string). All-zeros = platform.
|
||||
@ -152,10 +153,6 @@ message CreateSandboxResponse {
|
||||
// Runtime metadata collected during sandbox creation (e.g. envd_version,
|
||||
// kernel_version, vmm_version, agent_version).
|
||||
map<string, string> metadata = 4;
|
||||
|
||||
// Actual disk size in MB allocated for the sandbox rootfs.
|
||||
// Determined by the host agent (max of requested size and origin rootfs size).
|
||||
int32 disk_size_mb = 5;
|
||||
}
|
||||
|
||||
message DestroySandboxRequest {
|
||||
@ -471,8 +468,8 @@ message PtyAttachRequest {
|
||||
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
|
||||
// Chosen by the caller and used to reconnect later.
|
||||
string tag = 2;
|
||||
// Command to run for a new session. May be empty to launch the user's
|
||||
// default login shell. Ignored when reconnect is true.
|
||||
// If cmd is non-empty, a new process is started. If empty, reconnects to
|
||||
// the existing process identified by tag.
|
||||
string cmd = 3;
|
||||
repeated string args = 4;
|
||||
uint32 cols = 5;
|
||||
@ -483,10 +480,6 @@ message PtyAttachRequest {
|
||||
string cwd = 8;
|
||||
// User to run as. Empty means default (root).
|
||||
string user = 9;
|
||||
// If true, reconnect to the existing process identified by tag instead of
|
||||
// starting a new one. Distinguishes reconnect from a start whose cmd is
|
||||
// empty (login-shell default).
|
||||
bool reconnect = 10;
|
||||
}
|
||||
|
||||
message PtyAttachResponse {
|
||||
|
||||
169
recipes/code-runner-beta/test-jupyter-kernel.py
Executable file
169
recipes/code-runner-beta/test-jupyter-kernel.py
Executable file
@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
print("websocket-client is required: pip install websocket-client")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def create_kernel(base_url: str, token: str) -> str:
|
||||
url = f"{base_url}/api/kernels"
|
||||
headers = {}
|
||||
if token:
|
||||
headers["X-API-Key"] = token
|
||||
|
||||
req = urllib.request.Request(url, method="POST", data=b"", headers=headers)
|
||||
resp = urllib.request.urlopen(req)
|
||||
data = json.loads(resp.read())
|
||||
kernel_id = data["id"]
|
||||
print(f"Created kernel: {kernel_id}")
|
||||
return kernel_id
|
||||
|
||||
|
||||
def execute_code(ws: websocket.WebSocket, code: str) -> dict:
|
||||
msg_id = str(uuid.uuid4())
|
||||
session_id = str(uuid.uuid4())
|
||||
msg = {
|
||||
"header": {
|
||||
"msg_type": "execute_request",
|
||||
"msg_id": msg_id,
|
||||
"username": "",
|
||||
"session": session_id,
|
||||
"version": "5.3",
|
||||
"date": "",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
}
|
||||
ws.send(json.dumps(msg))
|
||||
|
||||
result = {"stdout": "", "stderr": "", "output": None, "error": None}
|
||||
|
||||
while True:
|
||||
resp = json.loads(ws.recv())
|
||||
|
||||
# Filter out messages from other executions by matching msg_id
|
||||
parent_id = resp.get("parent_header", {}).get("msg_id")
|
||||
if parent_id != msg_id:
|
||||
continue
|
||||
|
||||
msg_type = resp.get("msg_type", "")
|
||||
|
||||
if msg_type == "stream":
|
||||
result["stdout"] += resp["content"]["text"]
|
||||
elif msg_type == "error":
|
||||
result["error"] = "\n".join(resp["content"].get("traceback", []))
|
||||
elif msg_type == "execute_result":
|
||||
result["output"] = resp["content"]["data"]
|
||||
elif msg_type == "status":
|
||||
if resp["content"]["execution_state"] == "idle":
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Jupyter kernel state management in a sandbox"
|
||||
)
|
||||
parser.add_argument(
|
||||
"sandbox_id",
|
||||
help="Sandbox ID (e.g. cl-8nxizn9ygtczplsnn9jve38be)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--domain",
|
||||
default="localhost:8080",
|
||||
help="Proxy domain (default: localhost:8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
default="8888",
|
||||
help="Jupyter port inside the sandbox (default: 8888)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default="",
|
||||
help="Wrenn API Token",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = f"http://{args.port}-{args.sandbox_id}.{args.domain}"
|
||||
ws_base = base_url.replace("http", "ws", 1)
|
||||
|
||||
print(f"Testing Jupyter kernel at {base_url}")
|
||||
print()
|
||||
|
||||
kernel_id = create_kernel(base_url, args.key)
|
||||
|
||||
ws_url = f"{ws_base}/api/kernels/{kernel_id}/channels"
|
||||
|
||||
# Pass auth headers to the WebSocket if a token was provided
|
||||
ws_headers = {}
|
||||
if args.key:
|
||||
ws_headers["X-API-Key"] = args.key
|
||||
|
||||
ws = websocket.create_connection(ws_url, header=ws_headers)
|
||||
print("Connected to kernel WebSocket")
|
||||
print()
|
||||
|
||||
tests = [
|
||||
("variable assignment", "x = 42", None),
|
||||
("read variable", "x * 2", "84"),
|
||||
("import", "import math", None),
|
||||
("use import", "math.sqrt(144)", "12.0"),
|
||||
("function definition", "def greet(name): return f'hello {name}'", None),
|
||||
# Fixed: Jupyter 'execute_result' strings include the literal single quotes
|
||||
("call function", "greet('sandbox')", "'hello sandbox'"),
|
||||
("list mutation", "items = [1, 2, 3]; items.append(4); items", "[1, 2, 3, 4]"),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for name, code, expected in tests:
|
||||
print(f" {name}: {code}")
|
||||
result = execute_code(ws, code)
|
||||
|
||||
if result["error"]:
|
||||
print(f" ERROR: {result['error']}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
output = result["stdout"].strip()
|
||||
if not output and result["output"]:
|
||||
if "text/plain" in result["output"]:
|
||||
output = result["output"]["text/plain"].strip()
|
||||
|
||||
if expected is not None:
|
||||
if output == expected:
|
||||
print(f" PASS (got: {output})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL (expected: {expected}, got: {output})")
|
||||
failed += 1
|
||||
else:
|
||||
print(" OK")
|
||||
passed += 1
|
||||
|
||||
ws.close()
|
||||
print()
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user