forked from wrenn/wrenn
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a70ea99915 | |||
| 4b819394a5 | |||
| e824b97894 | |||
| af69ed3442 | |||
| a08e755e53 | |||
| cfc0c52010 | |||
| 05ddf62399 | |||
| 4707f16c76 |
27
.env.example
27
.env.example
@ -16,7 +16,22 @@ WRENN_HOST_LISTEN_ADDR=:50051
|
|||||||
WRENN_HOST_INTERFACE=eth0
|
WRENN_HOST_INTERFACE=eth0
|
||||||
WRENN_CP_URL=http://localhost:9725
|
WRENN_CP_URL=http://localhost:9725
|
||||||
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
|
WRENN_DEFAULT_ROOTFS_SIZE=5Gi
|
||||||
WRENN_FIRECRACKER_BIN=/usr/local/bin/firecracker
|
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
|
# Auth
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
@ -32,6 +47,16 @@ WRENN_CA_KEY=
|
|||||||
# Channels (notification destinations)
|
# Channels (notification destinations)
|
||||||
# AES-256-GCM key for encrypting channel secrets. Generate with: openssl rand -hex 32
|
# AES-256-GCM key for encrypting channel secrets. Generate with: openssl rand -hex 32
|
||||||
WRENN_ENCRYPTION_KEY=
|
WRENN_ENCRYPTION_KEY=
|
||||||
|
# Allow notification channels to target private/loopback/link-local addresses.
|
||||||
|
# Off by default to prevent SSRF; set to true only on self-hosted deployments
|
||||||
|
# that deliver to internal endpoints.
|
||||||
|
WRENN_CHANNELS_ALLOW_PRIVATE=false
|
||||||
|
|
||||||
|
# External storage volumes
|
||||||
|
# Largest size a single volume may be created with. Accepts G/Gi/M/Mi suffixes
|
||||||
|
# (same form as WRENN_DEFAULT_ROOTFS_SIZE). Volumes are sparse files, so this
|
||||||
|
# bounds worst-case growth rather than upfront allocation.
|
||||||
|
WRENN_MAX_VOLUME_SIZE=20Gi
|
||||||
|
|
||||||
# OAuth
|
# OAuth
|
||||||
OAUTH_GITHUB_CLIENT_ID=
|
OAUTH_GITHUB_CLIENT_ID=
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -33,13 +33,12 @@ go.work.sum
|
|||||||
|
|
||||||
## AI
|
## AI
|
||||||
.claude/
|
.claude/
|
||||||
e2b/
|
|
||||||
.impeccable.md
|
.impeccable.md
|
||||||
.gstack
|
.gstack
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
## Builds
|
## Builds
|
||||||
builds/
|
/builds/
|
||||||
|
|
||||||
## Rust
|
## Rust
|
||||||
envd-rs/target/
|
envd-rs/target/
|
||||||
@ -55,3 +54,7 @@ internal/dashboard/static/*
|
|||||||
.dual-graph/
|
.dual-graph/
|
||||||
# Added by code-review-graph
|
# Added by code-review-graph
|
||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# tokensave
|
||||||
|
.tokensave/
|
||||||
|
|||||||
63
.woodpecker/pipeline.yml
Normal file
63
.woodpecker/pipeline.yml
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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]
|
||||||
112
.woodpecker/scripts/build_go.py
Normal file
112
.woodpecker/scripts/build_go.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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()
|
||||||
105
.woodpecker/scripts/build_rust.py
Normal file
105
.woodpecker/scripts/build_rust.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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()
|
||||||
104
.woodpecker/scripts/publish_github.py
Normal file
104
.woodpecker/scripts/publish_github.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
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()
|
||||||
237
.woodpecker/scripts/release_notes.py
Normal file
237
.woodpecker/scripts/release_notes.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
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/big-pickle"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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()
|
||||||
262
CLAUDE.md
262
CLAUDE.md
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Firecracker 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.
|
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.)
|
||||||
|
|
||||||
## Build & Development Commands
|
## Build & Development Commands
|
||||||
|
|
||||||
@ -23,12 +23,12 @@ make dev-down # Stop dev infra
|
|||||||
make dev-cp # Control plane with hot reload (if air installed)
|
make dev-cp # Control plane with hot reload (if air installed)
|
||||||
make dev-frontend # Vite dev server with HMR (port 5173)
|
make dev-frontend # Vite dev server with HMR (port 5173)
|
||||||
make dev-agent # Host agent (sudo required)
|
make dev-agent # Host agent (sudo required)
|
||||||
make dev-envd # envd in debug mode (--isnotfc, port 49983)
|
make dev-envd # envd in debug mode (port 49983)
|
||||||
|
|
||||||
make check # fmt + vet + lint + test (CI order)
|
make check # fmt + vet + lint + test (CI order)
|
||||||
make test # Unit tests: go test -race -v ./internal/...
|
make test # Unit tests: go test -race -v ./internal/...
|
||||||
make test-integration # Integration tests (require host agent + Firecracker)
|
make test-integration # Integration tests (require host agent + Cloud Hypervisor)
|
||||||
make fmt # gofmt
|
make fmt # gofmt and rust fmt
|
||||||
make vet # go vet
|
make vet # go vet
|
||||||
make lint # golangci-lint
|
make lint # golangci-lint
|
||||||
|
|
||||||
@ -60,42 +60,55 @@ User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/
|
|||||||
|
|
||||||
envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completely independent from the Go module — the only connection is the protobuf contract. It compiles to a statically linked musl binary baked into rootfs images.
|
envd is a standalone Rust binary (Tokio + Axum + connectrpc-rs). It is completely independent from the Go module — the only connection is the protobuf contract. It compiles to a statically linked musl binary baked into rootfs images.
|
||||||
|
|
||||||
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The reconciler (`internal/api/reconciler.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
|
**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The host monitor (`internal/api/host_monitor.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped".
|
||||||
|
|
||||||
### Control Plane
|
### Control Plane
|
||||||
|
|
||||||
**Internal packages:** `internal/api/`, `internal/email/`
|
**Internal packages:** `internal/api/`, `internal/email/`
|
||||||
|
|
||||||
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`
|
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/auth/session/`, `pkg/auth/session/middleware/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`, `pkg/netutil/`
|
||||||
|
|
||||||
**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext`), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`)
|
**Extension framework:** `pkg/cpextension/` (shared `Extension` interface + `ServerContext` + hook interfaces), `pkg/cpserver/` (exported `Run()` entrypoint with functional options for cloud `main.go`)
|
||||||
|
|
||||||
The cloud repo imports this module as a Go dependency and calls `cpserver.Run(cpserver.WithExtensions(myExt))`. Each extension implements two methods: `RegisterRoutes(r chi.Router, sctx ServerContext)` to add HTTP routes, and `BackgroundWorkers(sctx ServerContext) []func(context.Context)` to add long-running goroutines. `ServerContext` carries all OSS services (DB, scheduler, auth, etc.) so extensions can use them without reimplementing anything. To expose a new OSS service to extensions, add it to `ServerContext` in `pkg/cpextension/extension.go` and populate it in `pkg/cpserver/run.go`.
|
The cloud repo imports this module as a Go dependency and calls `cpserver.Run(cpserver.WithExtensions(myExt))`. An extension always implements:
|
||||||
|
- `RegisterRoutes(r chi.Router, sctx ServerContext)` — adds HTTP routes.
|
||||||
|
- `BackgroundWorkers(sctx ServerContext) []func(context.Context)` — starts long-running goroutines.
|
||||||
|
|
||||||
**pkg/ vs internal/ decision rule:** A package belongs in `pkg/` only if the cloud repo needs to import it directly. Everything else stays in `internal/`. New OSS services (e.g. email, notifications) go in `internal/` — the cloud repo accesses them through `ServerContext`, not by importing the package. Do not put a service in `pkg/` just because the cloud repo uses it.
|
It can optionally implement any of these hook interfaces (the OSS server type-asserts at startup):
|
||||||
|
- `MiddlewareProvider` — `Middlewares(sctx) []func(http.Handler) http.Handler`, applied before OSS routes so cloud middleware can wrap them (e.g. billing gates).
|
||||||
|
- `AuthHook` — `OnSignup` (synchronous, error aborts the request with 500 `signup_hook_failed`), `OnLogin`, `OnAccountSoftDelete`, `OnAccountHardDelete` (the last three log + ignore errors). OnSignup fires after team provisioning in both email-activate and OAuth-new-signup paths.
|
||||||
|
- `SandboxEventHook` — `OnSandboxEvent(ctx, SandboxEvent)`, invoked from the unified Redis stream consumer for capsule create/pause/resume/destroy success events. Hook errors leave the message un-acked so it will be redelivered; hooks must be idempotent.
|
||||||
|
|
||||||
|
`ServerContext` carries the initialized OSS dependencies: `Queries`, `PgPool`, `Redis`, `HostPool`, `Scheduler`, `CA`, `Audit`, `Mailer`, `OAuthRegistry`, `Channels`, `ChannelPub`, `JWTSecret`, `Sessions`, `Config`. To expose a new OSS service to extensions, add it to `ServerContext` in `pkg/cpextension/extension.go` and populate it in `pkg/cpserver/run.go`.
|
||||||
|
|
||||||
|
**Auth helpers for extensions** (`pkg/auth/session/middleware/`, re-exported via `cpextension`): `RequireSession(sctx)`, `RequireSessionOrAPIKey(sctx)`, `RequireAdmin(sctx)`, `RequireCSRF()`, `IssueSession(w, r, sctx, userID, teamID)`, `ClearSessionCookies(w, r)`. Cookie/header names are exported as `SessionCookieName`, `CSRFCookieName`, `CSRFHeaderName`. OSS handlers (`internal/api/middleware_session.go`) are thin shims over this package — single source of truth.
|
||||||
|
|
||||||
|
**pkg/ vs internal/ decision rule:** A package belongs in `pkg/` only if an external module needs to import it directly — the cloud repo, or standalone consumers of the sandbox runtime (e.g. the `wr` CLI, which embeds the host agent's orchestration packages to run sandboxes without a control plane). Everything else stays in `internal/`. New OSS services (e.g. email, notifications) go in `internal/` — the cloud repo accesses them through `ServerContext`, not by importing the package. Do not put a service in `pkg/` just because the cloud repo uses it.
|
||||||
|
|
||||||
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
|
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
|
||||||
|
|
||||||
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. Accepts `[]cpextension.Extension` — each extension's `RegisterRoutes()` is called after all core routes are registered.
|
- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/capsules/*`. Accepts `[]cpextension.Extension` — each extension's `RegisterRoutes()` is called after all core routes are registered.
|
||||||
- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped".
|
- **Host monitor** (`internal/api/host_monitor.go`): safety-net reconciliation goroutine (every 5min, wired in `pkg/cpserver/run.go` via `NewHostMonitor`). Primary state sync is push-based (host agent callbacks + CP background goroutines); the monitor is the fallback for missed events, host-death detection, and transient-status resolution. For each online host it calls `ListSandboxes()` and reconciles orphaned DB entries.
|
||||||
- **Dashboard** (SvelteKit + Tailwind + Bits UI, built to static files in `frontend/build/`, served by Caddy as a reverse proxy)
|
- **Dashboard** (SvelteKit + Tailwind + Bits UI, built to static files in `frontend/build/`, served by Caddy as a reverse proxy)
|
||||||
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/*.sql` → `pkg/db/`. Migrations in `db/migrations/` (goose, plain SQL). `db/migrations/embed.go` exposes `migrations.FS` so the cloud repo can run OSS migrations via `go:embed`.
|
- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/*.sql` → `pkg/db/`. Migrations in `db/migrations/` (goose, plain SQL). `db/migrations/embed.go` exposes `migrations.FS` so the cloud repo can run OSS migrations via `go:embed`.
|
||||||
- **Config** (`pkg/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config.
|
- **Config** (`pkg/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config.
|
||||||
|
|
||||||
### Host Agent
|
### Host Agent
|
||||||
|
|
||||||
**Packages:** `internal/hostagent/`, `internal/sandbox/`, `internal/vm/`, `internal/network/`, `internal/devicemapper/`, `internal/envdclient/`, `internal/snapshot/`
|
**Packages:** `internal/hostagent/` (CP-facing: RPC server, registration, heartbeat, mTLS, proxy), plus the public sandbox runtime importable by external consumers such as the `wr` CLI: `pkg/sandbox/`, `pkg/vm/`, `pkg/network/`, `pkg/devicemapper/`, `pkg/envdclient/`, `pkg/layout/`, `pkg/models/`. `internal/snapshot/` stays internal (only used by `pkg/sandbox`, which may import it intra-module).
|
||||||
|
|
||||||
**Production deployment:** `scripts/prepare-wrenn-user.sh` creates the `wrenn` system user, sets Linux capabilities (setcap) on wrenn-agent and all child binaries (iptables, losetup, dmsetup, etc.), installs an apt hook to restore capabilities after package updates, configures udev rules for `/dev/net/tun`, loads required kernel modules, and writes systemd unit files for both services. No sudo grants — all privilege is via capabilities.
|
**Standalone/library use:** `pkg/sandbox` exposes the full startup ritual (`CheckPrivileges`, `EnsureIPForward`, `Setup(SetupOptions)`) so a CLI can wire a `sandbox.Manager` without the RPC server. A library consumer that re-attaches to sandboxes created by earlier processes must set `SetupOptions.SkipCleanup` — the default stale-cleanup kills every cloud-hypervisor process on the host. Running sandboxes persist re-attach state to `{wrennDir}/sandboxes/{id}/sandbox.json`; `Manager.RestoreRunningSandboxes()` (call before `RestorePausedSandboxes()`) re-attaches live VMs across processes. Network slots are claimed cross-process-safely via O_EXCL files in `{wrennDir}/slots/`.
|
||||||
|
|
||||||
|
**Production deployment:** `make setup-host` (→ `scripts/setup-host.sh`) prepares the host: creates the `wrenn` system user, sets Linux capabilities (setcap) on wrenn-agent and all child binaries (iptables, losetup, dmsetup, etc.), installs an apt hook to restore capabilities after package updates, configures udev rules for `/dev/net/tun`, and loads required kernel modules. No sudo grants — all privilege is via capabilities. `make install` then copies the binaries to `/usr/local/bin` and installs the systemd units from `deploy/systemd/`.
|
||||||
|
|
||||||
Startup (`cmd/host-agent/main.go`) wires: root/capabilities check → enable IP forwarding → clean up stale dm devices → `sandbox.Manager` (containing `vm.Manager` + `network.SlotAllocator` + `devicemapper.LoopRegistry`) → `hostagent.Server` (Connect RPC handler) → HTTP server.
|
Startup (`cmd/host-agent/main.go`) wires: root/capabilities check → enable IP forwarding → clean up stale dm devices → `sandbox.Manager` (containing `vm.Manager` + `network.SlotAllocator` + `devicemapper.LoopRegistry`) → `hostagent.Server` (Connect RPC handler) → HTTP server.
|
||||||
|
|
||||||
- **RPC Server** (`internal/hostagent/server.go`): implements `hostagentv1connect.HostAgentServiceHandler`. Thin wrapper — every method delegates to `sandbox.Manager`. Maps Connect error codes on return.
|
- **RPC Server** (`internal/hostagent/server.go`): implements `hostagentv1connect.HostAgentServiceHandler`. Thin wrapper — every method delegates to `sandbox.Manager`. Maps Connect error codes on return.
|
||||||
- **Sandbox Manager** (`internal/sandbox/manager.go`): the core orchestration layer. Maintains in-memory state in `boxes map[string]*sandboxState` (protected by `sync.RWMutex`). Each `sandboxState` holds a `models.Sandbox`, a `*network.Slot`, and an `*envdclient.Client`. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes.
|
- **Sandbox Manager** (`pkg/sandbox/manager.go`): the core orchestration layer. Maintains in-memory state in `boxes map[string]*sandboxState` (protected by `sync.RWMutex`). Each `sandboxState` holds a `models.Sandbox`, a `*network.Slot`, and an `*envdclient.Client`. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes.
|
||||||
- **VM Manager** (`internal/vm/manager.go`, `fc.go`, `config.go`): manages Firecracker processes. Uses raw HTTP API over Unix socket (`/tmp/fc-{sandboxID}.sock`), not the firecracker-go-sdk Machine type. Launches Firecracker via `unshare -m` + `ip netns exec`. Configures VM via PUT to `/boot-source`, `/drives/rootfs`, `/network-interfaces/eth0`, `/machine-config`, then starts with PUT `/actions`.
|
- **VM Manager** (`pkg/vm/manager.go`, `ch.go`, `config.go`): manages Cloud Hypervisor processes. Uses raw HTTP API over Unix socket (`/tmp/ch-{sandboxID}.sock`). Launches Cloud Hypervisor via `unshare -m` + `ip netns exec` with `--api-socket path=...`. Configures and boots VM via `PUT /vm.create` + `PUT /vm.boot`. Snapshot restore uses `--restore source_url=file://...`. `Reattach()` adopts a still-live CH process started by an earlier process (exit detection via kill-0 polling).
|
||||||
- **Network** (`internal/network/setup.go`, `allocator.go`): per-sandbox network namespace with veth pair + TAP device. See Networking section below.
|
- **Network** (`pkg/network/setup.go`, `allocator.go`): per-sandbox network namespace with veth pair + TAP device. Slot indices claimed cross-process via O_EXCL files. See Networking section below.
|
||||||
- **Device Mapper** (`internal/devicemapper/devicemapper.go`): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcounted `LoopRegistry`), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten operations.
|
- **Device Mapper** (`pkg/devicemapper/devicemapper.go`): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcounted `LoopRegistry`), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten/reattach operations.
|
||||||
- **envd Client** (`internal/envdclient/client.go`, `health.go`): dual interface to the guest agent. Connect RPC for streaming process exec (`process.Start()` bidirectional stream). Plain HTTP for file operations (POST/GET `/files?path=...&username=root`). Health check polls `GET /health` every 100ms until ready (30s timeout).
|
- **envd Client** (`pkg/envdclient/client.go`, `health.go`): dual interface to the guest agent. Connect RPC for streaming process exec (`process.Start()` bidirectional stream). Plain HTTP for file operations (POST/GET `/files?path=...&username=root`). Health check polls `GET /health` every 100ms until ready (30s timeout).
|
||||||
|
|
||||||
### envd (Guest Agent)
|
### envd (Guest Agent)
|
||||||
|
|
||||||
@ -109,14 +122,14 @@ Runs as PID 1 inside the microVM via `wrenn-init.sh` (mounts procfs/sysfs/dev, s
|
|||||||
- **HTTP endpoints**: GET `/health`, GET `/metrics`, POST `/init`, POST `/snapshot/prepare`, GET/POST `/files`
|
- **HTTP endpoints**: GET `/health`, GET `/metrics`, POST `/init`, POST `/snapshot/prepare`, GET/POST `/files`
|
||||||
- **Proto codegen**: `connectrpc-build` compiles `proto/envd/*.proto` at `cargo build` time via `build.rs` — no committed stubs
|
- **Proto codegen**: `connectrpc-build` compiles `proto/envd/*.proto` at `cargo build` time via `build.rs` — no committed stubs
|
||||||
- **Build**: `make build-envd` → static musl binary in `builds/envd`
|
- **Build**: `make build-envd` → static musl binary in `builds/envd`
|
||||||
- **Dev**: `make dev-envd` → `cargo run -- --isnotfc --port 49983`
|
- **Dev**: `make dev-envd` → `cargo run -- --port 49983`
|
||||||
|
|
||||||
### Dashboard (Frontend)
|
### Dashboard (Frontend)
|
||||||
|
|
||||||
**Directory:** `frontend/` — standalone SvelteKit app (Svelte 5, runes mode)
|
**Directory:** `frontend/` — standalone SvelteKit app (Svelte 5, runes mode)
|
||||||
|
|
||||||
- **Stack**: SvelteKit + `adapter-static` + Tailwind CSS v4 + Bits UI (headless accessible components)
|
- **Stack**: SvelteKit + `adapter-static` + Tailwind CSS v4 + Bits UI (headless accessible components)
|
||||||
- **Package manager**: pnpm
|
- **Package manager**: Bun
|
||||||
- **Routing**: SvelteKit file-based routing under `frontend/src/routes/`
|
- **Routing**: SvelteKit file-based routing under `frontend/src/routes/`
|
||||||
- **Routing layout**: `/login` and `/signup` at root, authenticated pages under `/dashboard/*` (e.g. `/dashboard/capsules`, `/dashboard/keys`)
|
- **Routing layout**: `/login` and `/signup` at root, authenticated pages under `/dashboard/*` (e.g. `/dashboard/capsules`, `/dashboard/keys`)
|
||||||
- **Build output**: `frontend/build/` — static files served by Caddy
|
- **Build output**: `frontend/build/` — static files served by Caddy
|
||||||
@ -145,7 +158,7 @@ veth-{idx} ←──── veth pair ────→ eth0
|
|||||||
- **Outbound NAT**: guest (169.254.0.21) → SNAT to vpeerIP inside namespace → MASQUERADE on host to default interface
|
- **Outbound NAT**: guest (169.254.0.21) → SNAT to vpeerIP inside namespace → MASQUERADE on host to default interface
|
||||||
- **Inbound NAT**: host traffic to 10.11.0.{idx} → DNAT to 169.254.0.21 inside namespace
|
- **Inbound NAT**: host traffic to 10.11.0.{idx} → DNAT to 169.254.0.21 inside namespace
|
||||||
- IP forwarding enabled inside each namespace
|
- IP forwarding enabled inside each namespace
|
||||||
- All details in `internal/network/setup.go`
|
- All details in `pkg/network/setup.go`
|
||||||
|
|
||||||
### Sandbox State Machine
|
### Sandbox State Machine
|
||||||
```
|
```
|
||||||
@ -164,7 +177,7 @@ HIBERNATED → RUNNING (cold snapshot resume, slower)
|
|||||||
**Sandbox creation** (`POST /v1/capsules`):
|
**Sandbox creation** (`POST /v1/capsules`):
|
||||||
1. API handler generates sandbox ID, inserts into DB as "pending"
|
1. API handler generates sandbox ID, inserts into DB as "pending"
|
||||||
2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()`
|
2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()`
|
||||||
3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state
|
3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Cloud Hypervisor with `/dev/mapper/wrenn-{id}`, configure via `PUT /vm.create` + `PUT /vm.boot`) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state
|
||||||
4. API handler updates DB to "running" with host_ip
|
4. API handler updates DB to "running" with host_ip
|
||||||
|
|
||||||
**Command execution** (`POST /v1/capsules/{id}/exec`):
|
**Command execution** (`POST /v1/capsules/{id}/exec`):
|
||||||
@ -183,7 +196,25 @@ HIBERNATED → RUNNING (cold snapshot resume, slower)
|
|||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
Routes defined in `internal/api/server.go`, handlers in `internal/api/handlers_*.go`. OpenAPI spec embedded via `//go:embed` and served at `/openapi.yaml` (Swagger UI at `/docs`). JSON request/response. API key auth via `X-API-Key` header. Error responses: `{"error": {"code": "...", "message": "..."}}`.
|
Routes defined in `internal/api/server.go`, handlers in `internal/api/handlers_*.go`. OpenAPI spec embedded via `//go:embed` and served at `/openapi.yaml` (Swagger UI at `/docs`). JSON request/response. Error responses: `{"error": {"code": "...", "message": "..."}}`.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Two paths, no JWTs for user auth:
|
||||||
|
|
||||||
|
- **SDK / server-to-server**: `X-API-Key: wrn_<32hex>` header. Keys are created via the dashboard or `POST /v1/api-keys`, SHA-256-hashed at rest, scoped to a single team. `requireSessionOrAPIKey` middleware accepts this on every capsule lifecycle route.
|
||||||
|
- **Browser (dashboard)**: opaque cookie session. `POST /v1/auth/login` (or activate / oauth-callback) sets two cookies — `wrenn_sid` (HttpOnly+Secure+SameSite=Strict) and `wrenn_csrf` (readable by JS, SameSite=Strict). All non-GET requests must echo the CSRF token in an `X-CSRF-Token` header (double-submit). Sessions live in Postgres `sessions` plus a Redis cache (`wrenn:session:{sid}`) — see `pkg/auth/session/`.
|
||||||
|
|
||||||
|
Session semantics:
|
||||||
|
- **Idle**: 6h. Each request bumps the Redis TTL. Postgres `last_seen_at` is updated on a debounce.
|
||||||
|
- **Absolute**: 24h from `created_at` (`expires_at`). Never extended.
|
||||||
|
- **Rotation**: `POST /v1/auth/switch-team` issues a fresh SID and re-sets both cookies; old SID revoked.
|
||||||
|
- **Revocation**: password change, password add, and password reset all call `RevokeAllForUser`. Self-service is at `GET /v1/me/sessions`, `DELETE /v1/me/sessions/{id}`, `POST /v1/auth/logout`, `POST /v1/auth/logout-all`.
|
||||||
|
- **`is_admin` freshness**: the session blob caches it for display, but `requireAdmin` always re-reads Postgres so revoked admins lose access at the next admin request.
|
||||||
|
|
||||||
|
Host JWTs (long-lived, signed by `JWT_SECRET`) are unchanged — that is the wrenn-cp ↔ wrenn-agent trust channel and has nothing to do with user auth.
|
||||||
|
|
||||||
|
SSE / WebSocket auth: browsers send the `wrenn_sid` cookie automatically on `EventSource` and WS upgrades (same-origin via Caddy); SDKs set `X-API-Key`. No ticket exchange is involved.
|
||||||
|
|
||||||
## Code Generation
|
## Code Generation
|
||||||
|
|
||||||
@ -210,9 +241,9 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
|
|||||||
|
|
||||||
- **Connect RPC** (not gRPC) for all RPC communication between components
|
- **Connect RPC** (not gRPC) for all RPC communication between components
|
||||||
- **Buf + protoc-gen-connect-go** for Go code generation; **connectrpc-build** for Rust code generation in envd
|
- **Buf + protoc-gen-connect-go** for Go code generation; **connectrpc-build** for Rust code generation in envd
|
||||||
- **Raw Firecracker HTTP API** via Unix socket (not firecracker-go-sdk Machine type)
|
- **Raw Cloud Hypervisor HTTP API** via Unix socket (`PUT /vm.create` + `PUT /vm.boot`)
|
||||||
- **TAP networking** (not vsock) for host-to-envd communication
|
- **TAP networking** (not vsock) for host-to-envd communication
|
||||||
- **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Firecracker gets `/dev/mapper/wrenn-{id}`
|
- **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Cloud Hypervisor gets `/dev/mapper/wrenn-{id}`
|
||||||
- **PostgreSQL** via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down)
|
- **PostgreSQL** via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down)
|
||||||
- **Dashboard**: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files in `frontend/build/`, served by Caddy (not embedded in the Go binary)
|
- **Dashboard**: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files in `frontend/build/`, served by Caddy (not embedded in the Go binary)
|
||||||
- **Lago** for billing (external service, not in this codebase)
|
- **Lago** for billing (external service, not in this codebase)
|
||||||
@ -220,7 +251,7 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
|
|||||||
## Coding Conventions
|
## Coding Conventions
|
||||||
|
|
||||||
- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state
|
- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state
|
||||||
- **Naming**: Sandbox IDs `sb-` + 8 hex, API keys `wrn_` + 32 chars, Host IDs `host-` + 8 hex
|
- **Naming**: IDs are a type prefix + a 25-char base36-encoded UUID (`pkg/id`) — sandboxes `cl-`, users `usr-`, teams `team-`, hosts `host-`, etc. API keys are `wrn_` + 32 hex
|
||||||
- **Dependencies**: Use `go get` to add Go deps, never hand-edit go.mod. For envd-rs deps: edit `envd-rs/Cargo.toml`
|
- **Dependencies**: Use `go get` to add Go deps, never hand-edit go.mod. For envd-rs deps: edit `envd-rs/Cargo.toml`
|
||||||
- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
|
- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore
|
||||||
- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually
|
- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually
|
||||||
@ -229,184 +260,13 @@ To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `
|
|||||||
## Rootfs & Guest Init
|
## Rootfs & Guest Init
|
||||||
|
|
||||||
- **wrenn-init** (`images/wrenn-init.sh`): the PID 1 init script baked into every rootfs. Mounts virtual filesystems, sets hostname, writes `/etc/resolv.conf`, then execs envd.
|
- **wrenn-init** (`images/wrenn-init.sh`): the PID 1 init script baked into every rootfs. Mounts virtual filesystems, sets hostname, writes `/etc/resolv.conf`, then execs envd.
|
||||||
- **Updating the rootfs** after changing envd or wrenn-init: `bash scripts/update-minimal-rootfs.sh`. This builds envd via `make build-envd` (Rust → static musl binary), mounts the rootfs image, copies in the new binaries, and unmounts. Defaults to `/var/lib/wrenn/images/minimal.ext4`.
|
- **System base templates**: four built-in distro images — `minimal-ubuntu` (id 0, default), `minimal-alpine` (1), `minimal-arch` (2), `minimal-fedora` (3) — built via `images/build-{ubuntu,alpine,arch,fedora}.sh` (or `make images`). All platform-owned, protected from deletion (reserved IDs 0–1024). Same static envd + tini run on all four. Each has a `wrenn-user` with passwordless sudo.
|
||||||
- Rootfs images are minimal debootstrap — no systemd, no coreutils beyond busybox. Use `/bin/sh -c` for shell builtins inside the guest.
|
- **Updating the rootfs** after changing envd or wrenn-init: `bash scripts/update-minimal-rootfs.sh`. Builds envd via `make build-envd` (Rust → static musl binary), then re-injects envd + wrenn-init + tini into all four system base images.
|
||||||
|
- Rootfs images are built from distro containers — no systemd (init is overridden to `wrenn-init`). Use `/bin/sh -c` for shell builtins inside the guest.
|
||||||
|
|
||||||
## Fixed Paths (on host machine)
|
## Fixed Paths (on host machine)
|
||||||
|
|
||||||
- Kernel: `/var/lib/wrenn/kernels/vmlinux`
|
- Kernel: `/var/lib/wrenn/kernels/vmlinux`
|
||||||
- Base rootfs images: `/var/lib/wrenn/images/{template}.ext4`
|
- Base rootfs images: `/var/lib/wrenn/images/teams/{base36(teamID)}/{base36(templateID)}/rootfs.ext4` (system templates use the platform team, base36 all-zeros)
|
||||||
- Sandbox clones: `/var/lib/wrenn/sandboxes/`
|
- Sandbox clones: `/var/lib/wrenn/sandboxes/`
|
||||||
- Firecracker: `/usr/local/bin/firecracker` (e2b's fork of firecracker)
|
- Cloud Hypervisor: `/usr/local/bin/cloud-hypervisor`
|
||||||
|
|
||||||
## Design Context
|
|
||||||
|
|
||||||
### Users
|
|
||||||
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
|
|
||||||
|
|
||||||
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
|
|
||||||
|
|
||||||
### Brand Personality
|
|
||||||
**Precise. Warm. Uncompromising.**
|
|
||||||
|
|
||||||
Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Firecracker microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity.
|
|
||||||
|
|
||||||
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
|
|
||||||
|
|
||||||
### Aesthetic Direction
|
|
||||||
**Dark-only (permanently), industrial-warm, data-forward.**
|
|
||||||
|
|
||||||
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
|
|
||||||
|
|
||||||
**Anti-references:**
|
|
||||||
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
|
|
||||||
- **AWS / GCP consoles**: avoid utility-first density without craft — functional but joyless, visually dated
|
|
||||||
|
|
||||||
**References that capture the right spirit:**
|
|
||||||
- The precision of a well-calibrated instrument
|
|
||||||
- Editorial typography from technical publications
|
|
||||||
- The quiet confidence of tools that don't need to explain themselves
|
|
||||||
|
|
||||||
### Type System
|
|
||||||
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
|
|
||||||
|
|
||||||
| Font | CSS Class | Role | When to use |
|
|
||||||
|------|-----------|------|-------------|
|
|
||||||
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
|
|
||||||
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
|
|
||||||
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
|
|
||||||
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
|
|
||||||
|
|
||||||
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
|
|
||||||
|
|
||||||
**Tracking overrides (app.css):**
|
|
||||||
- `.font-serif` — `letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
|
|
||||||
- `.font-mono` — `font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
|
|
||||||
|
|
||||||
**Type scale (root: 87.5% = 14px base):**
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--text-display` | 2.571rem (~36px) | Auth section headings |
|
|
||||||
| `--text-page` | 2rem (~28px) | Page h1 titles |
|
|
||||||
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
|
|
||||||
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
|
|
||||||
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
|
|
||||||
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
|
|
||||||
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
|
|
||||||
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
|
|
||||||
|
|
||||||
### Color System
|
|
||||||
|
|
||||||
All values are CSS custom properties in `frontend/src/app.css`.
|
|
||||||
|
|
||||||
**Backgrounds (6-step near-black-green scale):**
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
|
|
||||||
| `--color-bg-1` | `#0f1211` | Sidebar surface |
|
|
||||||
| `--color-bg-2` | `#141817` | Card backgrounds |
|
|
||||||
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
|
|
||||||
| `--color-bg-4` | `#212624` | Hover states, inputs |
|
|
||||||
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
|
|
||||||
|
|
||||||
**Text (5-level hierarchy):**
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
|
|
||||||
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
|
|
||||||
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
|
|
||||||
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
|
|
||||||
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
|
|
||||||
|
|
||||||
**Accent (sage green — use sparingly, must feel earned):**
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
|
|
||||||
| `--color-accent-mid` | `#89a785` | Hover accent text |
|
|
||||||
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
|
|
||||||
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
|
|
||||||
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
|
|
||||||
|
|
||||||
**Status semantics:**
|
|
||||||
| Token | Value | Use |
|
|
||||||
|---|---|---|
|
|
||||||
| `--color-amber` | `#d4a73c` | Warning, paused state |
|
|
||||||
| `--color-red` | `#cf8172` | Error, destructive actions |
|
|
||||||
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
|
|
||||||
|
|
||||||
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
|
|
||||||
|
|
||||||
### Component Patterns
|
|
||||||
|
|
||||||
**Buttons:**
|
|
||||||
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
|
|
||||||
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
|
|
||||||
- Danger: red text + subtle red background on hover
|
|
||||||
- All: `transition-all duration-150`
|
|
||||||
|
|
||||||
**Inputs:**
|
|
||||||
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
|
|
||||||
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
|
|
||||||
|
|
||||||
**Tables / data lists:**
|
|
||||||
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
|
|
||||||
- Status stripe: left border color matches sandbox state
|
|
||||||
|
|
||||||
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
|
|
||||||
|
|
||||||
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
|
|
||||||
|
|
||||||
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
|
|
||||||
|
|
||||||
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
1. **Precision over friendliness.** Every element earns its place. Wrenn doesn't need to tell you it's developer-friendly — that should be self-evident from the quality of the information architecture.
|
|
||||||
|
|
||||||
2. **Density with breathing room.** Data-forward doesn't mean cramped. Strategic whitespace creates calm hierarchy within dense contexts. Sections breathe; rows don't waste space.
|
|
||||||
|
|
||||||
3. **Industrial warmth.** The serif + mono + warm-black combination prevents sterility. This is a forge, not a gallery. The warmth is in the details, not the primary colors.
|
|
||||||
|
|
||||||
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
|
|
||||||
|
|
||||||
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
|
|
||||||
|
|
||||||
<!-- code-review-graph MCP tools -->
|
|
||||||
## MCP Tools: code-review-graph
|
|
||||||
|
|
||||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
|
||||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
|
||||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
|
||||||
you structural context (callers, dependents, test coverage) that file
|
|
||||||
scanning cannot.
|
|
||||||
|
|
||||||
### When to use graph tools FIRST
|
|
||||||
|
|
||||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
|
||||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
|
||||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
|
||||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
|
||||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
|
||||||
|
|
||||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
|
||||||
|
|
||||||
### Key Tools
|
|
||||||
|
|
||||||
| Tool | Use when |
|
|
||||||
|------|----------|
|
|
||||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
|
||||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
|
||||||
| `get_impact_radius` | Understanding blast radius of a change |
|
|
||||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
|
||||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
|
||||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
|
||||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
|
||||||
| `refactor_tool` | Planning renames, finding dead code |
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. The graph auto-updates on file changes (via hooks).
|
|
||||||
2. Use `detect_changes` for code review.
|
|
||||||
3. Use `get_affected_flows` to understand impact.
|
|
||||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
|
||||||
|
|||||||
54
Makefile
54
Makefile
@ -16,7 +16,7 @@ LDFLAGS := -s -w
|
|||||||
build: build-cp build-agent build-envd
|
build: build-cp build-agent build-envd
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
cd frontend && pnpm install --frozen-lockfile && pnpm build
|
cd frontend && bun install --frozen-lockfile && bun run build
|
||||||
|
|
||||||
build-cp:
|
build-cp:
|
||||||
go build -v -ldflags="$(LDFLAGS) -X main.version=$(VERSION_CP) -X main.commit=$(COMMIT)" -o $(BIN_DIR)/wrenn-cp ./cmd/control-plane
|
go build -v -ldflags="$(LDFLAGS) -X main.version=$(VERSION_CP) -X main.commit=$(COMMIT)" -o $(BIN_DIR)/wrenn-cp ./cmd/control-plane
|
||||||
@ -27,8 +27,12 @@ build-agent:
|
|||||||
build-envd:
|
build-envd:
|
||||||
cd envd-rs && ENVD_COMMIT=$(COMMIT) cargo build --release --target x86_64-unknown-linux-musl
|
cd envd-rs && ENVD_COMMIT=$(COMMIT) cargo build --release --target x86_64-unknown-linux-musl
|
||||||
@cp envd-rs/target/x86_64-unknown-linux-musl/release/envd $(BIN_DIR)/envd
|
@cp envd-rs/target/x86_64-unknown-linux-musl/release/envd $(BIN_DIR)/envd
|
||||||
@file $(BIN_DIR)/envd | grep -q "static-pie linked" || \
|
@readelf -h $(BIN_DIR)/envd | grep -q 'Type:.*DYN' && \
|
||||||
(echo "ERROR: envd is not statically linked!" && exit 1)
|
readelf -d $(BIN_DIR)/envd | grep -q 'FLAGS_1.*PIE' && \
|
||||||
|
! readelf -d $(BIN_DIR)/envd | grep -q '(NEEDED)' && \
|
||||||
|
{ ! readelf -lW $(BIN_DIR)/envd | grep -q 'Requesting program interpreter' || \
|
||||||
|
readelf -lW $(BIN_DIR)/envd | grep -Fq '[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]'; } || \
|
||||||
|
(echo "ERROR: envd must be PIE, have no DT_NEEDED shared libs, and either have no interpreter or use /lib/ld-musl-x86_64.so.1" && exit 1)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Development
|
# Development
|
||||||
@ -55,10 +59,10 @@ dev-agent:
|
|||||||
sudo go run ./cmd/host-agent
|
sudo go run ./cmd/host-agent
|
||||||
|
|
||||||
dev-frontend:
|
dev-frontend:
|
||||||
cd frontend && pnpm dev --port 5173 --host 0.0.0.0
|
cd frontend && bun run dev --port 5173 --host 0.0.0.0
|
||||||
|
|
||||||
dev-envd:
|
dev-envd:
|
||||||
cd envd-rs && cargo run -- --isnotfc --port 49983
|
cd envd-rs && cargo run -- --port 49983
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Database (goose)
|
# Database (goose)
|
||||||
@ -102,6 +106,7 @@ sqlc:
|
|||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
gofmt -w .
|
gofmt -w .
|
||||||
|
cargo fmt --manifest-path envd-rs/Cargo.toml
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
@ -110,7 +115,8 @@ vet:
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -race -v ./internal/...
|
go test -race -v ./internal/... ./pkg/...
|
||||||
|
cd envd-rs && cargo test
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
go test -race -v -tags=integration ./tests/integration/...
|
go test -race -v -tags=integration ./tests/integration/...
|
||||||
@ -126,32 +132,24 @@ check: fmt vet lint test
|
|||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Rootfs Images
|
# Rootfs Images
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
.PHONY: images image-minimal image-python image-node
|
.PHONY: images rootfs-ubuntu rootfs-alpine rootfs-arch rootfs-fedora
|
||||||
|
|
||||||
images: build-envd image-minimal image-python image-node
|
# Build all four system base rootfs images (ubuntu/alpine/arch/fedora). Each
|
||||||
|
# spawns a distro container, installs the required packages + wrenn-user, then
|
||||||
|
# exports to images/teams/<platform>/<id>/rootfs.ext4. Requires docker + sudo.
|
||||||
|
images: rootfs-ubuntu rootfs-alpine rootfs-arch rootfs-fedora
|
||||||
|
|
||||||
image-minimal:
|
rootfs-ubuntu:
|
||||||
sudo bash images/templates/minimal/build.sh
|
bash images/build-ubuntu.sh
|
||||||
|
|
||||||
image-python:
|
rootfs-alpine:
|
||||||
sudo bash images/templates/python312/build.sh
|
bash images/build-alpine.sh
|
||||||
|
|
||||||
image-node:
|
rootfs-arch:
|
||||||
sudo bash images/templates/node20/build.sh
|
bash images/build-arch.sh
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
rootfs-fedora:
|
||||||
# Deployment
|
bash images/build-fedora.sh
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
.PHONY: setup-host install
|
|
||||||
|
|
||||||
setup-host:
|
|
||||||
sudo bash scripts/setup-host.sh
|
|
||||||
|
|
||||||
install: build
|
|
||||||
sudo cp $(BIN_DIR)/wrenn-cp /usr/local/bin/
|
|
||||||
sudo cp $(BIN_DIR)/wrenn-agent /usr/local/bin/
|
|
||||||
sudo cp deploy/systemd/*.service /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Clean
|
# Clean
|
||||||
@ -176,7 +174,7 @@ help:
|
|||||||
@echo " make dev-cp Control plane (hot reload if air installed)"
|
@echo " make dev-cp Control plane (hot reload if air installed)"
|
||||||
@echo " make dev-frontend Vite dev server with HMR (port 5173)"
|
@echo " make dev-frontend Vite dev server with HMR (port 5173)"
|
||||||
@echo " make dev-agent Host agent (sudo required)"
|
@echo " make dev-agent Host agent (sudo required)"
|
||||||
@echo " make dev-envd envd in debug mode (--isnotfc, port 49983)"
|
@echo " make dev-envd envd in debug mode (port 49983)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " make build Build all binaries → builds/"
|
@echo " make build Build all binaries → builds/"
|
||||||
@echo " make build-frontend Build SvelteKit dashboard → frontend/build/"
|
@echo " make build-frontend Build SvelteKit dashboard → frontend/build/"
|
||||||
|
|||||||
132
PRODUCT.md
Normal file
132
PRODUCT.md
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
## Design Context
|
||||||
|
|
||||||
|
### Users
|
||||||
|
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Cloud Hypervisor microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
|
||||||
|
|
||||||
|
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
|
||||||
|
|
||||||
|
### Brand Personality
|
||||||
|
**Precise. Warm. Uncompromising.**
|
||||||
|
|
||||||
|
Wrenn is an engineer's favorite tool — built with visible care, not assembled from defaults. It runs real infrastructure (Cloud Hypervisor microVMs), so the UI should reflect that seriousness without becoming cold or corporate. The warmth comes from the typography and color palette; the precision comes from hierarchy, density, and data fidelity.
|
||||||
|
|
||||||
|
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
|
||||||
|
|
||||||
|
### Aesthetic Direction
|
||||||
|
**Dark-only (permanently), industrial-warm, data-forward.**
|
||||||
|
|
||||||
|
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
|
||||||
|
|
||||||
|
**Anti-references:**
|
||||||
|
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
|
||||||
|
- **AWS / GCP consoles**: avoid utility-first density without craft — functional but joyless, visually dated
|
||||||
|
|
||||||
|
**References that capture the right spirit:**
|
||||||
|
- The precision of a well-calibrated instrument
|
||||||
|
- Editorial typography from technical publications
|
||||||
|
- The quiet confidence of tools that don't need to explain themselves
|
||||||
|
|
||||||
|
### Type System
|
||||||
|
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
|
||||||
|
|
||||||
|
| Font | CSS Class | Role | When to use |
|
||||||
|
|------|-----------|------|-------------|
|
||||||
|
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
|
||||||
|
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
|
||||||
|
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
|
||||||
|
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
|
||||||
|
|
||||||
|
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
|
||||||
|
|
||||||
|
**Tracking overrides (app.css):**
|
||||||
|
- `.font-serif` — `letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
|
||||||
|
- `.font-mono` — `font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
|
||||||
|
|
||||||
|
**Type scale (root: 87.5% = 14px base):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--text-display` | 2.571rem (~36px) | Auth section headings |
|
||||||
|
| `--text-page` | 2rem (~28px) | Page h1 titles |
|
||||||
|
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
|
||||||
|
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
|
||||||
|
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
|
||||||
|
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
|
||||||
|
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
|
||||||
|
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
|
||||||
|
|
||||||
|
### Color System
|
||||||
|
|
||||||
|
All values are CSS custom properties in `frontend/src/app.css`.
|
||||||
|
|
||||||
|
**Backgrounds (6-step near-black-green scale):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
|
||||||
|
| `--color-bg-1` | `#0f1211` | Sidebar surface |
|
||||||
|
| `--color-bg-2` | `#141817` | Card backgrounds |
|
||||||
|
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
|
||||||
|
| `--color-bg-4` | `#212624` | Hover states, inputs |
|
||||||
|
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
|
||||||
|
|
||||||
|
**Text (5-level hierarchy):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
|
||||||
|
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
|
||||||
|
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
|
||||||
|
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
|
||||||
|
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
|
||||||
|
|
||||||
|
**Accent (sage green — use sparingly, must feel earned):**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
|
||||||
|
| `--color-accent-mid` | `#89a785` | Hover accent text |
|
||||||
|
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
|
||||||
|
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
|
||||||
|
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
|
||||||
|
|
||||||
|
**Status semantics:**
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-amber` | `#d4a73c` | Warning, paused state |
|
||||||
|
| `--color-red` | `#cf8172` | Error, destructive actions |
|
||||||
|
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
|
||||||
|
|
||||||
|
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
**Buttons:**
|
||||||
|
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
|
||||||
|
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
|
||||||
|
- Danger: red text + subtle red background on hover
|
||||||
|
- All: `transition-all duration-150`
|
||||||
|
|
||||||
|
**Inputs:**
|
||||||
|
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
|
||||||
|
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
|
||||||
|
|
||||||
|
**Tables / data lists:**
|
||||||
|
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
|
||||||
|
- Status stripe: left border color matches sandbox state
|
||||||
|
|
||||||
|
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
|
||||||
|
|
||||||
|
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
|
||||||
|
|
||||||
|
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
|
||||||
|
|
||||||
|
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
1. **Precision over friendliness.** Every element earns its place. Wrenn doesn't need to tell you it's developer-friendly — that should be self-evident from the quality of the information architecture.
|
||||||
|
|
||||||
|
2. **Density with breathing room.** Data-forward doesn't mean cramped. Strategic whitespace creates calm hierarchy within dense contexts. Sections breathe; rows don't waste space.
|
||||||
|
|
||||||
|
3. **Industrial warmth.** The serif + mono + warm-black combination prevents sterility. This is a forge, not a gallery. The warmth is in the details, not the primary colors.
|
||||||
|
|
||||||
|
4. **Legible at speed.** Users scan dashboards in seconds. Strong typographic contrast (serif h1, mono IDs, sans body), consistent patterns, and predictable placement let users orientate instantly without reading everything.
|
||||||
|
|
||||||
|
5. **Craft signals trust.** For infrastructure that runs production code, the quality of the UI is a proxy for the quality of the product. Pixel-level decisions matter. Polish is not decoration — it's a trust signal.
|
||||||
229
README.md
229
README.md
@ -1,15 +1,17 @@
|
|||||||
# Wrenn
|
# Wrenn
|
||||||
|
|
||||||
Secure infrastructure for AI
|
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.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Linux host with `/dev/kvm` access (bare metal or nested virt)
|
- Linux host with `/dev/kvm` access (bare metal or nested virt)
|
||||||
- Firecracker binary at `/usr/local/bin/firecracker`
|
- Cloud Hypervisor binary at `/usr/local/bin/cloud-hypervisor`
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Go 1.25+
|
- Go 1.25+
|
||||||
- Rust 1.88+ with `x86_64-unknown-linux-musl` target (`rustup target add x86_64-unknown-linux-musl`)
|
- Rust 1.88+ with `x86_64-unknown-linux-musl` target (`rustup target add x86_64-unknown-linux-musl`)
|
||||||
- pnpm (for frontend)
|
- Bun (for frontend)
|
||||||
- Docker (for dev infra and rootfs builds)
|
- Docker (for dev infra and rootfs builds)
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@ -22,7 +24,7 @@ Produces three binaries: `wrenn-cp` (control plane), `wrenn-agent` (host agent),
|
|||||||
|
|
||||||
## Host setup
|
## Host setup
|
||||||
|
|
||||||
The host agent needs a kernel, a minimal rootfs image, and working directories on the host machine.
|
The host agent needs a kernel, the system base rootfs images, and working directories on the host machine.
|
||||||
|
|
||||||
### Directory structure
|
### Directory structure
|
||||||
|
|
||||||
@ -31,59 +33,74 @@ The host agent needs a kernel, a minimal rootfs image, and working directories o
|
|||||||
├── kernels/
|
├── kernels/
|
||||||
│ └── vmlinux # uncompressed Linux kernel (not bzImage)
|
│ └── vmlinux # uncompressed Linux kernel (not bzImage)
|
||||||
├── images/
|
├── images/
|
||||||
│ └── minimal/
|
│ └── teams/
|
||||||
│ └── rootfs.ext4 # base rootfs (all other templates snapshot from this)
|
│ └── 0000000000000000000000000/ # platform team (base36 all-zeros)
|
||||||
|
│ ├── 0000000000000000000000000/rootfs.ext4 # minimal-ubuntu (id 0)
|
||||||
|
│ ├── 0000000000000000000000001/rootfs.ext4 # minimal-alpine (id 1)
|
||||||
|
│ ├── 0000000000000000000000002/rootfs.ext4 # minimal-arch (id 2)
|
||||||
|
│ └── 0000000000000000000000003/rootfs.ext4 # minimal-fedora (id 3)
|
||||||
├── sandboxes/ # per-sandbox CoW files (created at runtime)
|
├── sandboxes/ # per-sandbox CoW files (created at runtime)
|
||||||
└── snapshots/ # pause/hibernate snapshot files (created at runtime)
|
└── snapshots/ # pause/hibernate snapshot files (created at runtime)
|
||||||
```
|
```
|
||||||
|
|
||||||
Create the directories:
|
Create the base directories (the per-template image dirs are created by the build scripts):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /var/lib/wrenn/{kernels,images/minimal,sandboxes,snapshots}
|
sudo mkdir -p /var/lib/wrenn/{kernels,images,sandboxes,snapshots}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kernel
|
### Kernel
|
||||||
|
|
||||||
Place an uncompressed `vmlinux` kernel at `/var/lib/wrenn/kernels/vmlinux`. Versioned kernels (`vmlinux-{semver}`) are also supported — the agent picks the latest by semver.
|
Place an uncompressed `vmlinux` kernel at `/var/lib/wrenn/kernels/vmlinux`. Versioned kernels (`vmlinux-{semver}`) are also supported — the agent picks the latest by semver.
|
||||||
|
|
||||||
### Minimal rootfs
|
### System base rootfs images
|
||||||
|
|
||||||
The minimal rootfs is the base image that all other templates (Python, Node, etc.) are built on top of via device-mapper snapshots. It must contain:
|
There are four built-in **system base templates** — one per distro — that all other
|
||||||
|
templates snapshot from via device-mapper. They are platform-owned (visible to every
|
||||||
|
team) and protected from deletion (reserved template IDs 0–1024):
|
||||||
|
|
||||||
|
| Template | Distro | ID |
|
||||||
|
|----------|--------|----|
|
||||||
|
| `minimal-ubuntu` | `ubuntu:26.04` | 0 |
|
||||||
|
| `minimal-alpine` | `alpine:3.22` | 1 |
|
||||||
|
| `minimal-arch` | `archlinux:base` | 2 |
|
||||||
|
| `minimal-fedora` | `fedora:45` | 3 |
|
||||||
|
|
||||||
|
`minimal-ubuntu` is the default template for new sandboxes and builds. The same
|
||||||
|
statically-linked `envd` + `tini` run on all four regardless of the distro's libc
|
||||||
|
(glibc on Ubuntu/Arch/Fedora, musl on Alpine).
|
||||||
|
|
||||||
|
Each image contains these packages plus a `wrenn-user` account with passwordless `sudo`:
|
||||||
|
|
||||||
| Package | Why |
|
| Package | Why |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| `socat` | Bidirectional relay for port forwarding |
|
| `socat` | Bidirectional relay for port forwarding |
|
||||||
| `chrony` | Time sync from KVM PTP clock (`/dev/ptp0`) |
|
| `chrony` | Time sync from KVM PTP clock (`/dev/ptp0`) |
|
||||||
| `tini` | PID 1 zombie reaper (injected by build script, not apt) |
|
| `iproute2` (`iproute` on Fedora) | `ip` for guest network setup in `wrenn-init` |
|
||||||
|
| `tini` | PID 1 zombie reaper |
|
||||||
| `sudo` | User privilege management inside the guest |
|
| `sudo` | User privilege management inside the guest |
|
||||||
| `wget` | HTTP fetching |
|
| `wget` | HTTP fetching |
|
||||||
| `curl` | HTTP client |
|
| `curl` | HTTP client |
|
||||||
| `ca-certificates` | TLS certificate verification |
|
| `ca-certificates` | TLS certificate verification |
|
||||||
|
| `git` | Version control |
|
||||||
|
|
||||||
**To build a rootfs from a Docker container:**
|
**To build all four images** (each spawns a distro container, installs the packages +
|
||||||
|
`wrenn-user`, builds `envd`, injects `wrenn-init` + `tini`, and exports to the
|
||||||
|
team-scoped path). Requires Docker + sudo:
|
||||||
|
|
||||||
1. Create and configure a container with the required packages:
|
```bash
|
||||||
```bash
|
make images
|
||||||
docker run -it --name wrenn-minimal debian:bookworm bash
|
```
|
||||||
# Inside the container:
|
|
||||||
apt update && apt install -y socat chrony sudo wget curl ca-certificates
|
|
||||||
exit
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Export to a rootfs image (builds envd, injects wrenn-init + tini, shrinks to minimum size):
|
Or build a single distro: `make rootfs-ubuntu` / `rootfs-alpine` / `rootfs-arch` / `rootfs-fedora`.
|
||||||
```bash
|
|
||||||
sudo bash scripts/rootfs-from-container.sh wrenn-minimal minimal
|
|
||||||
```
|
|
||||||
|
|
||||||
**To update an existing rootfs** after changing envd or `wrenn-init.sh`:
|
**To update the images** after changing `envd` or `wrenn-init.sh` (rebuilds `envd` once,
|
||||||
|
then re-injects `envd` + `wrenn-init` + `tini` into every system base image):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/update-minimal-rootfs.sh
|
bash scripts/update-minimal-rootfs.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This rebuilds envd via `make build-envd` and copies the fresh binaries into the mounted rootfs image.
|
|
||||||
|
|
||||||
### IP forwarding
|
### IP forwarding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -120,14 +137,7 @@ make check # fmt + vet + lint + test
|
|||||||
|
|
||||||
Hosts must be registered with the control plane before they can serve sandboxes.
|
Hosts must be registered with the control plane before they can serve sandboxes.
|
||||||
|
|
||||||
1. **Create a host record** (via API or dashboard):
|
1. **Create a host record** in the dashboard (admin only — host management is not exposed over the SDK / API keys). Sign in at `/login`, open the admin hosts page, and click **Add host**. The dashboard returns a `registration_token` valid for 1 hour.
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8000/v1/hosts \
|
|
||||||
-H "Authorization: Bearer $JWT_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"type": "regular"}'
|
|
||||||
```
|
|
||||||
This returns a `registration_token` (valid for 1 hour).
|
|
||||||
|
|
||||||
2. **Start the host agent** with the registration token and its externally-reachable address:
|
2. **Start the host agent** with the registration token and its externally-reachable address:
|
||||||
```bash
|
```bash
|
||||||
@ -143,13 +153,152 @@ Hosts must be registered with the control plane before they can serve sandboxes.
|
|||||||
sudo ./builds/wrenn-agent --address <host-ip>:50051
|
sudo ./builds/wrenn-agent --address <host-ip>:50051
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **If registration fails** (e.g., network error after token was consumed), regenerate a token:
|
4. **If registration fails** (e.g., network error after token was consumed), regenerate a token from the dashboard host detail page, then restart the agent with the new token.
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8000/v1/hosts/$HOST_ID/token \
|
|
||||||
-H "Authorization: Bearer $JWT_TOKEN"
|
|
||||||
```
|
|
||||||
Then restart the agent with the new token.
|
|
||||||
|
|
||||||
The agent sends heartbeats to the control plane every 30 seconds.
|
The agent sends heartbeats to the control plane every 30 seconds.
|
||||||
|
|
||||||
|
## Notification channels
|
||||||
|
|
||||||
|
Teams can subscribe to lifecycle events via webhook, Discord, Slack, Teams, Google Chat, Telegram, or Matrix. All providers consume the same event stream (durable Redis stream `wrenn:events`, consumer group `wrenn-channels-v1`, at-least-once delivery with two retries at 10s / 30s).
|
||||||
|
|
||||||
|
### Subscribable event types
|
||||||
|
|
||||||
|
| Event | Emitted on | Has outcome |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| `capsule.create` | First boot of a sandbox | yes |
|
||||||
|
| `capsule.pause` | Manual pause, TTL auto-pause, or reconciler-detected pause | yes |
|
||||||
|
| `capsule.resume` | Unpause (any subsequent boot after `capsule.create`) | yes |
|
||||||
|
| `capsule.destroy` | Stop / destroy, including system cleanup-on-error | yes |
|
||||||
|
| `template.snapshot.create` | Snapshot taken from a running sandbox | yes |
|
||||||
|
| `template.snapshot.delete` | Snapshot deletion (including cleanup-on-error) | yes |
|
||||||
|
| `host.up` | Host agent comes online | no |
|
||||||
|
| `host.down` | Host agent crashes or misses heartbeats | no |
|
||||||
|
|
||||||
|
Subscribing to an event type delivers **both success and failure**. The `outcome` field on the payload (`success` or `error`) distinguishes them. `error` events carry an `error` string with the failure reason.
|
||||||
|
|
||||||
|
The transient `capsule.state.changed` event (intermediate transitions like `starting`, `pausing`, `resuming`) is **not** subscribable — it is delivered to the dashboard via SSE only and never written to the durable stream.
|
||||||
|
|
||||||
|
### Event payload
|
||||||
|
|
||||||
|
All channels receive the same canonical JSON shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "capsule.pause",
|
||||||
|
"outcome": "success",
|
||||||
|
"timestamp": "2026-05-19T14:23:01Z",
|
||||||
|
"team_id": "tm_...",
|
||||||
|
"actor": {
|
||||||
|
"type": "user",
|
||||||
|
"id": "usr_...",
|
||||||
|
"name": "alice@example.com"
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"id": "sb_a1b2c3d4",
|
||||||
|
"type": "sandbox"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"reason": "ttl_expired"
|
||||||
|
},
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `event` | string | Event type (see table above) |
|
||||||
|
| `outcome` | `"success"` \| `"error"` \| `""` | Omitted for host.up/host.down |
|
||||||
|
| `timestamp` | RFC3339 UTC | When the event was published |
|
||||||
|
| `team_id` | string | Owning team |
|
||||||
|
| `actor.type` | `"user"` \| `"api_key"` \| `"system"` | System = TTL reaper, reconciler, cleanup-on-error |
|
||||||
|
| `actor.id` | string | User ID, API key ID, or empty for system |
|
||||||
|
| `actor.name` | string | Display name (email for user, label for api_key) |
|
||||||
|
| `resource.id` | string | Sandbox ID, snapshot ID, or host ID |
|
||||||
|
| `resource.type` | `"sandbox"` \| `"snapshot"` \| `"host"` | |
|
||||||
|
| `metadata` | object\<string,string\> | Event-specific context (e.g., `reason`, `from`/`to`, `inferred`) |
|
||||||
|
| `error` | string | Failure reason when `outcome == "error"` |
|
||||||
|
|
||||||
|
`metadata` keys you may observe:
|
||||||
|
|
||||||
|
- `reason` — `ttl_expired` (auto-pause), `orphaned` (reconciler cleanup), `cleanup_after_create_error`, `restored_after_host_recovery`, `host_state_sync`, `transient_timeout`, `transient_timeout_inferred`
|
||||||
|
- `inferred` — `"true"` when the reconciler derived the event from host state, not a direct host callback
|
||||||
|
|
||||||
|
### Webhook delivery
|
||||||
|
|
||||||
|
Webhook channels receive a raw `POST` with the JSON payload as the body.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| `Content-Type` | `application/json` |
|
||||||
|
| `X-Wrenn-Delivery` | UUID, unique per delivery attempt |
|
||||||
|
| `X-Wrenn-Timestamp` | RFC3339 UTC, used for signature verification |
|
||||||
|
| `X-WRENN-SIGNATURE` | `sha256=<hex>` HMAC over `<timestamp>.<body>` using the channel's signing secret |
|
||||||
|
|
||||||
|
The signing secret is shown **once** at channel creation. Verify signatures by computing `HMAC-SHA256(secret, timestamp + "." + body)` and comparing to the header (constant-time compare). Reject deliveries where `X-Wrenn-Timestamp` is outside your acceptable clock skew window. Redirects are not followed.
|
||||||
|
|
||||||
|
Any non-2xx response triggers retry (10s, then 30s). After three total failures the event is dropped (logged on the control plane).
|
||||||
|
|
||||||
|
### Other providers
|
||||||
|
|
||||||
|
Discord, Slack, Teams, Google Chat, Telegram, and Matrix receive a formatted text message — the same fields, rendered as human-readable text — not the JSON payload. Use webhook if you need the structured event.
|
||||||
|
|
||||||
|
## Extending the control plane
|
||||||
|
|
||||||
|
The OSS control plane is designed to be embedded by a private cloud distribution without forking. Import this module, implement the `Extension` interface from `pkg/cpextension`, and pass it to `cpserver.Run`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/cpextension"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/cpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cpserver.Run(
|
||||||
|
cpserver.WithVersion("cloud-1.0.0"),
|
||||||
|
cpserver.WithExtensions(&myExtension{}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every extension implements two methods:
|
||||||
|
|
||||||
|
```go
|
||||||
|
RegisterRoutes(r chi.Router, sctx cpextension.ServerContext)
|
||||||
|
BackgroundWorkers(sctx cpextension.ServerContext) []func(context.Context)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerContext` exposes the initialized OSS services so extensions never re-implement them: `Queries`, `PgPool`, `Redis`, `HostPool`, `Scheduler`, `CA`, `Audit`, `Mailer`, `OAuthRegistry`, `Channels`, `ChannelPub`, `JWTSecret`, `Sessions`, `Config`.
|
||||||
|
|
||||||
|
### Optional hook interfaces
|
||||||
|
|
||||||
|
An extension can also implement any subset of these — the OSS server type-asserts at startup:
|
||||||
|
|
||||||
|
| Interface | When it fires | Failure semantics |
|
||||||
|
|---|---|---|
|
||||||
|
| `MiddlewareProvider` | Wraps every OSS route before registration | n/a |
|
||||||
|
| `AuthHook.OnSignup(ctx, userID, teamID, email)` | After team provisioning on email-activate or OAuth-new-signup | Error aborts signup with 500 `signup_hook_failed` (billing customer creation must succeed) |
|
||||||
|
| `AuthHook.OnLogin(ctx, userID)` | After a successful login or OAuth callback | Error logged, login still succeeds |
|
||||||
|
| `AuthHook.OnAccountSoftDelete(ctx, userID)` | After `DELETE /v1/me` commits | Error logged, request still succeeds |
|
||||||
|
| `AuthHook.OnAccountHardDelete(ctx, userID)` | After the 15-day cleanup goroutine purges a soft-deleted account | Error logged, cleanup continues |
|
||||||
|
| `SandboxEventHook.OnSandboxEvent(ctx, ev)` | Capsule create/pause/resume/destroy success, from the Redis stream consumer | Error leaves the message un-acked — hooks **must** be idempotent |
|
||||||
|
| `LimitsProvider.EffectiveLimits(ctx, teamID)` | `POST /v1/capsules` consults before scheduling | Returns 402 (`concurrent_sandbox_limit` / `vcpu_limit` / `memory_limit`) when over |
|
||||||
|
| `UsageProvider.CurrentUsage(ctx, teamID)` | Feeds `LimitsProvider` checks; falls back to OSS DB-backed default | Error → 402 `usage_unavailable` |
|
||||||
|
|
||||||
|
### Auth middleware helpers
|
||||||
|
|
||||||
|
For extensions that gate their own routes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.With(cpextension.RequireSession(sctx)).Get("/billing", handler)
|
||||||
|
r.With(cpextension.RequireSessionOrAPIKey(sctx)).Get("/usage", handler)
|
||||||
|
r.With(cpextension.RequireSession(sctx), cpextension.RequireAdmin(sctx)).Get("/admin/exports", handler)
|
||||||
|
|
||||||
|
// Issue a session from a custom flow (e.g. invite-accept):
|
||||||
|
sess, err := cpextension.IssueSession(w, r, sctx, userID, teamID)
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookie/header names are exported as `cpextension.SessionCookieName`, `CSRFCookieName`, `CSRFHeaderName`.
|
||||||
|
|
||||||
See `CLAUDE.md` for full architecture documentation.
|
See `CLAUDE.md` for full architecture documentation.
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
0.1.2
|
0.5.0
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
0.1.5
|
0.5.0
|
||||||
|
|||||||
@ -1,31 +1,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/devicemapper"
|
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/hostagent"
|
"git.omukk.dev/wrenn/wrenn/internal/hostagent"
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
"git.omukk.dev/wrenn/wrenn/internal/units"
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||||
"git.omukk.dev/wrenn/wrenn/pkg/logging"
|
"git.omukk.dev/wrenn/wrenn/pkg/logging"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -47,25 +43,26 @@ func main() {
|
|||||||
cleanupLog := logging.Setup(filepath.Join(rootDir, "logs"), "host-agent")
|
cleanupLog := logging.Setup(filepath.Join(rootDir, "logs"), "host-agent")
|
||||||
defer cleanupLog()
|
defer cleanupLog()
|
||||||
|
|
||||||
if err := checkPrivileges(); err != nil {
|
if err := sandbox.CheckPrivileges(); err != nil {
|
||||||
slog.Error("insufficient privileges", "error", err)
|
slog.Error("insufficient privileges", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable IP forwarding (required for NAT). The write may fail if running
|
// Enable IP forwarding (required for NAT). The write may fail if running
|
||||||
// as non-root without DAC_OVERRIDE on this path — that's OK if the systemd
|
// as non-root without DAC_OVERRIDE on this path — that's OK if the systemd
|
||||||
// unit's ExecStartPre already set it. We verify the value regardless.
|
// unit's ExecStartPre already set it.
|
||||||
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
|
if err := sandbox.EnsureIPForward(); err != nil {
|
||||||
slog.Warn("failed to enable ip_forward (may have been set by systemd unit)", "error", err)
|
|
||||||
}
|
|
||||||
if b, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err != nil || strings.TrimSpace(string(b)) != "1" {
|
|
||||||
slog.Error("ip_forward is not enabled — sandbox networking will be broken", "error", err)
|
slog.Error("ip_forward is not enabled — sandbox networking will be broken", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stale resources from a previous crash.
|
// Install the egress guard: block capsule traffic to private ranges from
|
||||||
devicemapper.CleanupStaleDevices()
|
// leaving the public NIC (which would leak private-destined packets onto the
|
||||||
network.CleanupStaleNamespaces()
|
// upstream network and read as a port scan) and deny cross-capsule reach.
|
||||||
|
if err := network.EnsureEgressGuard(); err != nil {
|
||||||
|
slog.Error("failed to install network egress guard", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051")
|
listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051")
|
||||||
cpURL := os.Getenv("WRENN_CP_URL")
|
cpURL := os.Getenv("WRENN_CP_URL")
|
||||||
@ -80,60 +77,12 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse default rootfs size from env (e.g. "5G", "2Gi", "1000M").
|
|
||||||
defaultRootfsSizeMB := sandbox.DefaultDiskSizeMB
|
|
||||||
if sizeStr := os.Getenv("WRENN_DEFAULT_ROOTFS_SIZE"); sizeStr != "" {
|
|
||||||
parsed, err := sandbox.ParseSizeToMB(sizeStr)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("invalid WRENN_DEFAULT_ROOTFS_SIZE", "value", sizeStr, "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defaultRootfsSizeMB = parsed
|
|
||||||
slog.Info("using custom rootfs size", "size_mb", defaultRootfsSizeMB)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand base images to the configured disk size (sparse, no extra physical
|
|
||||||
// disk). This ensures dm-snapshot sandboxes see the full size from boot.
|
|
||||||
if err := sandbox.EnsureImageSizes(rootDir, defaultRootfsSizeMB); err != nil {
|
|
||||||
slog.Error("failed to expand base images", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve latest kernel version.
|
|
||||||
kernelPath, kernelVersion, err := layout.LatestKernel(rootDir)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to find kernel", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
slog.Info("resolved kernel", "version", kernelVersion, "path", kernelPath)
|
|
||||||
|
|
||||||
// Detect firecracker version.
|
|
||||||
fcBin := envOrDefault("WRENN_FIRECRACKER_BIN", "/usr/local/bin/firecracker")
|
|
||||||
fcVersion, err := sandbox.DetectFirecrackerVersion(fcBin)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to detect firecracker version", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
slog.Info("resolved firecracker", "version", fcVersion, "path", fcBin)
|
|
||||||
|
|
||||||
cfg := sandbox.Config{
|
|
||||||
WrennDir: rootDir,
|
|
||||||
DefaultRootfsSizeMB: defaultRootfsSizeMB,
|
|
||||||
KernelPath: kernelPath,
|
|
||||||
KernelVersion: kernelVersion,
|
|
||||||
FirecrackerBin: fcBin,
|
|
||||||
FirecrackerVersion: fcVersion,
|
|
||||||
AgentVersion: version,
|
|
||||||
}
|
|
||||||
|
|
||||||
mgr := sandbox.New(cfg)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
mgr.StartTTLReaper(ctx)
|
// Register with the control plane before touching rootfs images. If the
|
||||||
|
// agent can't reach the CP there's no point inflating images (and crashing
|
||||||
// Register with the control plane and start heartbeating.
|
// afterward would leave them in the expanded state).
|
||||||
creds, err := hostagent.Register(ctx, hostagent.RegistrationConfig{
|
creds, err := hostagent.Register(ctx, hostagent.RegistrationConfig{
|
||||||
CPURL: cpURL,
|
CPURL: cpURL,
|
||||||
RegistrationToken: *registrationToken,
|
RegistrationToken: *registrationToken,
|
||||||
@ -147,6 +96,70 @@ func main() {
|
|||||||
|
|
||||||
slog.Info("host registered", "host_id", creds.HostID)
|
slog.Info("host registered", "host_id", creds.HostID)
|
||||||
|
|
||||||
|
// Parse default rootfs size from env (e.g. "5G", "2Gi", "1000M").
|
||||||
|
defaultRootfsSizeMB := sandbox.DefaultDiskSizeMB
|
||||||
|
if sizeStr := os.Getenv("WRENN_DEFAULT_ROOTFS_SIZE"); sizeStr != "" {
|
||||||
|
parsed, err := units.ParseSizeToMB(sizeStr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("invalid WRENN_DEFAULT_ROOTFS_SIZE", "value", sizeStr, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defaultRootfsSizeMB = parsed
|
||||||
|
slog.Info("using custom rootfs size", "size_mb", defaultRootfsSizeMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the startup ritual: stale-resource cleanup, base image expansion,
|
||||||
|
// kernel resolution, cloud-hypervisor detection, orphan pause-dir GC.
|
||||||
|
env, err := sandbox.Setup(sandbox.SetupOptions{
|
||||||
|
WrennDir: rootDir,
|
||||||
|
CHBin: envOrDefault("WRENN_CH_BIN", sandbox.DefaultCHBin),
|
||||||
|
DefaultRootfsSizeMB: defaultRootfsSizeMB,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("host setup failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("resolved kernel", "version", env.KernelVersion, "path", env.KernelPath)
|
||||||
|
slog.Info("resolved cloud-hypervisor", "version", env.CHVersion, "path", env.CHBin)
|
||||||
|
|
||||||
|
cfg := sandbox.Config{
|
||||||
|
WrennDir: rootDir,
|
||||||
|
DefaultRootfsSizeMB: defaultRootfsSizeMB,
|
||||||
|
KernelPath: env.KernelPath,
|
||||||
|
KernelVersion: env.KernelVersion,
|
||||||
|
VMMBin: env.CHBin,
|
||||||
|
VMMVersion: env.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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := sandbox.New(cfg)
|
||||||
|
|
||||||
|
// Set up lifecycle event callback sender so autonomous events
|
||||||
|
// (auto-pause, auto-destroy) are pushed to the CP proactively.
|
||||||
|
cb := hostagent.NewCallbackSender(cpURL, credsFile, creds.HostID)
|
||||||
|
mgr.SetEventSender(hostagent.NewEventSender(cb))
|
||||||
|
|
||||||
|
// Sweep stale running-state files first (Setup's stale cleanup killed
|
||||||
|
// every CH process, so nothing can actually be re-attached), then restore
|
||||||
|
// paused sandboxes from disk so ListSandboxes reports them as 'paused'
|
||||||
|
// immediately. Without the latter, the CP's HostMonitor would mark every
|
||||||
|
// paused-on-disk sandbox 'stopped' via the missing→stopped reconcile path
|
||||||
|
// on the first ListSandboxes after agent restart. Must run before the
|
||||||
|
// HTTP server starts serving (an early Create would race the slot
|
||||||
|
// reservation).
|
||||||
|
mgr.RestoreRunningSandboxes()
|
||||||
|
mgr.RestorePausedSandboxes()
|
||||||
|
|
||||||
|
mgr.StartTTLReaper(ctx)
|
||||||
|
mgr.StartActivitySampler(ctx)
|
||||||
|
|
||||||
// httpServer is declared here so the shutdown func can reference it.
|
// httpServer is declared here so the shutdown func can reference it.
|
||||||
// ReadTimeout/WriteTimeout are intentionally omitted — they would kill
|
// ReadTimeout/WriteTimeout are intentionally omitted — they would kill
|
||||||
// long-lived Connect RPC streams and WebSocket proxy connections.
|
// long-lived Connect RPC streams and WebSocket proxy connections.
|
||||||
@ -188,10 +201,22 @@ func main() {
|
|||||||
shutdownOnce.Do(func() {
|
shutdownOnce.Do(func() {
|
||||||
slog.Info("shutting down", "reason", reason)
|
slog.Info("shutting down", "reason", reason)
|
||||||
cancel()
|
cancel()
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
// Shutdown pauses every running sandbox in parallel (PauseAll uses
|
||||||
|
// a worker pool). Per-sandbox Pause can take 10–30s (memory loader
|
||||||
|
// wait + ch.snapshot of guest RAM). 5 minutes is enough headroom for
|
||||||
|
// a busy host while still bounded so a wedged sandbox can't keep the
|
||||||
|
// process alive indefinitely — a second signal force-exits anyway.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
// Order matters: mgr.Shutdown FIRST so it runs to completion
|
||||||
|
// before httpServer.Shutdown unblocks main's Serve and lets the
|
||||||
|
// process exit. mgr.Shutdown internally flips a draining flag
|
||||||
|
// that rejects new Create/Resume RPCs with Unavailable so any
|
||||||
|
// in-flight HTTP handlers can't add sandboxes after PauseAll
|
||||||
|
// snapshotted state. User-initiated Pauses already running are
|
||||||
|
// awaited by PauseAll/Destroy's lifecycleMu serialization.
|
||||||
mgr.Shutdown(shutdownCtx)
|
mgr.Shutdown(shutdownCtx)
|
||||||
sandbox.ShrinkMinimalImage(rootDir)
|
sandbox.ShrinkSystemImages(rootDir)
|
||||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
slog.Error("http server shutdown error", "error", err)
|
slog.Error("http server shutdown error", "error", err)
|
||||||
}
|
}
|
||||||
@ -224,8 +249,9 @@ func main() {
|
|||||||
func() {
|
func() {
|
||||||
doShutdown("host deleted from CP")
|
doShutdown("host deleted from CP")
|
||||||
},
|
},
|
||||||
// onCredsRefreshed: hot-swap the TLS certificate after a JWT refresh.
|
// onCredsRefreshed: hot-swap the TLS certificate and update callback JWT.
|
||||||
func(tf *hostagent.TokenFile) {
|
func(tf *hostagent.TokenFile) {
|
||||||
|
cb.UpdateJWT(tf.JWT)
|
||||||
if tf.CertPEM == "" || tf.KeyPEM == "" {
|
if tf.CertPEM == "" || tf.KeyPEM == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -237,12 +263,16 @@ func main() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Graceful shutdown on SIGINT/SIGTERM.
|
// Graceful shutdown on SIGINT/SIGTERM. A second signal force-exits
|
||||||
|
// so the operator can always kill the process if shutdown hangs.
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
go func() {
|
go func() {
|
||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
doShutdown("signal: " + sig.String())
|
go doShutdown("signal: " + sig.String())
|
||||||
|
sig = <-sigCh
|
||||||
|
slog.Error("received second signal, force exiting", "signal", sig.String())
|
||||||
|
os.Exit(1)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
slog.Info("host agent starting", "addr", listenAddr, "host_id", creds.HostID, "version", version, "commit", commit)
|
slog.Info("host agent starting", "addr", listenAddr, "host_id", creds.HostID, "version", version, "commit", commit)
|
||||||
@ -269,62 +299,45 @@ func envOrDefault(key, def string) string {
|
|||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkPrivileges verifies the process has the required Linux capabilities.
|
// envDuration parses an optional duration env var (e.g. "5s"). Empty or
|
||||||
// Always reads CapEff — even for root — because a root process inside a
|
// invalid → zero, letting the sandbox package apply its default.
|
||||||
// restricted container (e.g. docker --cap-drop=all) may not have all caps.
|
func envDuration(key string) time.Duration {
|
||||||
func checkPrivileges() error {
|
v := os.Getenv(key)
|
||||||
capEff, err := readEffectiveCaps()
|
if v == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read capabilities: %w", err)
|
slog.Warn("invalid duration env var, using default", "key", key, "value", v)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
// All capabilities required by the host agent at runtime.
|
|
||||||
required := []struct {
|
|
||||||
bit uint
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{1, "CAP_DAC_OVERRIDE"}, // /dev/loop*, /dev/mapper/*, /dev/net/tun
|
|
||||||
{5, "CAP_KILL"}, // SIGTERM/SIGKILL to Firecracker processes
|
|
||||||
{12, "CAP_NET_ADMIN"}, // netlink, iptables, routing, TAP/veth
|
|
||||||
{13, "CAP_NET_RAW"}, // raw sockets (iptables)
|
|
||||||
{19, "CAP_SYS_PTRACE"}, // reading /proc/self/ns/net (netns.Get)
|
|
||||||
{21, "CAP_SYS_ADMIN"}, // netns, mount ns, losetup, dmsetup
|
|
||||||
{27, "CAP_MKNOD"}, // device-mapper node creation
|
|
||||||
}
|
|
||||||
|
|
||||||
var missing []string
|
|
||||||
for _, cap := range required {
|
|
||||||
if capEff&(1<<cap.bit) == 0 {
|
|
||||||
missing = append(missing, cap.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missing) > 0 {
|
|
||||||
return fmt.Errorf("missing capabilities: %s — run as root or apply setcap to the binary",
|
|
||||||
strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readEffectiveCaps parses the CapEff bitmask from /proc/self/status.
|
// envFloat32 parses an optional float env var. Empty or invalid → 0.
|
||||||
func readEffectiveCaps() (uint64, error) {
|
func envFloat32(key string) float32 {
|
||||||
f, err := os.Open("/proc/self/status")
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(v, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
slog.Warn("invalid float env var, using default", "key", key, "value", v)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
defer f.Close()
|
return float32(f)
|
||||||
|
}
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
// envUint64 parses an optional unsigned-int env var. Empty or invalid → 0.
|
||||||
line := scanner.Text()
|
func envUint64(key string) uint64 {
|
||||||
if hexStr, ok := strings.CutPrefix(line, "CapEff:"); ok {
|
v := os.Getenv(key)
|
||||||
return strconv.ParseUint(strings.TrimSpace(hexStr), 16, 64)
|
if v == "" {
|
||||||
}
|
return 0
|
||||||
}
|
}
|
||||||
|
n, err := strconv.ParseUint(v, 10, 64)
|
||||||
if err := scanner.Err(); err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("read /proc/self/status: %w", err)
|
slog.Warn("invalid uint env var, using default", "key", key, "value", v)
|
||||||
}
|
return 0
|
||||||
return 0, fmt.Errorf("CapEff not found in /proc/self/status")
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
-- No user can become a member of this team — it exists solely to satisfy
|
-- No user can become a member of this team — it exists solely to satisfy
|
||||||
-- foreign key constraints and to act as a namespace for platform resources.
|
-- foreign key constraints and to act as a namespace for platform resources.
|
||||||
INSERT INTO teams (id, name, slug)
|
INSERT INTO teams (id, name, slug)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000000', 'Platform', 'platform')
|
VALUES ('00000000-0000-0000-0000-000000000000', 'Platform', 'wrenn')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
|
|||||||
21
db/migrations/20260518200117_add_sessions.sql
Normal file
21
db/migrations/20260518200117_add_sessions.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
csrf_token TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX sessions_user_id_idx ON sessions(user_id);
|
||||||
|
CREATE INDEX sessions_expires_at_idx ON sessions(expires_at);
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
-- +goose StatementEnd
|
||||||
15
db/migrations/20260519231056_hash_session_ids.sql
Normal file
15
db/migrations/20260519231056_hash_session_ids.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
-- Session IDs are now stored as sha256(raw_sid) hex so a DB/Redis dump
|
||||||
|
-- cannot be replayed as session cookies. Existing sessions hold raw SIDs
|
||||||
|
-- in id; they are unrecoverable under the new scheme and must be wiped.
|
||||||
|
-- Users will need to log in again after this migration.
|
||||||
|
TRUNCATE TABLE sessions;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
-- Down: nothing to do schematically. Hashed rows remain but will never
|
||||||
|
-- match a raw cookie under the old code path; safest is to wipe again.
|
||||||
|
TRUNCATE TABLE sessions;
|
||||||
|
-- +goose StatementEnd
|
||||||
49
db/migrations/20260522154716_seed_system_base_templates.sql
Normal file
49
db/migrations/20260522154716_seed_system_base_templates.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- Replace the old all-zeros "minimal" base template with the four system base
|
||||||
|
-- templates (ubuntu/alpine/arch/fedora). All are platform-owned (team_id
|
||||||
|
-- all-zeros) with reserved template IDs 0..3, default user wrenn-user.
|
||||||
|
--
|
||||||
|
-- Template IDs are well-known: the all-zeros UUID + low byte = {0,1,2,3}.
|
||||||
|
-- On disk each lives at images/teams/{base36(0)}/{base36(id)}/rootfs.ext4.
|
||||||
|
|
||||||
|
-- 0 → minimal-ubuntu (was "minimal").
|
||||||
|
UPDATE templates
|
||||||
|
SET name = 'minimal-ubuntu',
|
||||||
|
default_user = 'wrenn-user'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000000';
|
||||||
|
|
||||||
|
-- Seed the row if it did not already exist (fresh DBs).
|
||||||
|
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000000', 'minimal-ubuntu', 'base', 1, 512, 0,
|
||||||
|
'00000000-0000-0000-0000-000000000000', 'wrenn-user')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 1 → minimal-alpine, 2 → minimal-arch, 3 → minimal-fedora.
|
||||||
|
INSERT INTO templates (id, name, type, vcpus, memory_mb, size_bytes, team_id, default_user)
|
||||||
|
VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'minimal-alpine', 'base', 1, 512, 0,
|
||||||
|
'00000000-0000-0000-0000-000000000000', 'wrenn-user'),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'minimal-arch', 'base', 1, 512, 0,
|
||||||
|
'00000000-0000-0000-0000-000000000000', 'wrenn-user'),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'minimal-fedora', 'base', 1, 512, 0,
|
||||||
|
'00000000-0000-0000-0000-000000000000', 'wrenn-user')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Point the sandboxes.template column default at the new default base template.
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN template SET DEFAULT 'minimal-ubuntu';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN template SET DEFAULT 'minimal';
|
||||||
|
|
||||||
|
DELETE FROM templates WHERE id IN (
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'00000000-0000-0000-0000-000000000002',
|
||||||
|
'00000000-0000-0000-0000-000000000003'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE templates
|
||||||
|
SET name = 'minimal',
|
||||||
|
default_user = 'root'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000000';
|
||||||
25
db/migrations/20260706122825_bump_default_vcpus_memory.sql
Normal file
25
db/migrations/20260706122825_bump_default_vcpus_memory.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- Bump the default sandbox specs from 1 vCPU / 512 MB to 2 vCPUs / 2048 MB.
|
||||||
|
-- Applies to any row inserted without explicit vcpus/memory_mb across the
|
||||||
|
-- sandboxes, templates, and template_builds tables.
|
||||||
|
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN vcpus SET DEFAULT 2;
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||||
|
|
||||||
|
ALTER TABLE templates ALTER COLUMN vcpus SET DEFAULT 2;
|
||||||
|
ALTER TABLE templates ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||||
|
|
||||||
|
ALTER TABLE template_builds ALTER COLUMN vcpus SET DEFAULT 2;
|
||||||
|
ALTER TABLE template_builds ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN vcpus SET DEFAULT 1;
|
||||||
|
ALTER TABLE sandboxes ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||||
|
|
||||||
|
ALTER TABLE templates ALTER COLUMN vcpus SET DEFAULT 1;
|
||||||
|
ALTER TABLE templates ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||||
|
|
||||||
|
ALTER TABLE template_builds ALTER COLUMN vcpus SET DEFAULT 1;
|
||||||
|
ALTER TABLE template_builds ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||||
41
db/migrations/20260713100416_template_sharing.sql
Normal file
41
db/migrations/20260713100416_template_sharing.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- Templates can be published so teams other than the owner can launch them.
|
||||||
|
-- Owners reference foreign public templates as "<team-slug>/<name>".
|
||||||
|
ALTER TABLE templates ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Partial index for the templates list page, which unions a team's own
|
||||||
|
-- templates with every public template across all teams.
|
||||||
|
CREATE INDEX idx_templates_public ON templates(is_public) WHERE is_public;
|
||||||
|
|
||||||
|
-- Records the last time a team changed its slug. Used to enforce the 60-day
|
||||||
|
-- rename cooldown. NULL means the slug has never been changed (still the
|
||||||
|
-- auto-generated one).
|
||||||
|
ALTER TABLE teams ADD COLUMN slug_changed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Tombstone table: when a team changes its slug, the old slug is parked here
|
||||||
|
-- for 30 days so nobody else can immediately claim it and start serving
|
||||||
|
-- different templates under a name others may still reference. No redirect —
|
||||||
|
-- old "<slug>/<name>" references simply fail while the reservation is active
|
||||||
|
-- and after it expires (until someone re-registers the slug).
|
||||||
|
CREATE TABLE reserved_slugs (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
team_id UUID NOT NULL,
|
||||||
|
reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_reserved_slugs_expires ON reserved_slugs(expires_at);
|
||||||
|
|
||||||
|
-- Rebrand the platform team's slug to 'wrenn'. Fresh installs already seed
|
||||||
|
-- 'wrenn'; this migrates databases seeded with the old 'platform' slug. Guarded
|
||||||
|
-- on the current value so a re-slugged team is never clobbered.
|
||||||
|
UPDATE teams SET slug = 'wrenn'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000000' AND slug = 'platform';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
UPDATE teams SET slug = 'platform'
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000000' AND slug = 'wrenn';
|
||||||
|
DROP TABLE reserved_slugs;
|
||||||
|
ALTER TABLE teams DROP COLUMN slug_changed_at;
|
||||||
|
DROP INDEX IF EXISTS idx_templates_public;
|
||||||
|
ALTER TABLE templates DROP COLUMN is_public;
|
||||||
28
db/migrations/20260725043541_add_volumes.sql
Normal file
28
db/migrations/20260725043541_add_volumes.sql
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- External storage volumes: team-scoped block-storage disks that are attached
|
||||||
|
-- to a capsule at create time and mounted inside the guest. A volume is
|
||||||
|
-- host-local (Cloud Hypervisor can only attach a path on the same host), so it
|
||||||
|
-- is pinned to a host the first time it is attached and stays there. Volumes
|
||||||
|
-- are never auto-deleted: destroying a capsule frees its volumes back to
|
||||||
|
-- 'detached' (data preserved); removal is always an explicit delete.
|
||||||
|
CREATE TABLE volumes (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id),
|
||||||
|
host_id UUID REFERENCES hosts(id), -- NULL until first attach; pins the volume thereafter
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
size_mb INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'detached', -- detached | attaching | attached | deleting
|
||||||
|
sandbox_id UUID REFERENCES sandboxes(id), -- set while attaching/attached, cleared on capsule destroy
|
||||||
|
mount_path TEXT NOT NULL DEFAULT '', -- guest mount path in use; '' means the default /mnt/<vol-id>
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_attached_at TIMESTAMPTZ,
|
||||||
|
last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (team_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_volumes_team ON volumes(team_id);
|
||||||
|
CREATE INDEX idx_volumes_host ON volumes(host_id);
|
||||||
|
CREATE INDEX idx_volumes_sandbox ON volumes(sandbox_id) WHERE sandbox_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS volumes;
|
||||||
41
db/migrations/20260725105719_volume_name_prefix.sql
Normal file
41
db/migrations/20260725105719_volume_name_prefix.sql
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Volume names are now always "vl-"-prefixed slugs: lowercase alphanumerics in
|
||||||
|
-- dash-separated groups, at most 40 characters. The prefix makes a name
|
||||||
|
-- self-describing and — since volume IDs use the distinct "vol-" prefix — lets
|
||||||
|
-- one API path segment be resolved as either an ID or a name unambiguously.
|
||||||
|
--
|
||||||
|
-- Backfill the names written before the rule existed. They were validated with
|
||||||
|
-- the looser SafeName allowlist, so coerce them into slug shape first
|
||||||
|
-- (lowercase, non-slug characters to dashes, collapse and trim dashes) and only
|
||||||
|
-- then prefix. A name that survives as empty falls back to the volume's ID,
|
||||||
|
-- which is also the default a nameless volume now gets.
|
||||||
|
UPDATE volumes
|
||||||
|
SET name = LEFT(
|
||||||
|
'vl-' || COALESCE(
|
||||||
|
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(name), '[^a-z0-9]+', '-', 'g')), ''),
|
||||||
|
REPLACE(id::text, '-', '')
|
||||||
|
),
|
||||||
|
40
|
||||||
|
)
|
||||||
|
WHERE name !~ '^vl-[a-z0-9]+(-[a-z0-9]+)*$';
|
||||||
|
|
||||||
|
-- The truncation and coercion above can collide two previously-distinct names
|
||||||
|
-- within a team, which UNIQUE (team_id, name) would have rejected. Nothing here
|
||||||
|
-- can fail silently, so re-assert the invariant: any row still not matching the
|
||||||
|
-- rule aborts the migration rather than leaving invalid names behind.
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM volumes WHERE name !~ '^vl-[a-z0-9]+(-[a-z0-9]+)*$') THEN
|
||||||
|
RAISE EXCEPTION 'volume name backfill left rows that are not valid vl- slugs';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Strip the prefix back off. The original pre-coercion spelling is not
|
||||||
|
-- recoverable, so this restores the shape, not the exact former value.
|
||||||
|
UPDATE volumes
|
||||||
|
SET name = REGEXP_REPLACE(name, '^vl-', '')
|
||||||
|
WHERE name ~ '^vl-';
|
||||||
@ -28,3 +28,6 @@ DELETE FROM team_api_keys WHERE team_id = $1;
|
|||||||
|
|
||||||
-- name: DeleteAPIKeysByCreator :exec
|
-- name: DeleteAPIKeysByCreator :exec
|
||||||
DELETE FROM team_api_keys WHERE created_by = $1;
|
DELETE FROM team_api_keys WHERE created_by = $1;
|
||||||
|
|
||||||
|
-- name: DeleteAPIKeysByTeamAndCreator :exec
|
||||||
|
DELETE FROM team_api_keys WHERE team_id = $1 AND created_by = $2;
|
||||||
|
|||||||
@ -13,7 +13,36 @@ SELECT * FROM hosts ORDER BY created_at DESC;
|
|||||||
SELECT * FROM hosts WHERE type = $1 ORDER BY created_at DESC;
|
SELECT * FROM hosts WHERE type = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: ListHostsByTeam :many
|
-- name: ListHostsByTeam :many
|
||||||
SELECT * FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC;
|
-- Returns hosts by team with per-host sandbox resource consumption aggregated.
|
||||||
|
-- Follows the same aggregation pattern as ListHostsAdmin and GetHostsWithLoad.
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.type,
|
||||||
|
h.team_id,
|
||||||
|
h.provider,
|
||||||
|
h.availability_zone,
|
||||||
|
h.arch,
|
||||||
|
h.cpu_cores,
|
||||||
|
h.memory_mb,
|
||||||
|
h.disk_gb,
|
||||||
|
h.address,
|
||||||
|
h.status,
|
||||||
|
h.last_heartbeat_at,
|
||||||
|
h.metadata,
|
||||||
|
h.created_by,
|
||||||
|
h.created_at,
|
||||||
|
h.updated_at,
|
||||||
|
COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus,
|
||||||
|
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb,
|
||||||
|
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb,
|
||||||
|
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_memory_mb,
|
||||||
|
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_disk_mb
|
||||||
|
FROM hosts h
|
||||||
|
LEFT JOIN sandboxes s ON s.host_id = h.id
|
||||||
|
AND s.status IN ('running', 'paused', 'starting', 'pending')
|
||||||
|
WHERE h.team_id = $1 AND h.type = 'byoc'
|
||||||
|
GROUP BY h.id
|
||||||
|
ORDER BY h.created_at DESC;
|
||||||
|
|
||||||
-- name: ListHostsByStatus :many
|
-- name: ListHostsByStatus :many
|
||||||
SELECT * FROM hosts WHERE status = $1 ORDER BY created_at DESC;
|
SELECT * FROM hosts WHERE status = $1 ORDER BY created_at DESC;
|
||||||
@ -101,8 +130,6 @@ SELECT
|
|||||||
h.created_by,
|
h.created_by,
|
||||||
h.created_at,
|
h.created_at,
|
||||||
h.updated_at,
|
h.updated_at,
|
||||||
h.cert_fingerprint,
|
|
||||||
h.cert_expires_at,
|
|
||||||
COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus,
|
COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus,
|
||||||
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb,
|
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb,
|
||||||
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb,
|
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb,
|
||||||
@ -125,5 +152,37 @@ SET last_heartbeat_at = NOW(),
|
|||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListHostsAdmin :many
|
||||||
|
-- Returns all hosts with per-host sandbox resource consumption aggregated.
|
||||||
|
-- Unlike GetHostsWithLoad, this returns ALL hosts (not just online) so admins
|
||||||
|
-- can see resource usage across the entire fleet including offline/pending hosts.
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
h.type,
|
||||||
|
h.team_id,
|
||||||
|
h.provider,
|
||||||
|
h.availability_zone,
|
||||||
|
h.arch,
|
||||||
|
h.cpu_cores,
|
||||||
|
h.memory_mb,
|
||||||
|
h.disk_gb,
|
||||||
|
h.address,
|
||||||
|
h.status,
|
||||||
|
h.last_heartbeat_at,
|
||||||
|
h.metadata,
|
||||||
|
h.created_by,
|
||||||
|
h.created_at,
|
||||||
|
h.updated_at,
|
||||||
|
COALESCE(SUM(s.vcpus) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_vcpus,
|
||||||
|
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_memory_mb,
|
||||||
|
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status IN ('running', 'starting', 'pending')), 0)::int AS running_disk_mb,
|
||||||
|
COALESCE(SUM(s.memory_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_memory_mb,
|
||||||
|
COALESCE(SUM(s.disk_size_mb) FILTER (WHERE s.status = 'paused'), 0)::int AS paused_disk_mb
|
||||||
|
FROM hosts h
|
||||||
|
LEFT JOIN sandboxes s ON s.host_id = h.id
|
||||||
|
AND s.status IN ('running', 'paused', 'starting', 'pending')
|
||||||
|
GROUP BY h.id
|
||||||
|
ORDER BY h.created_at DESC;
|
||||||
|
|
||||||
-- name: MarkHostUnreachable :exec
|
-- name: MarkHostUnreachable :exec
|
||||||
UPDATE hosts SET status = 'unreachable', updated_at = NOW() WHERE id = $1;
|
UPDATE hosts SET status = 'unreachable', updated_at = NOW() WHERE id = $1;
|
||||||
|
|||||||
@ -42,6 +42,13 @@ ORDER BY ts ASC;
|
|||||||
DELETE FROM sandbox_metric_points
|
DELETE FROM sandbox_metric_points
|
||||||
WHERE sandbox_id = $1;
|
WHERE sandbox_id = $1;
|
||||||
|
|
||||||
|
-- name: GetLatestSandboxMetricPoint :one
|
||||||
|
SELECT ts, cpu_pct, mem_bytes, disk_bytes
|
||||||
|
FROM sandbox_metric_points
|
||||||
|
WHERE sandbox_id = $1
|
||||||
|
ORDER BY ts DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: DeleteSandboxMetricPointsByTier :exec
|
-- name: DeleteSandboxMetricPointsByTier :exec
|
||||||
DELETE FROM sandbox_metric_points
|
DELETE FROM sandbox_metric_points
|
||||||
WHERE sandbox_id = $1 AND tier = $2;
|
WHERE sandbox_id = $1 AND tier = $2;
|
||||||
|
|||||||
@ -72,7 +72,7 @@ ORDER BY created_at DESC;
|
|||||||
UPDATE sandboxes
|
UPDATE sandboxes
|
||||||
SET status = 'missing',
|
SET status = 'missing',
|
||||||
last_updated = NOW()
|
last_updated = NOW()
|
||||||
WHERE host_id = $1 AND status IN ('running', 'starting', 'pending');
|
WHERE host_id = $1 AND status IN ('running', 'starting', 'pending', 'pausing', 'resuming', 'stopping');
|
||||||
|
|
||||||
-- name: UpdateSandboxMetadata :exec
|
-- name: UpdateSandboxMetadata :exec
|
||||||
UPDATE sandboxes
|
UPDATE sandboxes
|
||||||
@ -80,10 +80,42 @@ SET metadata = $2,
|
|||||||
last_updated = NOW()
|
last_updated = NOW()
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: BulkRestoreRunning :exec
|
-- name: UpdateSandboxRunningIf :one
|
||||||
-- Called by the reconciler when a host comes back online and its sandboxes are
|
-- Conditionally transition a sandbox to running only if the current status
|
||||||
-- confirmed alive. Restores only sandboxes that are in 'missing' state.
|
-- matches the expected value. Prevents races where a user destroys a sandbox
|
||||||
|
-- while the create/resume goroutine is still in-flight.
|
||||||
UPDATE sandboxes
|
UPDATE sandboxes
|
||||||
SET status = 'running',
|
SET status = 'running',
|
||||||
|
host_ip = $3,
|
||||||
|
guest_ip = $4,
|
||||||
|
started_at = $5,
|
||||||
|
last_active_at = $5,
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateSandboxStatusIf :one
|
||||||
|
-- Atomically update status only when the current status matches the expected value.
|
||||||
|
-- Prevents background goroutines from overwriting a status that has since changed
|
||||||
|
-- (e.g. user destroyed a sandbox while Create was in-flight).
|
||||||
|
UPDATE sandboxes
|
||||||
|
SET status = $3,
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: BulkRestoreMissingToStatus :exec
|
||||||
|
-- Called by the reconciler when a host comes back online and its sandboxes are
|
||||||
|
-- confirmed alive. Restores only sandboxes currently in 'missing' state to the
|
||||||
|
-- given target status (typically 'running' or 'paused' based on the live state
|
||||||
|
-- reported by the host agent's ListSandboxes RPC).
|
||||||
|
UPDATE sandboxes
|
||||||
|
SET status = $2,
|
||||||
last_updated = NOW()
|
last_updated = NOW()
|
||||||
WHERE id = ANY($1::uuid[]) AND status = 'missing';
|
WHERE id = ANY($1::uuid[]) AND status = 'missing';
|
||||||
|
|
||||||
|
-- name: UpdateSandboxDiskSize :exec
|
||||||
|
UPDATE sandboxes
|
||||||
|
SET disk_size_mb = $2,
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|||||||
31
db/queries/sessions.sql
Normal file
31
db/queries/sessions.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- name: InsertSession :one
|
||||||
|
INSERT INTO sessions (id, user_id, team_id, csrf_token, user_agent, ip_address, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSession :one
|
||||||
|
SELECT * FROM sessions WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: TouchSession :exec
|
||||||
|
UPDATE sessions SET last_seen_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateSessionTeam :exec
|
||||||
|
UPDATE sessions SET team_id = $2 WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteSession :exec
|
||||||
|
DELETE FROM sessions WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteSessionForUser :exec
|
||||||
|
DELETE FROM sessions WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
|
-- name: ListSessionsByUserID :many
|
||||||
|
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
|
||||||
|
|
||||||
|
-- name: ListActiveSessionsByUserID :many
|
||||||
|
SELECT * FROM sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY last_seen_at DESC;
|
||||||
|
|
||||||
|
-- name: DeleteSessionsByUserID :many
|
||||||
|
DELETE FROM sessions WHERE user_id = $1 RETURNING id;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredSessions :exec
|
||||||
|
DELETE FROM sessions WHERE expires_at < NOW();
|
||||||
@ -34,6 +34,26 @@ UPDATE teams SET deleted_at = NOW() WHERE id = $1;
|
|||||||
-- name: GetTeamBySlug :one
|
-- name: GetTeamBySlug :one
|
||||||
SELECT * FROM teams WHERE slug = $1 AND deleted_at IS NULL;
|
SELECT * FROM teams WHERE slug = $1 AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- name: UpdateTeamSlug :exec
|
||||||
|
UPDATE teams SET slug = $2, slug_changed_at = NOW() WHERE id = $1 AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- name: InsertReservedSlug :exec
|
||||||
|
-- Park an old slug for 30 days so nobody can immediately claim it after a rename.
|
||||||
|
INSERT INTO reserved_slugs (slug, team_id, expires_at)
|
||||||
|
VALUES ($1, $2, NOW() + INTERVAL '30 days')
|
||||||
|
ON CONFLICT (slug) DO UPDATE
|
||||||
|
SET team_id = EXCLUDED.team_id, reserved_at = NOW(), expires_at = NOW() + INTERVAL '30 days';
|
||||||
|
|
||||||
|
-- name: GetActiveReservedSlug :one
|
||||||
|
-- Returns a non-expired reservation for the slug, if any.
|
||||||
|
SELECT * FROM reserved_slugs WHERE slug = $1 AND expires_at > NOW();
|
||||||
|
|
||||||
|
-- name: DeleteReservedSlug :exec
|
||||||
|
DELETE FROM reserved_slugs WHERE slug = $1;
|
||||||
|
|
||||||
|
-- name: DeleteExpiredReservedSlugs :exec
|
||||||
|
DELETE FROM reserved_slugs WHERE expires_at <= NOW();
|
||||||
|
|
||||||
-- name: GetTeamsForUser :many
|
-- name: GetTeamsForUser :many
|
||||||
SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at, ut.role
|
SELECT t.id, t.name, t.slug, t.is_byoc, t.created_at, t.deleted_at, ut.role
|
||||||
FROM teams t
|
FROM teams t
|
||||||
@ -66,7 +86,9 @@ SELECT
|
|||||||
COALESCE(owner_u.name, '') AS owner_name,
|
COALESCE(owner_u.name, '') AS owner_name,
|
||||||
COALESCE(owner_u.email, '') AS owner_email,
|
COALESCE(owner_u.email, '') AS owner_email,
|
||||||
(SELECT COUNT(*) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting'))::int AS active_sandbox_count,
|
(SELECT COUNT(*) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting'))::int AS active_sandbox_count,
|
||||||
(SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count
|
(SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count,
|
||||||
|
COALESCE((SELECT SUM(s.vcpus) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting')), 0)::int AS running_vcpus,
|
||||||
|
COALESCE((SELECT SUM(s.memory_mb) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting')), 0)::int AS running_memory_mb
|
||||||
FROM teams t
|
FROM teams t
|
||||||
LEFT JOIN users_teams owner_ut ON owner_ut.team_id = t.id AND owner_ut.role = 'owner'
|
LEFT JOIN users_teams owner_ut ON owner_ut.team_id = t.id AND owner_ut.role = 'owner'
|
||||||
LEFT JOIN users owner_u ON owner_u.id = owner_ut.user_id
|
LEFT JOIN users owner_u ON owner_u.id = owner_ut.user_id
|
||||||
|
|||||||
@ -7,13 +7,65 @@ RETURNING *;
|
|||||||
SELECT * FROM templates WHERE id = $1;
|
SELECT * FROM templates WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetTemplateByTeam :one
|
-- name: GetTemplateByTeam :one
|
||||||
-- Platform templates (team_id = 00000000-...) are visible to all teams.
|
-- Platform templates (team_id = 00000000-...) are visible to all teams. When a
|
||||||
SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000');
|
-- team owns a template whose name also matches a platform template, prefer the
|
||||||
|
-- team's own row so mutation guards act on the caller's template, not the
|
||||||
|
-- shared platform one.
|
||||||
|
SELECT * FROM templates
|
||||||
|
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||||
|
ORDER BY (team_id = $2) DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: GetTemplateByName :one
|
-- name: GetTemplateByName :one
|
||||||
-- Look up a template by team_id and name (exact team match, no global fallback).
|
-- Look up a template by team_id and name (exact team match, no global fallback).
|
||||||
SELECT * FROM templates WHERE team_id = $1 AND name = $2;
|
SELECT * FROM templates WHERE team_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ResolveTemplateForTeam :one
|
||||||
|
-- Unqualified reference resolution: prefer the requesting team's own template,
|
||||||
|
-- then fall back to a platform template of the same name.
|
||||||
|
SELECT * FROM templates
|
||||||
|
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||||
|
ORDER BY (team_id = $2) DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetVisibleTemplateByTeamName :one
|
||||||
|
-- Slug-qualified reference resolution: the template must belong to the named
|
||||||
|
-- owning team ($1) and be visible to the requester ($3) — public, owned by the
|
||||||
|
-- requester, or a platform template.
|
||||||
|
SELECT * FROM templates
|
||||||
|
WHERE team_id = $1 AND name = $2
|
||||||
|
AND (is_public = TRUE OR team_id = $3 OR team_id = '00000000-0000-0000-0000-000000000000');
|
||||||
|
|
||||||
|
-- name: SetTemplateVisibility :one
|
||||||
|
-- Publish or unpublish a template the team owns. Returns the updated row (or no
|
||||||
|
-- rows if the team does not own a template of that name).
|
||||||
|
UPDATE templates SET is_public = $3
|
||||||
|
WHERE team_id = $1 AND name = $2
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ListVisibleTemplates :many
|
||||||
|
-- Templates the team may launch: its own, platform, and every public template.
|
||||||
|
-- $2 = type filter ('' = any), $3 = search over name/slug ('' = no search).
|
||||||
|
SELECT t.*, tm.slug AS team_slug
|
||||||
|
FROM templates t
|
||||||
|
JOIN teams tm ON tm.id = t.team_id
|
||||||
|
WHERE (t.team_id = @team_id OR t.team_id = '00000000-0000-0000-0000-000000000000' OR t.is_public = TRUE)
|
||||||
|
AND (@type_filter::text = '' OR t.type = @type_filter::text)
|
||||||
|
AND (@search::text = '' OR t.name ILIKE '%' || @search::text || '%' OR tm.slug ILIKE '%' || @search::text || '%')
|
||||||
|
ORDER BY
|
||||||
|
(t.team_id = @team_id) DESC,
|
||||||
|
(t.team_id = '00000000-0000-0000-0000-000000000000') DESC,
|
||||||
|
t.created_at DESC
|
||||||
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
|
-- name: CountVisibleTemplates :one
|
||||||
|
SELECT COUNT(*)::int AS total
|
||||||
|
FROM templates t
|
||||||
|
JOIN teams tm ON tm.id = t.team_id
|
||||||
|
WHERE (t.team_id = @team_id OR t.team_id = '00000000-0000-0000-0000-000000000000' OR t.is_public = TRUE)
|
||||||
|
AND (@type_filter::text = '' OR t.type = @type_filter::text)
|
||||||
|
AND (@search::text = '' OR t.name ILIKE '%' || @search::text || '%' OR tm.slug ILIKE '%' || @search::text || '%');
|
||||||
|
|
||||||
-- name: GetPlatformTemplateByName :one
|
-- name: GetPlatformTemplateByName :one
|
||||||
-- Check if a global (platform) template exists with the given name.
|
-- Check if a global (platform) template exists with the given name.
|
||||||
SELECT * FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1;
|
SELECT * FROM templates WHERE team_id = '00000000-0000-0000-0000-000000000000' AND name = $1;
|
||||||
@ -42,6 +94,19 @@ DELETE FROM templates WHERE name = $1 AND team_id = $2;
|
|||||||
-- Bulk delete all templates owned by a team (for team soft-delete cleanup).
|
-- Bulk delete all templates owned by a team (for team soft-delete cleanup).
|
||||||
DELETE FROM templates WHERE team_id = $1;
|
DELETE FROM templates WHERE team_id = $1;
|
||||||
|
|
||||||
|
-- name: UpdateTemplateSize :exec
|
||||||
|
UPDATE templates SET size_bytes = $2 WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: RenameTemplate :one
|
||||||
|
-- Rename a template by ID. Clears the published flag so stale
|
||||||
|
-- "<team-slug>/<name>" references from other teams break loudly rather than
|
||||||
|
-- silently resolving to a different template. Callers apply ownership and
|
||||||
|
-- protection guards before calling. A name collision (team-scoped uniqueness or
|
||||||
|
-- the global-template-name trigger) surfaces as a unique_violation.
|
||||||
|
UPDATE templates SET name = @new_name, is_public = FALSE
|
||||||
|
WHERE id = @id
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ListTemplatesByTeamOnly :many
|
-- name: ListTemplatesByTeamOnly :many
|
||||||
-- List templates owned by a specific team (NOT including platform templates).
|
-- List templates owned by a specific team (NOT including platform templates).
|
||||||
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;
|
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;
|
||||||
|
|||||||
@ -22,6 +22,12 @@ RETURNING *;
|
|||||||
-- name: SetUserAdmin :exec
|
-- name: SetUserAdmin :exec
|
||||||
UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1;
|
UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: RevokeUserAdmin :execrows
|
||||||
|
UPDATE users u SET is_admin = false, updated_at = NOW()
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND u.is_admin = true
|
||||||
|
AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1;
|
||||||
|
|
||||||
-- name: GetAdminUsers :many
|
-- name: GetAdminUsers :many
|
||||||
SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at;
|
SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at;
|
||||||
|
|
||||||
|
|||||||
178
db/queries/volumes.sql
Normal file
178
db/queries/volumes.sql
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
-- name: InsertVolume :one
|
||||||
|
INSERT INTO volumes (id, team_id, name, size_mb, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'detached')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetVolume :one
|
||||||
|
SELECT * FROM volumes WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: GetVolumeByTeam :one
|
||||||
|
SELECT * FROM volumes WHERE id = $1 AND team_id = $2;
|
||||||
|
|
||||||
|
-- name: GetVolumeByTeamAndName :one
|
||||||
|
-- Resolve a volume by its user-facing name. Names are unique per team, so this
|
||||||
|
-- is the name-based counterpart to GetVolumeByTeam and is what lets the API
|
||||||
|
-- accept "vl-cache" wherever it accepts "vol-<id>".
|
||||||
|
SELECT * FROM volumes WHERE team_id = $1 AND name = $2;
|
||||||
|
|
||||||
|
-- name: ListVolumesByTeam :many
|
||||||
|
SELECT * FROM volumes WHERE team_id = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListVolumesBySandbox :many
|
||||||
|
SELECT * FROM volumes WHERE sandbox_id = $1 ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: CountVolumesByHost :one
|
||||||
|
-- Guard used before a host can be removed: a host holding pinned volumes must
|
||||||
|
-- not be deleted out from under them.
|
||||||
|
SELECT COUNT(*) FROM volumes WHERE host_id = $1;
|
||||||
|
|
||||||
|
-- name: ReserveVolumeForAttach :one
|
||||||
|
-- Atomically claim a detached volume for a capsule about to boot. The CAS on
|
||||||
|
-- status = 'detached' enforces single-attach: a second capsule create racing
|
||||||
|
-- for the same volume finds no row and fails. sandbox_id is deliberately NOT
|
||||||
|
-- set here — the sandbox row does not exist yet, so setting the FK would fail.
|
||||||
|
-- It is set in MarkVolumeAttached once the capsule has booted.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'attaching',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = 'detached'
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: MarkVolumeAttached :one
|
||||||
|
-- Promote a reserved volume to attached once the capsule has booted with it.
|
||||||
|
-- Records the owning sandbox and pins the volume to the sandbox's host on
|
||||||
|
-- first-ever attach (COALESCE keeps an existing pin for a re-attached volume).
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'attached',
|
||||||
|
host_id = COALESCE(host_id, $2),
|
||||||
|
sandbox_id = $3,
|
||||||
|
mount_path = $4,
|
||||||
|
last_attached_at = NOW(),
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = 'attaching'
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: LinkVolumeReservation :exec
|
||||||
|
-- Stamp the owning sandbox onto a reservation as soon as the sandbox row
|
||||||
|
-- exists (ReserveVolumeForAttach runs before the insert, so it cannot set the
|
||||||
|
-- FK itself). This makes every in-flight reservation attributable to a capsule,
|
||||||
|
-- which is what lets the host monitor decide — against live host state —
|
||||||
|
-- whether a stuck 'attaching' row is safe to free.
|
||||||
|
UPDATE volumes
|
||||||
|
SET sandbox_id = $2,
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = 'attaching';
|
||||||
|
|
||||||
|
-- name: ReleaseVolumeReservation :exec
|
||||||
|
-- Roll a reserved volume back to detached when the capsule create fails.
|
||||||
|
-- host_id is left untouched (a failed create never pins a fresh volume, since
|
||||||
|
-- MarkVolumeAttached is what sets the pin). Only call this once the host has
|
||||||
|
-- confirmed the capsule is not running — a volume freed while a VM still has
|
||||||
|
-- its backing file open can be re-attached elsewhere and corrupted.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
sandbox_id = NULL,
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = 'attaching';
|
||||||
|
|
||||||
|
-- name: DetachVolumesBySandbox :exec
|
||||||
|
-- Terminal sweep run when a capsule reaches a terminal state (destroyed,
|
||||||
|
-- stopped, errored, or reaped): free every volume it held back to detached
|
||||||
|
-- (data and host pin preserved) so it can be reused or deleted. Keyed on
|
||||||
|
-- sandbox_id, which is stamped on at reservation time. Never deletes the
|
||||||
|
-- volume — removal is always explicit.
|
||||||
|
--
|
||||||
|
-- 'attaching' is swept alongside 'attached' so a reservation whose promotion
|
||||||
|
-- never landed (lost RPC response, DB blip) is freed here — on confirmed host
|
||||||
|
-- state — rather than on a blind timer.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
sandbox_id = NULL,
|
||||||
|
mount_path = '',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE sandbox_id = $1 AND status IN ('attached', 'attaching');
|
||||||
|
|
||||||
|
-- name: DetachVolumesBySandboxIDs :exec
|
||||||
|
-- Bulk variant of DetachVolumesBySandbox for the host monitor, which stops
|
||||||
|
-- many sandboxes at once (e.g. after a host goes offline).
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
sandbox_id = NULL,
|
||||||
|
mount_path = '',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE sandbox_id = ANY($1::uuid[]) AND status IN ('attached', 'attaching');
|
||||||
|
|
||||||
|
-- name: DetachVolumesByHost :exec
|
||||||
|
-- Free and un-pin every volume on a host being force-deleted. The host (and its
|
||||||
|
-- backing files) are going away, so the volumes are reset to a fresh detached,
|
||||||
|
-- un-pinned state — the rows survive (never auto-deleted) and can be re-attached
|
||||||
|
-- on a new host. Also required before DeleteHost so the host_id FK does not
|
||||||
|
-- block deletion.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
sandbox_id = NULL,
|
||||||
|
host_id = NULL,
|
||||||
|
mount_path = '',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE host_id = $1;
|
||||||
|
|
||||||
|
-- name: BeginVolumeDelete :one
|
||||||
|
-- Atomically claim a volume for deletion. The CAS blocks a concurrent attach
|
||||||
|
-- (which needs status='detached') so a volume can never be attached and deleted
|
||||||
|
-- at the same time. 'deleting' is also accepted so a delete that crashed
|
||||||
|
-- mid-flight can be retried rather than leaving the volume permanently stuck.
|
||||||
|
-- Returns the row (for host_id) when claimed; no row means it is not deletable
|
||||||
|
-- (attached/attaching, wrong team, or missing).
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'deleting',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND team_id = $2 AND status IN ('detached', 'deleting')
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: AbortVolumeDelete :exec
|
||||||
|
-- Revert a delete claim back to detached when the host-side file removal fails,
|
||||||
|
-- so the volume stays usable rather than stuck in 'deleting'.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE id = $1 AND status = 'deleting';
|
||||||
|
|
||||||
|
-- name: ReleaseStaleVolumeReservations :execrows
|
||||||
|
-- Free volumes stuck in a transient state whose owning operation died before it
|
||||||
|
-- could ever reach a host — the control plane crashed between reserving a
|
||||||
|
-- volume and inserting the sandbox row, or mid-delete. Run periodically by the
|
||||||
|
-- volume reaper.
|
||||||
|
--
|
||||||
|
-- Deliberately limited to unattributed rows (sandbox_id IS NULL). A reservation
|
||||||
|
-- that already carries a sandbox_id may correspond to a capsule that really did
|
||||||
|
-- boot with the volume mounted — and a timer cannot tell the difference. Those
|
||||||
|
-- are freed only by the host-monitor sweep, which first confirms against the
|
||||||
|
-- host that the capsule is gone. Freeing a live volume here would let a second
|
||||||
|
-- capsule attach the same backing file and corrupt it.
|
||||||
|
--
|
||||||
|
-- The cutoff ($1 = now - grace) MUST exceed the capsule create timeout so a
|
||||||
|
-- legitimately in-flight attach is never freed out from under a booting capsule.
|
||||||
|
UPDATE volumes
|
||||||
|
SET status = 'detached',
|
||||||
|
mount_path = '',
|
||||||
|
last_updated = NOW()
|
||||||
|
WHERE status IN ('attaching', 'deleting')
|
||||||
|
AND sandbox_id IS NULL
|
||||||
|
AND last_updated < $1;
|
||||||
|
|
||||||
|
-- name: DeleteVolumeRow :exec
|
||||||
|
-- Drop the row only while the delete claim still holds. Without the status
|
||||||
|
-- guard a delete that lost its claim mid-flight (reaped, or aborted and then
|
||||||
|
-- re-attached by a racing capsule create) would erase the record of a volume
|
||||||
|
-- that is once again in use.
|
||||||
|
DELETE FROM volumes WHERE id = $1 AND team_id = $2 AND status = 'deleting';
|
||||||
|
|
||||||
|
-- name: DeleteVolumesByTeam :exec
|
||||||
|
-- Drop every volume record belonging to a team being deleted. This is the one
|
||||||
|
-- place volumes are removed without an explicit per-volume delete: the team that
|
||||||
|
-- owns them is going away, so there is no longer anyone who could reference,
|
||||||
|
-- re-attach, or delete them. Unlike DeleteVolumeRow there is no status guard —
|
||||||
|
-- the team's capsules have already been destroyed, so no volume can still be
|
||||||
|
-- legitimately in use, and a row left behind would be unreachable forever (and
|
||||||
|
-- would keep blocking deletion of the host it is pinned to).
|
||||||
|
DELETE FROM volumes WHERE team_id = $1;
|
||||||
818
envd-rs/Cargo.lock
generated
818
envd-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "envd"
|
name = "envd"
|
||||||
version = "0.2.0"
|
version = "0.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.95"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Async runtime
|
# Async runtime
|
||||||
@ -50,15 +50,12 @@ zeroize = { version = "1", features = ["derive"] }
|
|||||||
# File watching
|
# File watching
|
||||||
notify = "7"
|
notify = "7"
|
||||||
|
|
||||||
# Compression
|
|
||||||
flate2 = "1"
|
|
||||||
|
|
||||||
# HTTP client (MMDS polling)
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
|
||||||
|
|
||||||
# Directory walking
|
# Directory walking
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
|
|
||||||
|
# Time parsing (RFC3339 → epoch nanos for clock fix)
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
@ -70,7 +67,9 @@ subtle = "2"
|
|||||||
http-body = "1.0.1"
|
http-body = "1.0.1"
|
||||||
buffa = "0.3"
|
buffa = "0.3"
|
||||||
async-stream = "0.3.6"
|
async-stream = "0.3.6"
|
||||||
mime_guess = "2"
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
connectrpc-build = "0.3"
|
connectrpc-build = "0.3"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# envd (Rust)
|
# envd (Rust)
|
||||||
|
|
||||||
Wrenn guest agent daemon — runs as PID 1 inside Firecracker microVMs. Provides process management, filesystem operations, file transfer, port forwarding, and VM lifecycle control over Connect RPC and HTTP.
|
Wrenn guest agent daemon — runs as PID 1 inside Cloud Hypervisor microVMs. Provides process management, filesystem operations, file transfer, port forwarding, and VM lifecycle control over Connect RPC and HTTP.
|
||||||
|
|
||||||
Rust rewrite of `envd/` (Go). Drop-in replacement — same wire protocol, same endpoints, same CLI flags.
|
Rust rewrite of `envd/` (Go). Drop-in replacement — same wire protocol, same endpoints, same CLI flags.
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ cargo build
|
|||||||
Run locally (outside a VM):
|
Run locally (outside a VM):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/debug/envd --isnotfc --port 49983
|
./target/debug/envd --port 49983
|
||||||
```
|
```
|
||||||
|
|
||||||
### Via Makefile (from repo root)
|
### Via Makefile (from repo root)
|
||||||
@ -64,7 +64,6 @@ make build-envd-go # Go version (for comparison)
|
|||||||
|
|
||||||
```
|
```
|
||||||
--port <PORT> Listen port [default: 49983]
|
--port <PORT> Listen port [default: 49983]
|
||||||
--isnotfc Not running inside Firecracker (disables MMDS, cgroups)
|
|
||||||
--version Print version and exit
|
--version Print version and exit
|
||||||
--commit Print git commit and exit
|
--commit Print git commit and exit
|
||||||
--cmd <CMD> Spawn a process at startup (e.g. --cmd "/bin/bash")
|
--cmd <CMD> Spawn a process at startup (e.g. --cmd "/bin/bash")
|
||||||
@ -81,9 +80,9 @@ make build-envd-go # Go version (for comparison)
|
|||||||
| GET | `/metrics` | System metrics (CPU, memory, disk) |
|
| GET | `/metrics` | System metrics (CPU, memory, disk) |
|
||||||
| GET | `/envs` | Current environment variables |
|
| GET | `/envs` | Current environment variables |
|
||||||
| POST | `/init` | Host agent init (token, env, mounts) |
|
| POST | `/init` | Host agent init (token, env, mounts) |
|
||||||
| POST | `/snapshot/prepare` | Quiesce before Firecracker snapshot |
|
| POST | `/snapshot/prepare` | Quiesce before Cloud Hypervisor snapshot |
|
||||||
| GET | `/files` | Download file (gzip, range support) |
|
| GET | `/files` | Download file (streamed from disk) |
|
||||||
| POST | `/files` | Upload file(s) via multipart |
|
| PUT | `/files` | Upload file (raw body, streamed, atomic) |
|
||||||
|
|
||||||
### Connect RPC (same port)
|
### Connect RPC (same port)
|
||||||
|
|
||||||
@ -108,7 +107,7 @@ src/
|
|||||||
├── util.rs # AtomicMax
|
├── util.rs # AtomicMax
|
||||||
├── auth/ # Token, signing, middleware
|
├── auth/ # Token, signing, middleware
|
||||||
├── crypto/ # SHA-256, SHA-512, HMAC
|
├── crypto/ # SHA-256, SHA-512, HMAC
|
||||||
├── host/ # MMDS polling, system metrics
|
├── host/ # System metrics
|
||||||
├── http/ # Axum handlers (health, init, snapshot, files, encoding)
|
├── http/ # Axum handlers (health, init, snapshot, files, encoding)
|
||||||
├── permissions/ # Path resolution, user lookup, chown
|
├── permissions/ # Path resolution, user lookup, chown
|
||||||
├── rpc/ # Connect RPC services
|
├── rpc/ # Connect RPC services
|
||||||
@ -129,13 +128,15 @@ src/
|
|||||||
After building the static binary, copy it into the rootfs:
|
After building the static binary, copy it into the rootfs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash scripts/update-debug-rootfs.sh [rootfs_path]
|
bash scripts/update-minimal-rootfs.sh [rootfs_path]
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
With no argument it updates all four system base images; pass a path to target one.
|
||||||
|
|
||||||
|
Or manually (example path: the minimal-ubuntu image, platform team + template id 0):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mount -o loop /var/lib/wrenn/images/minimal.ext4 /mnt
|
sudo mount -o loop /var/lib/wrenn/images/teams/0000000000000000000000000/0000000000000000000000000/rootfs.ext4 /mnt
|
||||||
sudo cp target/x86_64-unknown-linux-musl/release/envd /mnt/usr/bin/envd
|
sudo cp target/x86_64-unknown-linux-musl/release/envd /mnt/usr/local/bin/envd
|
||||||
sudo umount /mnt
|
sudo umount /mnt
|
||||||
```
|
```
|
||||||
|
|||||||
@ -1,56 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::Request;
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::auth::token::SecureToken;
|
|
||||||
|
|
||||||
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
|
||||||
|
|
||||||
/// Paths excluded from general token auth.
|
|
||||||
/// Format: "METHOD/path"
|
|
||||||
const AUTH_EXCLUDED: &[&str] = &[
|
|
||||||
"GET/health",
|
|
||||||
"GET/files",
|
|
||||||
"POST/files",
|
|
||||||
"POST/init",
|
|
||||||
"POST/snapshot/prepare",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Axum middleware that checks X-Access-Token header.
|
|
||||||
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();
|
|
||||||
let key = format!("{method}{path}");
|
|
||||||
|
|
||||||
let is_excluded = AUTH_EXCLUDED.iter().any(|p| *p == key);
|
|
||||||
|
|
||||||
let header_val = request
|
|
||||||
.headers()
|
|
||||||
.get(ACCESS_TOKEN_HEADER)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if !access_token.equals(header_val) && !is_excluded {
|
|
||||||
tracing::error!("unauthorized access attempt");
|
|
||||||
return (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
axum::Json(json!({
|
|
||||||
"code": 401,
|
|
||||||
"message": "unauthorized access, please provide a valid access token or method signing if supported"
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next.run(request).await
|
|
||||||
}
|
|
||||||
@ -1,3 +1,2 @@
|
|||||||
pub mod token;
|
|
||||||
pub mod signing;
|
pub mod signing;
|
||||||
pub mod middleware;
|
pub mod token;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::auth::token::SecureToken;
|
use crate::auth::token::SecureToken;
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
@ -67,7 +69,9 @@ pub fn validate_signing(
|
|||||||
let expected = generate_signature(token, path, username, operation, signature_expiration)
|
let expected = generate_signature(token, path, username, operation, signature_expiration)
|
||||||
.map_err(|e| format!("error generating signing key: {e}"))?;
|
.map_err(|e| format!("error generating signing key: {e}"))?;
|
||||||
|
|
||||||
if expected != sig {
|
// Constant-time compare: both values are fixed-length `v1_<64 hex>` digests,
|
||||||
|
// so this matches the timing-side-channel posture of SecureToken::equals.
|
||||||
|
if expected.as_bytes().ct_eq(sig.as_bytes()).unwrap_u8() != 1 {
|
||||||
return Err("invalid signature".into());
|
return Err("invalid signature".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,3 +87,185 @@ pub fn validate_signing(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn test_token(val: &[u8]) -> SecureToken {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
t.set(val).unwrap();
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn far_future() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
+ 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_starts_with_v1() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let sig = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap();
|
||||||
|
assert!(sig.starts_with("v1_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_deterministic() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let s1 = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap();
|
||||||
|
let s2 = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap();
|
||||||
|
assert_eq!(s1, s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_with_expiration_differs() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let without = generate_signature(&token, "/f", "u", READ_OPERATION, None).unwrap();
|
||||||
|
let with = generate_signature(&token, "/f", "u", READ_OPERATION, Some(9999)).unwrap();
|
||||||
|
assert_ne!(without, with);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_unset_token_errors() {
|
||||||
|
let token = SecureToken::new();
|
||||||
|
assert!(generate_signature(&token, "/f", "u", READ_OPERATION, None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_no_token_set_passes() {
|
||||||
|
let token = SecureToken::new();
|
||||||
|
assert!(validate_signing(&token, None, None, None, "root", "/f", READ_OPERATION).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("does not match"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_valid_signature() {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("invalid signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_expired_signature() {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("expired"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_missing_signature() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let result = validate_signing(&token, None, None, None, "root", "/f", READ_OPERATION);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("missing signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_empty_header_token_falls_through_to_signature() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let result = validate_signing(&token, Some(""), None, None, "root", "/f", READ_OPERATION);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("missing signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_operations_produce_different_signatures() {
|
||||||
|
let token = test_token(b"secret");
|
||||||
|
let r = generate_signature(&token, "/f", "root", READ_OPERATION, None).unwrap();
|
||||||
|
let w = generate_signature(&token, "/f", "root", WRITE_OPERATION, None).unwrap();
|
||||||
|
assert_ne!(r, w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -125,3 +125,132 @@ impl SecureToken {
|
|||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_is_unset() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
assert!(!t.is_set());
|
||||||
|
assert!(!t.equals("anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_and_equals() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
t.set(b"secret").unwrap();
|
||||||
|
assert!(t.is_set());
|
||||||
|
assert!(t.equals("secret"));
|
||||||
|
assert!(!t.equals("wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_empty_errors() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
assert!(t.set(b"").is_err());
|
||||||
|
assert!(!t.is_set());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_overwrites_previous() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
t.set(b"first").unwrap();
|
||||||
|
t.set(b"second").unwrap();
|
||||||
|
assert!(!t.equals("first"));
|
||||||
|
assert!(t.equals("second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn destroy_clears() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
t.set(b"secret").unwrap();
|
||||||
|
t.destroy();
|
||||||
|
assert!(!t.is_set());
|
||||||
|
assert!(!t.equals("secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_returns_copy() {
|
||||||
|
let t = SecureToken::new();
|
||||||
|
assert!(t.bytes().is_none());
|
||||||
|
t.set(b"hello").unwrap();
|
||||||
|
assert_eq!(t.bytes().unwrap(), b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_transfers_and_clears_source() {
|
||||||
|
let src = SecureToken::new();
|
||||||
|
src.set(b"token").unwrap();
|
||||||
|
let dst = SecureToken::new();
|
||||||
|
dst.take_from(&src);
|
||||||
|
assert!(!src.is_set());
|
||||||
|
assert!(dst.equals("token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn take_from_overwrites_existing() {
|
||||||
|
let src = SecureToken::new();
|
||||||
|
src.set(b"new").unwrap();
|
||||||
|
let dst = SecureToken::new();
|
||||||
|
dst.set(b"old").unwrap();
|
||||||
|
dst.take_from(&src);
|
||||||
|
assert!(dst.equals("new"));
|
||||||
|
assert!(!dst.equals("old"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_secure_matching() {
|
||||||
|
let a = SecureToken::new();
|
||||||
|
a.set(b"same").unwrap();
|
||||||
|
let b = SecureToken::new();
|
||||||
|
b.set(b"same").unwrap();
|
||||||
|
assert!(a.equals_secure(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_secure_different() {
|
||||||
|
let a = SecureToken::new();
|
||||||
|
a.set(b"one").unwrap();
|
||||||
|
let b = SecureToken::new();
|
||||||
|
b.set(b"two").unwrap();
|
||||||
|
assert!(!a.equals_secure(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn equals_secure_unset() {
|
||||||
|
let a = SecureToken::new();
|
||||||
|
let b = SecureToken::new();
|
||||||
|
assert!(!a.equals_secure(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_json_bytes_valid() {
|
||||||
|
let mut data = b"\"mysecret\"".to_vec();
|
||||||
|
let t = SecureToken::from_json_bytes(&mut data).unwrap();
|
||||||
|
assert!(t.equals("mysecret"));
|
||||||
|
assert!(data.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_json_bytes_rejects_missing_quotes() {
|
||||||
|
let mut data = b"noquotes".to_vec();
|
||||||
|
assert!(SecureToken::from_json_bytes(&mut data).is_err());
|
||||||
|
assert!(data.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_json_bytes_rejects_escape_sequences() {
|
||||||
|
let mut data = b"\"has\\nescapes\"".to_vec();
|
||||||
|
assert!(SecureToken::from_json_bytes(&mut data).is_err());
|
||||||
|
assert!(data.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_json_bytes_rejects_empty_content() {
|
||||||
|
let mut data = b"\"\"".to_vec();
|
||||||
|
assert!(SecureToken::from_json_bytes(&mut data).is_err());
|
||||||
|
assert!(data.iter().all(|&b| b == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -19,20 +19,25 @@ pub struct Cgroup2Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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();
|
let mut fds = HashMap::new();
|
||||||
|
|
||||||
for (proc_type, sub_path, properties) in configs {
|
for (proc_type, sub_path, properties) in configs {
|
||||||
let full_path = PathBuf::from(root).join(sub_path);
|
let full_path = PathBuf::from(root).join(sub_path);
|
||||||
|
|
||||||
fs::create_dir_all(&full_path).map_err(|e| {
|
fs::create_dir_all(&full_path)
|
||||||
format!("failed to create cgroup {}: {e}", full_path.display())
|
.map_err(|e| format!("failed to create cgroup {}: {e}", full_path.display()))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
for (name, value) in *properties {
|
for (name, value) in *properties {
|
||||||
let prop_path = full_path.join(name);
|
let prop_path = full_path.join(name);
|
||||||
fs::write(&prop_path, value).map_err(|e| {
|
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()
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
envd-rs/src/cmd/mod.rs
Normal file
5
envd-rs/src/cmd/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
//! 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;
|
||||||
164
envd-rs/src/cmd/ports.rs
Normal file
164
envd-rs/src/cmd/ports.rs
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
//! `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,10 @@ pub const PORT_SCANNER_INTERVAL: Duration = Duration::from_millis(1000);
|
|||||||
pub const DEFAULT_USER: &str = "root";
|
pub const DEFAULT_USER: &str = "root";
|
||||||
pub const WRENN_RUN_DIR: &str = "/run/wrenn";
|
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 KILOBYTE: u64 = 1024;
|
||||||
pub const MEGABYTE: u64 = 1024 * KILOBYTE;
|
pub const MEGABYTE: u64 = 1024 * KILOBYTE;
|
||||||
|
|
||||||
pub const MMDS_ADDRESS: &str = "169.254.169.254";
|
|
||||||
pub const MMDS_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
pub const MMDS_TOKEN_EXPIRATION_SECS: u64 = 60;
|
|
||||||
pub const MMDS_ACCESS_TOKEN_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
|
|||||||
@ -1,24 +1,14 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// Tracks active TCP connections for snapshot/restore lifecycle.
|
/// Tracks active TCP connections.
|
||||||
///
|
|
||||||
/// Before snapshot: close idle connections, record active ones.
|
|
||||||
/// After restore: close all pre-snapshot connections (zombie TCP sockets).
|
|
||||||
///
|
|
||||||
/// In Rust/axum, we don't have Go's ConnState callback. Instead we track
|
|
||||||
/// connections via a tower middleware that registers connection IDs.
|
|
||||||
/// For the initial implementation, we track by a simple connection counter
|
|
||||||
/// and rely on axum's graceful shutdown mechanics.
|
|
||||||
pub struct ConnTracker {
|
pub struct ConnTracker {
|
||||||
inner: Mutex<ConnTrackerInner>,
|
inner: Mutex<ConnTrackerInner>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ConnTrackerInner {
|
struct ConnTrackerInner {
|
||||||
active: HashSet<u64>,
|
active: HashSet<u64>,
|
||||||
pre_snapshot: Option<HashSet<u64>>,
|
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
keepalives_enabled: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnTracker {
|
impl ConnTracker {
|
||||||
@ -26,9 +16,7 @@ impl ConnTracker {
|
|||||||
Self {
|
Self {
|
||||||
inner: Mutex::new(ConnTrackerInner {
|
inner: Mutex::new(ConnTrackerInner {
|
||||||
active: HashSet::new(),
|
active: HashSet::new(),
|
||||||
pre_snapshot: None,
|
|
||||||
next_id: 0,
|
next_id: 0,
|
||||||
keepalives_enabled: true,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,36 +32,39 @@ impl ConnTracker {
|
|||||||
pub fn remove_connection(&self, id: u64) {
|
pub fn remove_connection(&self, id: u64) {
|
||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
inner.active.remove(&id);
|
inner.active.remove(&id);
|
||||||
if let Some(ref mut pre) = inner.pre_snapshot {
|
|
||||||
pre.remove(&id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_for_snapshot(&self) {
|
#[cfg(test)]
|
||||||
let mut inner = self.inner.lock().unwrap();
|
fn active_count(&self) -> usize {
|
||||||
inner.keepalives_enabled = false;
|
self.inner.lock().unwrap().active.len()
|
||||||
inner.pre_snapshot = Some(inner.active.clone());
|
}
|
||||||
tracing::info!(
|
}
|
||||||
active_connections = inner.active.len(),
|
|
||||||
"snapshot: recorded pre-snapshot connections, keep-alives disabled"
|
#[cfg(test)]
|
||||||
);
|
mod tests {
|
||||||
}
|
use super::*;
|
||||||
|
|
||||||
pub fn restore_after_snapshot(&self) {
|
#[test]
|
||||||
let mut inner = self.inner.lock().unwrap();
|
fn register_assigns_sequential_ids() {
|
||||||
if let Some(pre) = inner.pre_snapshot.take() {
|
let ct = ConnTracker::new();
|
||||||
let zombie_count = pre.len();
|
assert_eq!(ct.register_connection(), 0);
|
||||||
for id in &pre {
|
assert_eq!(ct.register_connection(), 1);
|
||||||
inner.active.remove(id);
|
assert_eq!(ct.register_connection(), 2);
|
||||||
}
|
}
|
||||||
if zombie_count > 0 {
|
|
||||||
tracing::info!(zombie_count, "restore: closed zombie connections");
|
#[test]
|
||||||
}
|
fn remove_clears_active() {
|
||||||
}
|
let ct = ConnTracker::new();
|
||||||
inner.keepalives_enabled = true;
|
let id = ct.register_connection();
|
||||||
}
|
assert_eq!(ct.active_count(), 1);
|
||||||
|
ct.remove_connection(id);
|
||||||
pub fn keepalives_enabled(&self) -> bool {
|
assert_eq!(ct.active_count(), 0);
|
||||||
self.inner.lock().unwrap().keepalives_enabled
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_nonexistent_is_noop() {
|
||||||
|
let ct = ConnTracker::new();
|
||||||
|
ct.remove_connection(999);
|
||||||
|
assert_eq!(ct.active_count(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,8 +15,29 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hmac_sha256() {
|
fn rfc4231_tc1() {
|
||||||
let result = compute(b"key", b"message");
|
let key = &[0x0b; 20];
|
||||||
assert_eq!(result.len(), 64); // SHA-256 hex = 64 chars
|
let data = b"Hi There";
|
||||||
|
assert_eq!(
|
||||||
|
compute(key, data),
|
||||||
|
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc4231_tc2() {
|
||||||
|
let key = b"Jefe";
|
||||||
|
let data = b"what do ya want for nothing?";
|
||||||
|
assert_eq!(
|
||||||
|
compute(key, data),
|
||||||
|
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_is_64_hex_chars() {
|
||||||
|
let result = compute(b"key", b"data");
|
||||||
|
assert_eq!(result.len(), 64);
|
||||||
|
assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
|
pub mod hmac_sha256;
|
||||||
pub mod sha256;
|
pub mod sha256;
|
||||||
pub mod sha512;
|
pub mod sha512;
|
||||||
pub mod hmac_sha256;
|
|
||||||
|
|||||||
@ -17,17 +17,51 @@ pub fn hash_without_prefix(data: &[u8]) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const VECTORS: &[(&[u8], &str)] = &[
|
||||||
|
(b"", "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"),
|
||||||
|
(b"abc", "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"),
|
||||||
|
(
|
||||||
|
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||||
|
"JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hash_format() {
|
fn known_answer_with_prefix() {
|
||||||
let result = hash(b"test");
|
for (input, expected_b64) in VECTORS {
|
||||||
assert!(result.starts_with("$sha256$"));
|
let result = hash(input);
|
||||||
assert!(!result.contains('='));
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
format!("$sha256${expected_b64}"),
|
||||||
|
"input: {:?}",
|
||||||
|
String::from_utf8_lossy(input)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hash_without_prefix() {
|
fn known_answer_without_prefix() {
|
||||||
let result = hash_without_prefix(b"test");
|
for (input, expected_b64) in VECTORS {
|
||||||
assert!(!result.starts_with("$sha256$"));
|
let result = hash_without_prefix(input);
|
||||||
assert!(!result.contains('='));
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
*expected_b64,
|
||||||
|
"input: {:?}",
|
||||||
|
String::from_utf8_lossy(input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_base64_padding() {
|
||||||
|
for (input, _) in VECTORS {
|
||||||
|
assert!(!hash(input).contains('='));
|
||||||
|
assert!(!hash_without_prefix(input).contains('='));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deterministic() {
|
||||||
|
assert_eq!(hash(b"test"), hash(b"test"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,11 +14,45 @@ pub fn hash_access_token_bytes(token: &[u8]) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const VECTORS: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"",
|
||||||
|
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"abc",
|
||||||
|
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||||
|
"204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hash_access_token() {
|
fn known_answer() {
|
||||||
let h1 = hash_access_token("test");
|
for (input, expected) in VECTORS {
|
||||||
let h2 = hash_access_token_bytes(b"test");
|
assert_eq!(hash_access_token(input), *expected, "input: {input:?}");
|
||||||
assert_eq!(h1, h2);
|
}
|
||||||
assert_eq!(h1.len(), 128); // SHA-512 hex = 128 chars
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn str_and_bytes_agree() {
|
||||||
|
for (input, _) in VECTORS {
|
||||||
|
assert_eq!(
|
||||||
|
hash_access_token(input),
|
||||||
|
hash_access_token_bytes(input.as_bytes())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,36 @@
|
|||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Defaults {
|
pub struct Defaults {
|
||||||
pub env_vars: Arc<DashMap<String, String>>,
|
pub env_vars: Arc<DashMap<String, String>>,
|
||||||
pub user: String,
|
user: RwLock<String>,
|
||||||
pub workdir: Option<String>,
|
workdir: RwLock<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Defaults {
|
impl Defaults {
|
||||||
pub fn new(user: &str) -> Self {
|
pub fn new(user: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
env_vars: Arc::new(DashMap::new()),
|
env_vars: Arc::new(DashMap::new()),
|
||||||
user: user.to_string(),
|
user: RwLock::new(user.to_string()),
|
||||||
workdir: None,
|
workdir: RwLock::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn user(&self) -> String {
|
||||||
|
self.user.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_user(&self, user: String) {
|
||||||
|
*self.user.write().unwrap() = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workdir(&self) -> Option<String> {
|
||||||
|
self.workdir.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_workdir(&self, workdir: Option<String>) {
|
||||||
|
*self.workdir.write().unwrap() = workdir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_default_workdir(workdir: &str, default_workdir: Option<&str>) -> String {
|
pub fn resolve_default_workdir(workdir: &str, default_workdir: Option<&str>) -> String {
|
||||||
@ -40,3 +55,70 @@ pub fn resolve_default_username<'a>(
|
|||||||
}
|
}
|
||||||
Err("username not provided")
|
Err("username not provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_explicit_overrides_default() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_default_workdir("/explicit", Some("/default")),
|
||||||
|
"/explicit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_empty_uses_default() {
|
||||||
|
assert_eq!(resolve_default_workdir("", Some("/default")), "/default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_empty_no_default_returns_empty() {
|
||||||
|
assert_eq!(resolve_default_workdir("", None), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_explicit_ignores_none_default() {
|
||||||
|
assert_eq!(resolve_default_workdir("/explicit", None), "/explicit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_explicit_returns_explicit() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_default_username(Some("root"), "wrenn").unwrap(),
|
||||||
|
"root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_none_uses_default() {
|
||||||
|
assert_eq!(resolve_default_username(None, "wrenn").unwrap(), "wrenn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_none_empty_default_errors() {
|
||||||
|
assert!(resolve_default_username(None, "").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn username_some_overrides_empty_default() {
|
||||||
|
assert_eq!(resolve_default_username(Some("root"), "").unwrap(), "root");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_user_set_and_get() {
|
||||||
|
let d = Defaults::new("initial");
|
||||||
|
assert_eq!(d.user(), "initial");
|
||||||
|
d.set_user("changed".into());
|
||||||
|
assert_eq!(d.user(), "changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_workdir_initially_none() {
|
||||||
|
let d = Defaults::new("user");
|
||||||
|
assert!(d.workdir().is_none());
|
||||||
|
d.set_workdir(Some("/home".into()));
|
||||||
|
assert_eq!(d.workdir().unwrap(), "/home");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
use std::ffi::CString;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct Metrics {
|
|
||||||
pub ts: i64,
|
|
||||||
pub cpu_count: u32,
|
|
||||||
pub cpu_used_pct: f32,
|
|
||||||
pub mem_total_mib: u64,
|
|
||||||
pub mem_used_mib: u64,
|
|
||||||
pub mem_total: u64,
|
|
||||||
pub mem_used: u64,
|
|
||||||
pub disk_used: u64,
|
|
||||||
pub disk_total: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_metrics() -> Result<Metrics, String> {
|
|
||||||
use sysinfo::System;
|
|
||||||
|
|
||||||
let mut sys = System::new();
|
|
||||||
sys.refresh_memory();
|
|
||||||
sys.refresh_cpu_all();
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
||||||
sys.refresh_cpu_all();
|
|
||||||
|
|
||||||
let cpu_count = sys.cpus().len() as u32;
|
|
||||||
let cpu_used_pct = sys.global_cpu_usage();
|
|
||||||
let cpu_used_pct_rounded = if cpu_used_pct > 0.0 {
|
|
||||||
(cpu_used_pct * 100.0).round() / 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let mem_total = sys.total_memory();
|
|
||||||
let mem_used = sys.used_memory();
|
|
||||||
|
|
||||||
let (disk_total, disk_used) = disk_stats("/")?;
|
|
||||||
|
|
||||||
let ts = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
Ok(Metrics {
|
|
||||||
ts,
|
|
||||||
cpu_count,
|
|
||||||
cpu_used_pct: cpu_used_pct_rounded,
|
|
||||||
mem_total_mib: mem_total / 1024 / 1024,
|
|
||||||
mem_used_mib: mem_used / 1024 / 1024,
|
|
||||||
mem_total,
|
|
||||||
mem_used,
|
|
||||||
disk_used,
|
|
||||||
disk_total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn disk_stats(path: &str) -> Result<(u64, u64), String> {
|
|
||||||
let c_path = CString::new(path).unwrap();
|
|
||||||
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
|
|
||||||
let ret = unsafe { libc::statfs(c_path.as_ptr(), &mut stat) };
|
|
||||||
if ret != 0 {
|
|
||||||
return Err(format!("statfs failed: {}", std::io::Error::last_os_error()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let block = stat.f_bsize as u64;
|
|
||||||
let total = stat.f_blocks * block;
|
|
||||||
let available = stat.f_bavail * block;
|
|
||||||
|
|
||||||
Ok((total, total - available))
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use dashmap::DashMap;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crate::config::{MMDS_ADDRESS, MMDS_POLL_INTERVAL, MMDS_TOKEN_EXPIRATION_SECS, WRENN_RUN_DIR};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct MMDSOpts {
|
|
||||||
#[serde(rename = "instanceID")]
|
|
||||||
pub sandbox_id: String,
|
|
||||||
#[serde(rename = "envID")]
|
|
||||||
pub template_id: String,
|
|
||||||
#[serde(rename = "address", default)]
|
|
||||||
pub logs_collector_address: String,
|
|
||||||
#[serde(rename = "accessTokenHash", default)]
|
|
||||||
pub access_token_hash: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_mmds_token(client: &reqwest::Client) -> Result<String, String> {
|
|
||||||
let resp = client
|
|
||||||
.put(format!("http://{MMDS_ADDRESS}/latest/api/token"))
|
|
||||||
.header(
|
|
||||||
"X-metadata-token-ttl-seconds",
|
|
||||||
MMDS_TOKEN_EXPIRATION_SECS.to_string(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("mmds token request failed: {e}"))?;
|
|
||||||
|
|
||||||
let token = resp.text().await.map_err(|e| format!("mmds token read: {e}"))?;
|
|
||||||
if token.is_empty() {
|
|
||||||
return Err("mmds token is an empty string".into());
|
|
||||||
}
|
|
||||||
Ok(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_mmds_opts(client: &reqwest::Client, token: &str) -> Result<MMDSOpts, String> {
|
|
||||||
let resp = client
|
|
||||||
.get(format!("http://{MMDS_ADDRESS}"))
|
|
||||||
.header("X-metadata-token", token)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("mmds opts request failed: {e}"))?;
|
|
||||||
|
|
||||||
resp.json::<MMDSOpts>()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("mmds opts parse: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_access_token_hash() -> Result<String, String> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.no_proxy()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("http client: {e}"))?;
|
|
||||||
|
|
||||||
let token = get_mmds_token(&client).await?;
|
|
||||||
let opts = get_mmds_opts(&client, &token).await?;
|
|
||||||
Ok(opts.access_token_hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Polls MMDS every 50ms until metadata is available.
|
|
||||||
/// Stores sandbox_id and template_id in env_vars and writes to /run/wrenn/ files.
|
|
||||||
pub async fn poll_for_opts(
|
|
||||||
env_vars: Arc<DashMap<String, String>>,
|
|
||||||
cancel: CancellationToken,
|
|
||||||
) -> Option<MMDSOpts> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.no_proxy()
|
|
||||||
.build()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
let mut interval = tokio::time::interval(MMDS_POLL_INTERVAL);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = cancel.cancelled() => {
|
|
||||||
tracing::warn!("context cancelled while waiting for mmds opts");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
_ = interval.tick() => {
|
|
||||||
let token = match get_mmds_token(&client).await {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "mmds token poll");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let opts = match get_mmds_opts(&client, &token).await {
|
|
||||||
Ok(o) => o,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "mmds opts poll");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
env_vars.insert("WRENN_SANDBOX_ID".into(), opts.sandbox_id.clone());
|
|
||||||
env_vars.insert("WRENN_TEMPLATE_ID".into(), opts.template_id.clone());
|
|
||||||
|
|
||||||
let run_dir = std::path::Path::new(WRENN_RUN_DIR);
|
|
||||||
if let Err(e) = std::fs::create_dir_all(run_dir) {
|
|
||||||
tracing::error!(error = %e, "mmds: failed to create run dir");
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(run_dir.join(".WRENN_SANDBOX_ID"), &opts.sandbox_id) {
|
|
||||||
tracing::error!(error = %e, "mmds: failed to write .WRENN_SANDBOX_ID");
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(run_dir.join(".WRENN_TEMPLATE_ID"), &opts.template_id) {
|
|
||||||
tracing::error!(error = %e, "mmds: failed to write .WRENN_TEMPLATE_ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Some(opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
pub mod metrics;
|
|
||||||
pub mod mmds;
|
|
||||||
34
envd-rs/src/http/activity.rs
Normal file
34
envd-rs/src/http/activity.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@ -1,147 +0,0 @@
|
|||||||
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:?}"))
|
|
||||||
}
|
|
||||||
@ -18,8 +18,5 @@ pub async fn get_envs(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
|||||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
(
|
([(header::CACHE_CONTROL, "no-store")], Json(envs))
|
||||||
[(header::CACHE_CONTROL, "no-store")],
|
|
||||||
Json(envs),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,30 @@
|
|||||||
use std::io::Write as _;
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{FromRequest, Query, Request, State};
|
use axum::extract::{Query, Request, State};
|
||||||
use axum::http::{StatusCode, header};
|
use axum::http::{StatusCode, header};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use futures::StreamExt;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
use crate::auth::signing;
|
use crate::auth::signing;
|
||||||
use crate::execcontext;
|
use crate::execcontext;
|
||||||
use crate::http::encoding;
|
|
||||||
use crate::permissions::path::{ensure_dirs, expand_and_resolve};
|
use crate::permissions::path::{ensure_dirs, expand_and_resolve};
|
||||||
use crate::permissions::user::lookup_user;
|
use crate::permissions::user::lookup_user;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct FileParams {
|
pub struct FileParams {
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@ -62,7 +70,10 @@ fn validate_file_signing(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /files — download a file
|
/// 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.
|
||||||
pub async fn get_files(
|
pub async fn get_files(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<FileParams>,
|
Query(params): Query<FileParams>,
|
||||||
@ -71,13 +82,12 @@ pub async fn get_files(
|
|||||||
let path_str = params.path.as_deref().unwrap_or("");
|
let path_str = params.path.as_deref().unwrap_or("");
|
||||||
let header_token = extract_header_token(&req);
|
let header_token = extract_header_token(&req);
|
||||||
|
|
||||||
let username = match execcontext::resolve_default_username(
|
let default_user = state.defaults.user();
|
||||||
params.username.as_deref(),
|
let username =
|
||||||
&state.defaults.user,
|
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||||
) {
|
Ok(u) => u.to_string(),
|
||||||
Ok(u) => u.to_string(),
|
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = validate_file_signing(
|
if let Err(e) = validate_file_signing(
|
||||||
&state,
|
&state,
|
||||||
@ -96,13 +106,13 @@ pub async fn get_files(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let home_dir = user.dir.to_string_lossy().to_string();
|
let home_dir = user.dir.to_string_lossy().to_string();
|
||||||
let resolved = match expand_and_resolve(path_str, &home_dir, state.defaults.workdir.as_deref())
|
let default_workdir = state.defaults.workdir();
|
||||||
{
|
let resolved = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||||
};
|
};
|
||||||
|
|
||||||
let meta = match std::fs::metadata(&resolved) {
|
let meta = match tokio::fs::metadata(&resolved).await {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
return json_error(
|
return json_error(
|
||||||
@ -132,34 +142,12 @@ pub async fn get_files(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let accept_enc = match encoding::parse_accept_encoding(&req) {
|
let file = match tokio::fs::File::open(&resolved).await {
|
||||||
Ok(e) => e,
|
Ok(f) => f,
|
||||||
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) => {
|
Err(e) => {
|
||||||
return json_error(
|
return json_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
&format!("error reading file: {e}"),
|
&format!("error opening file: {e}"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -168,67 +156,42 @@ pub async fn get_files(
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let content_disposition = format!("inline; filename=\"{}\"", filename);
|
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 body = Body::from_stream(ReaderStream::new(file));
|
||||||
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()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, content_type)
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(header::CONTENT_DISPOSITION, content_disposition)
|
.header(header::CONTENT_DISPOSITION, content_disposition)
|
||||||
.header(header::VARY, "Accept-Encoding")
|
.header(header::CONTENT_LENGTH, meta.len())
|
||||||
.header(header::CONTENT_LENGTH, file_data.len())
|
.body(body)
|
||||||
.body(Body::from(file_data))
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /files — upload file(s) via multipart
|
/// PUT /files — upload a single file, streamed to disk.
|
||||||
pub async fn post_files(
|
///
|
||||||
|
/// 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(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<FileParams>,
|
Query(params): Query<FileParams>,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let path_str = params.path.as_deref().unwrap_or("");
|
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 header_token = extract_header_token(&req);
|
||||||
|
|
||||||
let username = match execcontext::resolve_default_username(
|
let default_user = state.defaults.user();
|
||||||
params.username.as_deref(),
|
let username =
|
||||||
&state.defaults.user,
|
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||||
) {
|
Ok(u) => u.to_string(),
|
||||||
Ok(u) => u.to_string(),
|
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = validate_file_signing(
|
if let Err(e) = validate_file_signing(
|
||||||
&state,
|
&state,
|
||||||
@ -249,107 +212,42 @@ pub async fn post_files(
|
|||||||
let home_dir = user.dir.to_string_lossy().to_string();
|
let home_dir = user.dir.to_string_lossy().to_string();
|
||||||
let uid = user.uid;
|
let uid = user.uid;
|
||||||
let gid = user.gid;
|
let gid = user.gid;
|
||||||
|
let default_workdir = state.defaults.workdir();
|
||||||
|
|
||||||
let content_enc = match encoding::parse_content_encoding(&req) {
|
let file_path = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||||
Ok(e) => e,
|
Ok(p) => p,
|
||||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut multipart = match axum::extract::Multipart::from_request(req, &()).await {
|
if let Err((status, msg)) = stream_to_file(req.into_body(), &file_path, uid, gid).await {
|
||||||
Ok(m) => m,
|
return json_error(status, &msg);
|
||||||
Err(e) => {
|
|
||||||
return json_error(
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
&format!("error parsing multipart: {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut uploaded: Vec<EntryInfo> = Vec::new();
|
|
||||||
|
|
||||||
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, state.defaults.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, state.defaults.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",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
axum::Json(uploaded).into_response()
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_file(
|
/// 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,
|
||||||
path: &str,
|
path: &str,
|
||||||
data: &[u8],
|
|
||||||
uid: nix::unistd::Uid,
|
uid: nix::unistd::Uid,
|
||||||
gid: nix::unistd::Gid,
|
gid: nix::unistd::Gid,
|
||||||
) -> Result<(), (StatusCode, String)> {
|
) -> Result<(), (StatusCode, String)> {
|
||||||
let dir = Path::new(path)
|
let target = Path::new(path);
|
||||||
|
|
||||||
|
let dir = target
|
||||||
.parent()
|
.parent()
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@ -363,68 +261,34 @@ fn process_file(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let can_pre_chown = match std::fs::metadata(path) {
|
// Reject writing over an existing directory before staging anything.
|
||||||
Ok(meta) => {
|
match std::fs::metadata(path) {
|
||||||
if meta.is_dir() {
|
Ok(meta) if meta.is_dir() => {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
format!("path is a directory: {path}"),
|
format!("path is a directory: {path}"),
|
||||||
));
|
));
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
Ok(_) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("error getting file info: {e}"),
|
format!("error getting file info: {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 mut chowned = false;
|
let map_open_err = |e: std::io::Error| {
|
||||||
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}"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o666)
|
|
||||||
.open(path)
|
|
||||||
.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 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.write_all(data).map_err(|e| {
|
|
||||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||||
return (
|
return (
|
||||||
StatusCode::INSUFFICIENT_STORAGE,
|
StatusCode::INSUFFICIENT_STORAGE,
|
||||||
@ -433,11 +297,81 @@ fn process_file(
|
|||||||
}
|
}
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
format!("error writing file: {e}"),
|
format!("error opening file: {e}"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let std_file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
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| {
|
||||||
|
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 writing file: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.flush().await.map_err(|e| {
|
||||||
|
(
|
||||||
|
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}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
@ -10,14 +9,6 @@ use serde_json::json;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub async fn get_health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
pub async fn get_health(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
if state
|
|
||||||
.needs_restore
|
|
||||||
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
post_restore_recovery(&state);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::trace!("health check");
|
tracing::trace!("health check");
|
||||||
|
|
||||||
(
|
(
|
||||||
@ -25,15 +16,3 @@ pub async fn get_health(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
|||||||
Json(json!({ "version": state.version })),
|
Json(json!({ "version": state.version })),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_restore_recovery(state: &AppState) {
|
|
||||||
tracing::info!("restore: post-restore recovery (no GC needed in Rust)");
|
|
||||||
|
|
||||||
state.conn_tracker.restore_after_snapshot();
|
|
||||||
tracing::info!("restore: zombie connections closed");
|
|
||||||
|
|
||||||
if let Some(ref ps) = state.port_subsystem {
|
|
||||||
ps.restart();
|
|
||||||
tracing::info!("restore: port subsystem restarted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
@ -8,29 +7,33 @@ use axum::http::{StatusCode, header};
|
|||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::host::mmds;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct InitRequest {
|
pub struct InitRequest {
|
||||||
|
#[serde(rename = "access_token")]
|
||||||
pub access_token: Option<String>,
|
pub access_token: Option<String>,
|
||||||
|
#[serde(rename = "defaultUser")]
|
||||||
pub default_user: Option<String>,
|
pub default_user: Option<String>,
|
||||||
|
#[serde(rename = "defaultWorkdir")]
|
||||||
pub default_workdir: Option<String>,
|
pub default_workdir: Option<String>,
|
||||||
|
#[serde(rename = "envVars")]
|
||||||
pub env_vars: Option<HashMap<String, String>>,
|
pub env_vars: Option<HashMap<String, String>>,
|
||||||
|
#[serde(rename = "hyperloop_ip")]
|
||||||
pub hyperloop_ip: Option<String>,
|
pub hyperloop_ip: Option<String>,
|
||||||
pub timestamp: Option<String>,
|
pub timestamp: Option<String>,
|
||||||
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: the port
|
||||||
|
/// forwarder is restarted and the clock is stepped.
|
||||||
|
pub lifecycle_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// POST /init — called by host agent after boot.
|
||||||
pub struct VolumeMount {
|
|
||||||
pub nfs_target: String,
|
|
||||||
pub path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /init — called by host agent after boot and after every resume.
|
|
||||||
pub async fn post_init(
|
pub async fn post_init(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
body: Option<Json<InitRequest>>,
|
body: Option<Json<InitRequest>>,
|
||||||
@ -45,12 +48,88 @@ pub async fn post_init(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent timestamp check
|
// Post-resume lifecycle hook: restart port forwarder so socat children
|
||||||
|
// are reaped + respawned against the new wall clock and any rotated
|
||||||
|
// listeners. Must run BEFORE the stale-timestamp early-return so a
|
||||||
|
// resume with an out-of-order timestamp still refreshes the subsystem.
|
||||||
|
let lifecycle_changed = if let Some(ref new_id) = init_req.lifecycle_id {
|
||||||
|
state.bump_lifecycle(new_id)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if lifecycle_changed {
|
||||||
|
// Each new lifecycle (i.e. a snapshot restore) requires a fresh memory
|
||||||
|
// preload pass — pages materialised before the previous pause are now
|
||||||
|
// back in the source memory-ranges file as the host re-restored them
|
||||||
|
// lazily. Reset the flags so the next POST /memory/preload kicks off
|
||||||
|
// a new loader instead of returning the stale "already-done".
|
||||||
|
//
|
||||||
|
// The generation bump + reset happen under the error mutex — the same
|
||||||
|
// critical section a loader thread publishes its result in. A loader
|
||||||
|
// from the PREVIOUS lifecycle can be frozen mid-walk by the pause and
|
||||||
|
// thaw here; the bump makes it stop and discard its result instead of
|
||||||
|
// storing a stale done=true that the host would trust for the next
|
||||||
|
// snapshot. cancel is deliberately cleared (not set): the stale thread
|
||||||
|
// stops on generation mismatch, and the new run must start uncancelled.
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
{
|
||||||
|
let mut err = state.mem_preload_error.lock().unwrap();
|
||||||
|
state.mem_preload_generation.fetch_add(1, Ordering::SeqCst);
|
||||||
|
state.mem_preload_cancel.store(false, Ordering::SeqCst);
|
||||||
|
state.mem_preload_started.store(false, Ordering::SeqCst);
|
||||||
|
state.reset_preload_run(&mut err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref port_sub) = state.port_subsystem {
|
||||||
|
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.
|
||||||
|
tokio::spawn(async {
|
||||||
|
match tokio::process::Command::new("chronyc")
|
||||||
|
.args(["makestep"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
tracing::info!("chronyc makestep ok");
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
tracing::warn!(stderr = %stderr, "chronyc makestep failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "chronyc makestep spawn failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
if let Some(ref ts_str) = init_req.timestamp {
|
if let Some(ref ts_str) = init_req.timestamp {
|
||||||
if let Ok(ts) = chrono_parse_to_nanos(ts_str) {
|
if let Ok(ts) = parse_timestamp_to_nanos(ts_str) {
|
||||||
if !state.last_set_time.set_to_greater(ts) {
|
if !state.last_set_time.set_to_greater(ts) {
|
||||||
// Stale request, skip data updates
|
return (
|
||||||
return trigger_restore_and_respond(&state).await;
|
StatusCode::NO_CONTENT,
|
||||||
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,66 +157,58 @@ pub async fn post_init(
|
|||||||
if let Some(ref user) = init_req.default_user {
|
if let Some(ref user) = init_req.default_user {
|
||||||
if !user.is_empty() {
|
if !user.is_empty() {
|
||||||
tracing::debug!(user = %user, "setting default user");
|
tracing::debug!(user = %user, "setting default user");
|
||||||
let mut defaults = state.defaults.clone();
|
state.defaults.set_user(user.clone());
|
||||||
defaults.user = user.clone();
|
|
||||||
// Note: In Rust we'd need interior mutability for this.
|
|
||||||
// For now, env_vars (DashMap) handles concurrent access.
|
|
||||||
// User/workdir mutation deferred to full state refactor.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hyperloop /etc/hosts setup
|
// Set default workdir
|
||||||
|
if let Some(ref workdir) = init_req.default_workdir {
|
||||||
|
if !workdir.is_empty() {
|
||||||
|
tracing::debug!(workdir = %workdir, "setting default workdir");
|
||||||
|
state.defaults.set_workdir(Some(workdir.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hyperloop /etc/hosts setup. Awaited so callers that immediately
|
||||||
|
// resolve events.wrenn.local see the entry. Cheap (two file ops).
|
||||||
if let Some(ref ip) = init_req.hyperloop_ip {
|
if let Some(ref ip) = init_req.hyperloop_ip {
|
||||||
let ip = ip.clone();
|
setup_hyperloop(ip, &state.defaults.env_vars).await;
|
||||||
let env_vars = Arc::clone(&state.defaults.env_vars);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
setup_hyperloop(&ip, &env_vars).await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NFS mounts
|
// Set sandbox/template metadata from request body. Deliberately NOT
|
||||||
if let Some(ref mounts) = init_req.volume_mounts {
|
// written into envd's own process environment: std::env::set_var is
|
||||||
for mount in mounts {
|
// undefined behavior with the multi-threaded runtime live (concurrent
|
||||||
let target = mount.nfs_target.clone();
|
// getenv from any thread races the environ rewrite), and nothing needs
|
||||||
let path = mount.path.clone();
|
// it there — spawned processes get these via defaults.env_vars, and
|
||||||
tokio::spawn(async move {
|
// out-of-band consumers (e.g. the `envd ports` subcommand's
|
||||||
setup_nfs(&target, &path).await;
|
// read_identity) fall back to the run files.
|
||||||
});
|
if let Some(ref id) = init_req.sandbox_id {
|
||||||
|
tracing::debug!(sandbox_id = %id, "setting sandbox ID from init request");
|
||||||
|
write_run_file(".WRENN_SANDBOX_ID", id);
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
write_run_file(".WRENN_PROXY_DOMAIN", domain);
|
||||||
|
state
|
||||||
|
.defaults
|
||||||
|
.env_vars
|
||||||
|
.insert("WRENN_PROXY_DOMAIN".into(), domain.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-poll MMDS in background
|
|
||||||
if state.is_fc {
|
|
||||||
let env_vars = Arc::clone(&state.defaults.env_vars);
|
|
||||||
let cancel = tokio_util::sync::CancellationToken::new();
|
|
||||||
let cancel_clone = cancel.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
|
||||||
mmds::poll_for_opts(env_vars, cancel_clone).await;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger_restore_and_respond(&state).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn trigger_restore_and_respond(state: &AppState) -> axum::response::Response {
|
|
||||||
// Safety net: if health check's postRestoreRecovery hasn't run yet
|
|
||||||
if state
|
|
||||||
.needs_restore
|
|
||||||
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Relaxed)
|
|
||||||
.is_ok()
|
|
||||||
{
|
|
||||||
post_restore_recovery(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.conn_tracker.restore_after_snapshot();
|
|
||||||
if let Some(ref ps) = state.port_subsystem {
|
|
||||||
ps.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::NO_CONTENT,
|
StatusCode::NO_CONTENT,
|
||||||
[(header::CACHE_CONTROL, "no-store")],
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
@ -145,43 +216,16 @@ async fn trigger_restore_and_respond(state: &AppState) -> axum::response::Respon
|
|||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_restore_recovery(state: &AppState) {
|
|
||||||
tracing::info!("restore: post-restore recovery (no GC needed in Rust)");
|
|
||||||
state.conn_tracker.restore_after_snapshot();
|
|
||||||
|
|
||||||
if let Some(ref ps) = state.port_subsystem {
|
|
||||||
ps.restart();
|
|
||||||
tracing::info!("restore: port subsystem restarted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn validate_init_access_token(state: &AppState, request_token: &str) -> Result<(), String> {
|
async fn validate_init_access_token(state: &AppState, request_token: &str) -> Result<(), String> {
|
||||||
// Fast path: matches existing token
|
// 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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check MMDS hash
|
// First-time setup: no existing token
|
||||||
if state.is_fc {
|
|
||||||
if let Ok(mmds_hash) = mmds::get_access_token_hash().await {
|
|
||||||
if !mmds_hash.is_empty() {
|
|
||||||
if request_token.is_empty() {
|
|
||||||
let empty_hash = crypto::sha512::hash_access_token("");
|
|
||||||
if mmds_hash == empty_hash {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let token_hash = crypto::sha512::hash_access_token(request_token);
|
|
||||||
if mmds_hash == token_hash {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Err("access token validation failed".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-time setup: no existing token and no MMDS
|
|
||||||
if !state.access_token.is_set() {
|
if !state.access_token.is_set() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@ -194,6 +238,14 @@ async fn validate_init_access_token(state: &AppState, request_token: &str) -> Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, String>) {
|
async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, String>) {
|
||||||
|
// Reject anything that is not a bare IP address before it reaches
|
||||||
|
// /etc/hosts. Without this, a newline in `address` would inject arbitrary
|
||||||
|
// additional host entries into the file.
|
||||||
|
if address.parse::<std::net::IpAddr>().is_err() {
|
||||||
|
tracing::error!(%address, "hyperloop address is not a valid IP; skipping /etc/hosts entry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Write to /etc/hosts: events.wrenn.local → address
|
// Write to /etc/hosts: events.wrenn.local → address
|
||||||
let entry = format!("{address} events.wrenn.local\n");
|
let entry = format!("{address} events.wrenn.local\n");
|
||||||
|
|
||||||
@ -216,59 +268,53 @@ async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, Stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
env_vars.insert(
|
env_vars.insert("WRENN_EVENTS_ADDRESS".into(), format!("http://{address}"));
|
||||||
"WRENN_EVENTS_ADDRESS".into(),
|
|
||||||
format!("http://{address}"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn setup_nfs(nfs_target: &str, path: &str) {
|
fn write_run_file(name: &str, value: &str) {
|
||||||
let mkdir = tokio::process::Command::new("mkdir")
|
let dir = std::path::Path::new(crate::config::WRENN_RUN_DIR);
|
||||||
.args(["-p", path])
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
.output()
|
tracing::warn!(error = %e, "failed to create /run/wrenn");
|
||||||
.await;
|
|
||||||
if let Err(e) = mkdir {
|
|
||||||
tracing::error!(error = %e, path, "nfs: mkdir failed");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Err(e) = std::fs::write(dir.join(name), value) {
|
||||||
let mount = tokio::process::Command::new("mount")
|
tracing::warn!(error = %e, name, "failed to write run file");
|
||||||
.args([
|
|
||||||
"-v",
|
|
||||||
"-t",
|
|
||||||
"nfs",
|
|
||||||
"-o",
|
|
||||||
"mountproto=tcp,mountport=2049,proto=tcp,port=2049,nfsvers=3,noacl",
|
|
||||||
nfs_target,
|
|
||||||
path,
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match mount {
|
|
||||||
Ok(output) => {
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
if output.status.success() {
|
|
||||||
tracing::info!(nfs_target, path, stdout = %stdout, "nfs: mount success");
|
|
||||||
} else {
|
|
||||||
tracing::error!(nfs_target, path, stderr = %stderr, "nfs: mount failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, nfs_target, path, "nfs: mount command failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chrono_parse_to_nanos(ts: &str) -> Result<i64, ()> {
|
/// Hard-steps CLOCK_REALTIME to `nanos` since the Unix epoch. Requires
|
||||||
// Parse RFC3339 timestamp to nanoseconds since epoch
|
/// CAP_SYS_TIME, which envd has as PID 1 in the guest. Best effort — on
|
||||||
// Simple approach: parse as seconds + fractional
|
/// failure the clock is left for chrony to discipline against the PHC.
|
||||||
let secs = ts.parse::<f64>().ok();
|
// libc::time_t is deprecated pending musl 1.2's 64-bit switch, but the
|
||||||
if let Some(s) = secs {
|
// timespec.tv_sec field is still typed as time_t on this target.
|
||||||
return Ok((s * 1_000_000_000.0) as i64);
|
#[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).
|
||||||
|
fn parse_timestamp_to_nanos(ts: &str) -> Result<i64, ()> {
|
||||||
|
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) {
|
||||||
|
return Ok(parsed.timestamp_nanos_opt().ok_or(())?);
|
||||||
|
}
|
||||||
|
if let Ok(secs) = ts.parse::<f64>() {
|
||||||
|
return Ok((secs * 1_000_000_000.0) as i64);
|
||||||
}
|
}
|
||||||
// Try RFC3339 format
|
|
||||||
// For now, fall back to allowing the update
|
|
||||||
Err(())
|
Err(())
|
||||||
}
|
}
|
||||||
|
|||||||
616
envd-rs/src/http/memory.rs
Normal file
616
envd-rs/src/http/memory.rs
Normal file
@ -0,0 +1,616 @@
|
|||||||
|
// POST /memory/preload — guest-side helper that materialises every physical
|
||||||
|
// RAM page so a subsequent ch.snapshot writes a self-contained memory-ranges
|
||||||
|
// file. Required after a restore with memory_restore_mode=ondemand: pages
|
||||||
|
// that were never demand-faulted live only in the source memory-ranges file
|
||||||
|
// and would become holes (read back as zero) in the new snapshot.
|
||||||
|
//
|
||||||
|
// Trigger is one-byte-per-page reads through /dev/mem (fallback /proc/kcore
|
||||||
|
// PT_LOAD segments). The guest kernel walks its direct map → accesses the
|
||||||
|
// physical page → host kernel handles the EPT entry → CH's userfaultfd
|
||||||
|
// handler fills the page from the source memory-ranges file.
|
||||||
|
//
|
||||||
|
// Wire protocol:
|
||||||
|
// POST /memory/preload — starts the loader (idempotent while a run
|
||||||
|
// is in flight or completed successfully;
|
||||||
|
// restarts a run that ended failed/cancelled)
|
||||||
|
// and returns the current status JSON
|
||||||
|
// immediately
|
||||||
|
// GET /memory/preload — returns the current status JSON
|
||||||
|
// POST /memory/preload/cancel — signals the loader to stop early
|
||||||
|
//
|
||||||
|
// Returning immediately avoids any HTTP-level header timeout in the caller
|
||||||
|
// while materialisation (hundreds of MiB at one byte per page) runs in a
|
||||||
|
// background blocking thread.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const PAGE_SIZE: u64 = 4096;
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct PreloadStatus {
|
||||||
|
/// One of: "idle", "running", "done", "failed", "cancelled".
|
||||||
|
pub state: &'static str,
|
||||||
|
pub regions: u64,
|
||||||
|
pub pages: u64,
|
||||||
|
pub bytes: u64,
|
||||||
|
pub elapsed_sec: f64,
|
||||||
|
pub source: &'static str,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_memory_preload(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
// First caller wins the CAS and spawns the loader; subsequent callers
|
||||||
|
// just report the existing status — EXCEPT when the previous run finished
|
||||||
|
// in failed/cancelled state, which may be retried. Without the retry, one
|
||||||
|
// transient failure would block the host's pause/snapshot gate until the
|
||||||
|
// next /init (which never comes while the sandbox stays running).
|
||||||
|
//
|
||||||
|
// Both the fresh-run reset and the retry check run under the error mutex
|
||||||
|
// — the same critical section /init's reset and the loader's result
|
||||||
|
// publication use — so concurrent POSTs cannot both claim a retry and a
|
||||||
|
// frozen /init cannot interleave.
|
||||||
|
// The generation MUST be captured inside the same mutex-held block as the
|
||||||
|
// CAS/reset: loading it after the lock drops lets an interleaving /init
|
||||||
|
// (bump + started=false) tag this loader with the NEW generation, and a
|
||||||
|
// follow-up POST would then spawn a second concurrent full-RAM walk.
|
||||||
|
let start_generation = {
|
||||||
|
let mut err = state.mem_preload_error.lock().unwrap();
|
||||||
|
let fresh = state
|
||||||
|
.mem_preload_started
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok();
|
||||||
|
let retry = !fresh
|
||||||
|
&& state.mem_preload_done.load(Ordering::SeqCst)
|
||||||
|
&& (err.is_some() || state.mem_preload_cancel.load(Ordering::SeqCst));
|
||||||
|
if fresh || retry {
|
||||||
|
// Clear leftovers (previous lifecycle's or failed run's result) so
|
||||||
|
// a stale done=true can't masquerade as this run's completion.
|
||||||
|
state.reset_preload_run(&mut err);
|
||||||
|
state.mem_preload_cancel.store(false, Ordering::SeqCst);
|
||||||
|
Some(state.mem_preload_generation.load(Ordering::SeqCst))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(generation) = start_generation {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
// Detached blocking thread — no axum task lifetime ties it to the
|
||||||
|
// request, so the connection can close immediately without aborting
|
||||||
|
// materialisation. Lifecycle bump on the next /init clears the flags
|
||||||
|
// for a fresh run after a restore; this thread stops (and refuses to
|
||||||
|
// publish) once the generation it captured is no longer current — a
|
||||||
|
// pause can freeze it mid-walk and thaw it in the NEXT lifecycle.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let started = Instant::now();
|
||||||
|
let outcome = preload_blocking(&state_clone, generation);
|
||||||
|
|
||||||
|
// Publish under the error mutex so a concurrent /init bump can't
|
||||||
|
// interleave: either this store lands before the bump (and /init
|
||||||
|
// resets it), or the generation no longer matches and the result
|
||||||
|
// is discarded.
|
||||||
|
let mut err = state_clone.mem_preload_error.lock().unwrap();
|
||||||
|
if state_clone.mem_preload_generation.load(Ordering::SeqCst) != generation {
|
||||||
|
tracing::info!(
|
||||||
|
generation,
|
||||||
|
"memory preload result discarded: stale lifecycle"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok((source, regions, pages, bytes)) => {
|
||||||
|
let elapsed = started.elapsed().as_secs_f64();
|
||||||
|
state_clone
|
||||||
|
.mem_preload_regions
|
||||||
|
.store(regions, Ordering::SeqCst);
|
||||||
|
state_clone.mem_preload_pages.store(pages, Ordering::SeqCst);
|
||||||
|
state_clone.mem_preload_bytes.store(bytes, Ordering::SeqCst);
|
||||||
|
state_clone
|
||||||
|
.mem_preload_elapsed_us
|
||||||
|
.store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst);
|
||||||
|
set_source(&state_clone, source);
|
||||||
|
*err = None;
|
||||||
|
state_clone.mem_preload_done.store(true, Ordering::SeqCst);
|
||||||
|
tracing::info!(
|
||||||
|
regions,
|
||||||
|
pages,
|
||||||
|
bytes,
|
||||||
|
elapsed_sec = elapsed,
|
||||||
|
source,
|
||||||
|
"memory preload complete"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let elapsed = started.elapsed().as_secs_f64();
|
||||||
|
state_clone
|
||||||
|
.mem_preload_elapsed_us
|
||||||
|
.store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst);
|
||||||
|
*err = Some(e.clone());
|
||||||
|
state_clone.mem_preload_done.store(true, Ordering::SeqCst);
|
||||||
|
tracing::warn!(error = %e, "memory preload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = read_status(&state);
|
||||||
|
(StatusCode::OK, Json(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_memory_preload(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
(StatusCode::OK, Json(read_status(&state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_memory_preload_cancel(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
state.mem_preload_cancel.store(true, Ordering::SeqCst);
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_status(state: &AppState) -> PreloadStatus {
|
||||||
|
let started = state.mem_preload_started.load(Ordering::SeqCst);
|
||||||
|
let done = state.mem_preload_done.load(Ordering::SeqCst);
|
||||||
|
let cancelled = state.mem_preload_cancel.load(Ordering::SeqCst);
|
||||||
|
let error = state.mem_preload_error.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let lane = if !started {
|
||||||
|
"idle"
|
||||||
|
} else if !done {
|
||||||
|
"running"
|
||||||
|
} else if let Some(_) = &error {
|
||||||
|
"failed"
|
||||||
|
} else if cancelled {
|
||||||
|
"cancelled"
|
||||||
|
} else {
|
||||||
|
"done"
|
||||||
|
};
|
||||||
|
|
||||||
|
PreloadStatus {
|
||||||
|
state: lane,
|
||||||
|
regions: state.mem_preload_regions.load(Ordering::SeqCst),
|
||||||
|
pages: state.mem_preload_pages.load(Ordering::SeqCst),
|
||||||
|
bytes: state.mem_preload_bytes.load(Ordering::SeqCst),
|
||||||
|
elapsed_sec: state.mem_preload_elapsed_us.load(Ordering::SeqCst) as f64 / 1_000_000.0,
|
||||||
|
source: get_source(state),
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_source(state: &AppState, src: &'static str) {
|
||||||
|
let code: u8 = match src {
|
||||||
|
"/dev/mem" => 1,
|
||||||
|
"/proc/kcore" => 2,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
state.mem_preload_source.store(code, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_source(state: &AppState) -> &'static str {
|
||||||
|
match state.mem_preload_source.load(Ordering::SeqCst) {
|
||||||
|
1 => "/dev/mem",
|
||||||
|
2 => "/proc/kcore",
|
||||||
|
_ => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preload_blocking(
|
||||||
|
state: &AppState,
|
||||||
|
generation: u64,
|
||||||
|
) -> Result<(&'static str, u64, u64, u64), String> {
|
||||||
|
let ranges = parse_system_ram_ranges().map_err(|e| format!("iomem: {e}"))?;
|
||||||
|
if ranges.is_empty() {
|
||||||
|
return Err("no System RAM ranges found in /proc/iomem".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pages: u64 = 0;
|
||||||
|
let mut bytes: u64 = 0;
|
||||||
|
|
||||||
|
match preload_via_devmem(&ranges, state, generation, &mut pages, &mut bytes) {
|
||||||
|
Ok(()) => Ok(("/dev/mem", ranges.len() as u64, pages, bytes)),
|
||||||
|
Err(devmem_err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %devmem_err,
|
||||||
|
"/dev/mem preload failed, falling back to /proc/kcore"
|
||||||
|
);
|
||||||
|
pages = 0;
|
||||||
|
bytes = 0;
|
||||||
|
preload_via_kcore(state, generation, &mut pages, &mut bytes)
|
||||||
|
.map_err(|e| format!("/dev/mem: {devmem_err}; /proc/kcore: {e}"))?;
|
||||||
|
Ok(("/proc/kcore", ranges.len() as u64, pages, bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// should_stop reports whether the walk must abort: an explicit cancel, or the
|
||||||
|
// lifecycle moved on (a pause froze this thread and a resume /init bumped the
|
||||||
|
// generation — continuing would fault pages nobody waits for and eventually
|
||||||
|
// publish a stale result).
|
||||||
|
fn should_stop(state: &AppState, generation: u64) -> bool {
|
||||||
|
state.mem_preload_cancel.load(Ordering::SeqCst)
|
||||||
|
|| state.mem_preload_generation.load(Ordering::SeqCst) != generation
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_system_ram_ranges() -> std::io::Result<Vec<(u64, u64)>> {
|
||||||
|
let data = fs::read_to_string("/proc/iomem")?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for line in data.lines() {
|
||||||
|
if line.starts_with(|c: char| c.is_whitespace()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((range, label)) = line.split_once(" : ") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if label.trim() != "System RAM" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((start, end)) = range.split_once('-') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start = u64::from_str_radix(start.trim(), 16).ok();
|
||||||
|
let end = u64::from_str_radix(end.trim(), 16).ok();
|
||||||
|
if let (Some(s), Some(e)) = (start, end) {
|
||||||
|
out.push((s, e.saturating_add(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preload_via_devmem(
|
||||||
|
ranges: &[(u64, u64)],
|
||||||
|
state: &AppState,
|
||||||
|
generation: u64,
|
||||||
|
pages: &mut u64,
|
||||||
|
bytes: &mut u64,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let f = fs::File::open("/dev/mem")?;
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
for (start, end) in ranges {
|
||||||
|
let mut off = *start;
|
||||||
|
while off < *end {
|
||||||
|
if should_stop(state, generation) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
f.read_at(&mut buf, off)?;
|
||||||
|
*pages += 1;
|
||||||
|
*bytes += PAGE_SIZE;
|
||||||
|
// Publish progress so GET /memory/preload reports useful numbers
|
||||||
|
// while the loader is still running.
|
||||||
|
if *pages % 1024 == 0 {
|
||||||
|
state.mem_preload_pages.store(*pages, Ordering::SeqCst);
|
||||||
|
state.mem_preload_bytes.store(*bytes, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
off = off.saturating_add(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read physical RAM through /proc/kcore's per-range direct-map segments.
|
||||||
|
//
|
||||||
|
// The kernel emits one KCORE_RAM PT_LOAD *per contiguous System RAM range*
|
||||||
|
// (walk_system_ram_range), each with vaddr = direct_map_base + phys_start and
|
||||||
|
// file_size = range size — there is NO single segment covering all of RAM.
|
||||||
|
// Segments must therefore be matched range-by-range against /proc/iomem;
|
||||||
|
// picking "a big kernel-space segment" instead lands on the vmalloc or
|
||||||
|
// vmemmap region, which the kernel zero-fills for unmapped addresses without
|
||||||
|
// ever touching a physical page — the reads "succeed" instantly and nothing
|
||||||
|
// is materialised. Matching failure is a hard error for the same reason:
|
||||||
|
// reading the wrong segment is indistinguishable from success.
|
||||||
|
fn preload_via_kcore(
|
||||||
|
state: &AppState,
|
||||||
|
generation: u64,
|
||||||
|
pages: &mut u64,
|
||||||
|
bytes: &mut u64,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let ram_ranges = parse_system_ram_ranges()?;
|
||||||
|
if ram_ranges.is_empty() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"no System RAM ranges to bound kcore walk",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut f = fs::File::open("/proc/kcore")?;
|
||||||
|
let segments = parse_kcore_pt_load(&mut f)?;
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"no PT_LOAD segments in /proc/kcore",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ram_segments = match_ram_segments(&ram_ranges, &segments)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
let mut buf = [0u8; 1];
|
||||||
|
let mut read_errors: u64 = 0;
|
||||||
|
for seg in &ram_segments {
|
||||||
|
let end = seg.file_offset.saturating_add(seg.len);
|
||||||
|
let mut off = seg.file_offset;
|
||||||
|
while off < end {
|
||||||
|
if should_stop(state, generation) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match f.read_at(&mut buf, off) {
|
||||||
|
Ok(_) => {
|
||||||
|
*pages += 1;
|
||||||
|
*bytes += PAGE_SIZE;
|
||||||
|
if *pages % 256 == 0 {
|
||||||
|
state.mem_preload_pages.store(*pages, Ordering::SeqCst);
|
||||||
|
state.mem_preload_bytes.store(*bytes, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => read_errors += 1,
|
||||||
|
}
|
||||||
|
off = off.saturating_add(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few failed reads are tolerable (hwpoison, offline pages); a walk where
|
||||||
|
// nothing was read means the segments were wrong — report it rather than
|
||||||
|
// letting the host trust an empty "done".
|
||||||
|
if *pages == 0 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("kcore walk read no pages ({read_errors} read errors)"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if read_errors > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
read_errors,
|
||||||
|
pages = *pages,
|
||||||
|
"kcore preload skipped unreadable pages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resolved (file_offset, length) window in /proc/kcore corresponding to one
|
||||||
|
// System RAM range.
|
||||||
|
struct RamSegment {
|
||||||
|
file_offset: u64,
|
||||||
|
len: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match every System RAM range from /proc/iomem to its KCORE_RAM PT_LOAD
|
||||||
|
// segment. RAM segments share a single direct-map base (vaddr = base +
|
||||||
|
// phys_start; base is KASLR-randomised, so it must be derived, not assumed).
|
||||||
|
// Candidate bases come from size-matched (segment, range) pairs; a base is
|
||||||
|
// accepted only if EVERY RAM range has a segment at its expected vaddr with
|
||||||
|
// the expected size. This can never select vmalloc/vmemmap segments: their
|
||||||
|
// sizes (tens of TB) match no iomem range.
|
||||||
|
//
|
||||||
|
// Sizes are compared after clamping the iomem range to page boundaries:
|
||||||
|
// kcore's segments are PFN-granular (walk_system_ram_range uses
|
||||||
|
// PFN_UP/PFN_DOWN) while iomem ranges are byte-granular — the classic
|
||||||
|
// 0x1000-0x9fbff low-RAM range is 0x9ec00 bytes in iomem but 0x9e000 in
|
||||||
|
// kcore. Exact byte matching would reject every kernel that has such a range.
|
||||||
|
fn match_ram_segments(
|
||||||
|
ranges: &[(u64, u64)],
|
||||||
|
segments: &[KcoreSegment],
|
||||||
|
) -> Result<Vec<RamSegment>, String> {
|
||||||
|
const KERNEL_SPACE_MIN: u64 = 0xffff_8000_0000_0000;
|
||||||
|
|
||||||
|
// Page-clamp: [PFN_UP(start), PFN_DOWN(end)). Ranges smaller than one
|
||||||
|
// page after clamping have no kcore segment and nothing to preload.
|
||||||
|
let clamped: Vec<(u64, u64)> = ranges
|
||||||
|
.iter()
|
||||||
|
.map(|(start, end)| (start.next_multiple_of(PAGE_SIZE), end & !(PAGE_SIZE - 1)))
|
||||||
|
.filter(|(start, end)| end > start)
|
||||||
|
.collect();
|
||||||
|
if clamped.is_empty() {
|
||||||
|
return Err("no page-sized System RAM ranges after clamping".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<u64> = Vec::new();
|
||||||
|
for s in segments.iter().filter(|s| s.vaddr >= KERNEL_SPACE_MIN) {
|
||||||
|
for (start, end) in &clamped {
|
||||||
|
if s.file_size == end - start {
|
||||||
|
if let Some(base) = s.vaddr.checked_sub(*start) {
|
||||||
|
candidates.push(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort_unstable();
|
||||||
|
candidates.dedup();
|
||||||
|
|
||||||
|
let try_base = |base: u64| -> Option<Vec<RamSegment>> {
|
||||||
|
clamped
|
||||||
|
.iter()
|
||||||
|
.map(|(start, end)| {
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.vaddr == base + start && s.file_size == end - start)
|
||||||
|
.map(|s| RamSegment {
|
||||||
|
file_offset: s.file_offset,
|
||||||
|
len: end - start,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for base in &candidates {
|
||||||
|
if let Some(out) = try_base(*base) {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"no consistent direct-map base for {} RAM ranges across {} PT_LOAD segments \
|
||||||
|
({} candidate bases tried)",
|
||||||
|
clamped.len(),
|
||||||
|
segments.len(),
|
||||||
|
candidates.len(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KcoreSegment {
|
||||||
|
file_offset: u64,
|
||||||
|
file_size: u64,
|
||||||
|
vaddr: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const BASE: u64 = 0xffff_8880_0000_0000; // typical direct-map base, no KASLR
|
||||||
|
const GIB: u64 = 1 << 30;
|
||||||
|
const TIB: u64 = 1 << 40;
|
||||||
|
|
||||||
|
fn seg(vaddr: u64, file_size: u64, file_offset: u64) -> KcoreSegment {
|
||||||
|
KcoreSegment {
|
||||||
|
file_offset,
|
||||||
|
file_size,
|
||||||
|
vaddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typical CH guest: low RAM hole below 1MB, main range, plus the huge
|
||||||
|
// vmalloc/vmemmap segments that the old heuristic used to pick.
|
||||||
|
fn typical_segments() -> Vec<KcoreSegment> {
|
||||||
|
vec![
|
||||||
|
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000), // vmalloc
|
||||||
|
seg(0xffff_ea00_0000_0000, TIB, 0x2000), // vmemmap
|
||||||
|
seg(BASE + 0x1000, 0x9e000, 0x10000), // RAM 0x1000-0x9efff
|
||||||
|
seg(BASE + 0x100000, 2 * GIB - 0x100000, 0x20000), // RAM 1MB-2GB
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte-granular, as /proc/iomem reports them: the low range ends at
|
||||||
|
// 0x9fbff (parse adds 1 → 0x9fc00), NOT page-aligned. kcore's segment for
|
||||||
|
// it is PFN-clamped to 0x9e000 bytes — matching must tolerate that.
|
||||||
|
fn typical_ranges() -> Vec<(u64, u64)> {
|
||||||
|
vec![(0x1000, 0x9fc00), (0x100000, 2 * GIB)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_all_ranges_and_skips_vmalloc() {
|
||||||
|
let out = match_ram_segments(&typical_ranges(), &typical_segments()).unwrap();
|
||||||
|
assert_eq!(out.len(), 2);
|
||||||
|
assert_eq!(out[0].file_offset, 0x10000);
|
||||||
|
assert_eq!(out[0].len, 0x9e000);
|
||||||
|
assert_eq!(out[1].file_offset, 0x20000);
|
||||||
|
assert_eq!(out[1].len, 2 * GIB - 0x100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kaslr_base_is_derived_not_assumed() {
|
||||||
|
let kaslr = BASE + 37 * GIB; // PUD-granular randomisation
|
||||||
|
let segments = vec![
|
||||||
|
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||||
|
seg(kaslr + 0x1000, 0x9e000, 0x10000),
|
||||||
|
seg(kaslr + 0x100000, 2 * GIB - 0x100000, 0x20000),
|
||||||
|
];
|
||||||
|
let out = match_ram_segments(&typical_ranges(), &segments).unwrap();
|
||||||
|
assert_eq!(out[1].file_offset, 0x20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_when_a_range_has_no_segment() {
|
||||||
|
// Second RAM range's segment missing → no base covers all ranges.
|
||||||
|
let segments = vec![
|
||||||
|
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||||
|
seg(BASE + 0x1000, 0x9e000, 0x10000),
|
||||||
|
];
|
||||||
|
assert!(match_ram_segments(&typical_ranges(), &segments).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_instead_of_falling_back_to_vmalloc() {
|
||||||
|
// Only huge kernel segments present (the exact shape that fooled the
|
||||||
|
// old `file_size >= total_ram` heuristic).
|
||||||
|
let segments = vec![
|
||||||
|
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||||
|
seg(0xffff_ea00_0000_0000, TIB, 0x2000),
|
||||||
|
];
|
||||||
|
assert!(match_ram_segments(&typical_ranges(), &segments).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unaligned_range_is_page_clamped() {
|
||||||
|
// Range with unaligned start AND end: kcore clamps to
|
||||||
|
// [PFN_UP(start), PFN_DOWN(end)).
|
||||||
|
let ranges = vec![(0x2400, 0x9fbff + 1)];
|
||||||
|
let segments = vec![
|
||||||
|
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||||
|
seg(BASE + 0x3000, 0x9c000, 0x10000), // 0x3000..0x9f000
|
||||||
|
];
|
||||||
|
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].file_offset, 0x10000);
|
||||||
|
assert_eq!(out[0].len, 0x9c000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sub_page_range_is_skipped() {
|
||||||
|
// A range smaller than one page after clamping (e.g. 0x9fc00-0x9ffff)
|
||||||
|
// has no kcore segment; it must be dropped, not fail the match.
|
||||||
|
let ranges = vec![(0x9fc00, 0xa0000), (0x100000, 2 * GIB)];
|
||||||
|
let segments = vec![seg(BASE + 0x100000, 2 * GIB - 0x100000, 0x20000)];
|
||||||
|
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(out[0].file_offset, 0x20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambiguous_same_size_ranges_still_resolve() {
|
||||||
|
// Two RAM ranges of identical size: candidate bases from cross pairs
|
||||||
|
// must be rejected; only the true base matches both ranges.
|
||||||
|
let ranges = vec![(0x0, GIB), (2 * GIB, 3 * GIB)];
|
||||||
|
let segments = vec![seg(BASE, GIB, 0x10000), seg(BASE + 2 * GIB, GIB, 0x20000)];
|
||||||
|
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||||
|
assert_eq!(out[0].file_offset, 0x10000);
|
||||||
|
assert_eq!(out[1].file_offset, 0x20000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_kcore_pt_load(f: &mut fs::File) -> std::io::Result<Vec<KcoreSegment>> {
|
||||||
|
let mut hdr = [0u8; 64];
|
||||||
|
f.seek(SeekFrom::Start(0))?;
|
||||||
|
f.read_exact(&mut hdr)?;
|
||||||
|
|
||||||
|
if &hdr[0..4] != b"\x7fELF" || hdr[4] != 2 {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"not an ELF64 file",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let e_phoff = u64::from_le_bytes(hdr[32..40].try_into().unwrap());
|
||||||
|
let e_phentsize = u16::from_le_bytes(hdr[54..56].try_into().unwrap()) as u64;
|
||||||
|
let e_phnum = u16::from_le_bytes(hdr[56..58].try_into().unwrap()) as u64;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut entry = vec![0u8; e_phentsize as usize];
|
||||||
|
for i in 0..e_phnum {
|
||||||
|
f.seek(SeekFrom::Start(e_phoff + i * e_phentsize))?;
|
||||||
|
f.read_exact(&mut entry)?;
|
||||||
|
let p_type = u32::from_le_bytes(entry[0..4].try_into().unwrap());
|
||||||
|
if p_type != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p_offset = u64::from_le_bytes(entry[8..16].try_into().unwrap());
|
||||||
|
let p_vaddr = u64::from_le_bytes(entry[16..24].try_into().unwrap());
|
||||||
|
let p_filesz = u64::from_le_bytes(entry[32..40].try_into().unwrap());
|
||||||
|
out.push(KcoreSegment {
|
||||||
|
file_offset: p_offset,
|
||||||
|
file_size: p_filesz,
|
||||||
|
vaddr: p_vaddr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@ -46,7 +46,8 @@ fn collect_metrics(state: &AppState) -> Result<Metrics, String> {
|
|||||||
let mut sys = sysinfo::System::new();
|
let mut sys = sysinfo::System::new();
|
||||||
sys.refresh_memory();
|
sys.refresh_memory();
|
||||||
let mem_total = sys.total_memory();
|
let mem_total = sys.total_memory();
|
||||||
let mem_used = sys.used_memory();
|
let mem_available = sys.available_memory();
|
||||||
|
let mem_used = mem_total.saturating_sub(mem_available);
|
||||||
let mem_total_mib = mem_total / 1024 / 1024;
|
let mem_total_mib = mem_total / 1024 / 1024;
|
||||||
let mem_used_mib = mem_used / 1024 / 1024;
|
let mem_used_mib = mem_used / 1024 / 1024;
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,21 @@
|
|||||||
pub mod encoding;
|
pub mod activity;
|
||||||
pub mod envs;
|
pub mod envs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
pub mod memory;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
pub mod volumes;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use http::header::{CACHE_CONTROL, HeaderName};
|
|
||||||
use http::Method;
|
use http::Method;
|
||||||
|
use http::header::{CACHE_CONTROL, HeaderName};
|
||||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||||
|
|
||||||
use crate::config::CORS_MAX_AGE;
|
use crate::config::CORS_MAX_AGE;
|
||||||
@ -46,11 +48,22 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health::get_health))
|
.route("/health", get(health::get_health))
|
||||||
|
.route("/activity", get(activity::get_activity))
|
||||||
.route("/metrics", get(metrics::get_metrics))
|
.route("/metrics", get(metrics::get_metrics))
|
||||||
.route("/envs", get(envs::get_envs))
|
.route("/envs", get(envs::get_envs))
|
||||||
.route("/init", post(init::post_init))
|
.route("/init", post(init::post_init))
|
||||||
.route("/snapshot/prepare", post(snapshot::post_snapshot_prepare))
|
.route("/snapshot/prepare", post(snapshot::post_snapshot_prepare))
|
||||||
.route("/files", get(files::get_files).post(files::post_files))
|
.route(
|
||||||
|
"/memory/preload",
|
||||||
|
get(memory::get_memory_preload).post(memory::post_memory_preload),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/memory/preload/cancel",
|
||||||
|
post(memory::post_memory_preload_cancel),
|
||||||
|
)
|
||||||
|
.route("/files", get(files::get_files).put(files::put_files))
|
||||||
|
.route("/volumes/mount", post(volumes::post_mount))
|
||||||
|
.route("/volumes/unmount", post(volumes::post_unmount))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,67 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::{StatusCode, header};
|
use axum::http::{StatusCode, header};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
|
use nix::unistd::sync;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// POST /snapshot/prepare — quiesce subsystems before Firecracker snapshot.
|
/// POST /snapshot/prepare — called by the host agent immediately before it
|
||||||
///
|
/// invokes vm.pause + vm.snapshot. The handler quiesces guest state so the
|
||||||
/// In Rust there is no GC dance. We just:
|
/// resulting snapshot is clean: outstanding writes are flushed to disk, the
|
||||||
/// 1. Stop port subsystem
|
/// VFS page cache is dropped (so the dm-snapshot CoW is the source of truth),
|
||||||
/// 2. Close idle connections via conntracker
|
/// and the port forwarder is stopped to prevent socat children from being
|
||||||
/// 3. Set needs_restore flag
|
/// frozen mid-handshake.
|
||||||
pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
if let Some(ref ps) = state.port_subsystem {
|
// Stop port forwarder + scanner so no socat process is captured in the
|
||||||
ps.stop();
|
// snapshot with a half-open TCP connection. /init on resume restarts it.
|
||||||
tracing::info!("snapshot/prepare: port subsystem stopped");
|
if let Some(ref port_sub) = state.port_subsystem {
|
||||||
|
port_sub.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
state.conn_tracker.prepare_for_snapshot();
|
// Flush in-memory FS state, then drop the VFS page cache +
|
||||||
tracing::info!("snapshot/prepare: connections prepared");
|
// dentries/inodes. sync first so the pages we drop are clean; dropping
|
||||||
|
// reduces snapshot size by ensuring CH only persists memory pages the
|
||||||
|
// guest actually needs.
|
||||||
|
flush_and_drop_caches("first pass").await;
|
||||||
|
|
||||||
state.needs_restore.store(true, Ordering::Release);
|
// Best-effort fstrim on the rootfs so unused blocks are returned to the
|
||||||
tracing::info!("snapshot/prepare: ready for freeze");
|
// dm-snapshot, keeping CoW size minimal.
|
||||||
|
let _ = tokio::process::Command::new("fstrim")
|
||||||
|
.arg("/")
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Second pass after fstrim: fstrim re-reads superblock / group descriptor
|
||||||
|
// pages that we just evicted, putting them back in the page cache. This
|
||||||
|
// drops those and any other late readers (e.g. sync flushers).
|
||||||
|
flush_and_drop_caches("second pass").await;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
tracing::info!("snapshot/prepare: quiesced");
|
||||||
(
|
(
|
||||||
StatusCode::NO_CONTENT,
|
StatusCode::NO_CONTENT,
|
||||||
[(header::CACHE_CONTROL, "no-store")],
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sync(2) can block for seconds flushing dirty pages, and the drop_caches
|
||||||
|
// write blocks while the kernel evicts — both run on the blocking pool or
|
||||||
|
// they starve every async task sharing the worker thread (health probes,
|
||||||
|
// exec streams) for the whole quiesce window.
|
||||||
|
async fn flush_and_drop_caches(pass: &'static str) {
|
||||||
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
|
sync();
|
||||||
|
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") {
|
||||||
|
tracing::warn!(error = %e, pass, "drop_caches failed (continuing)");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|||||||
252
envd-rs/src/http/volumes.rs
Normal file
252
envd-rs/src/http/volumes.rs
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{StatusCode, header};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MountRequest {
|
||||||
|
/// virtio-blk serial set by the host in the VM config; used to resolve the
|
||||||
|
/// block device via /sys/block/*/serial regardless of enumeration order.
|
||||||
|
pub serial: String,
|
||||||
|
/// Guest path to mount the volume at (e.g. "/mnt/vol-...").
|
||||||
|
pub mount_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UnmountRequest {
|
||||||
|
pub mount_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_error(status: StatusCode, msg: &str) -> Response {
|
||||||
|
let body = serde_json::json!({ "code": status.as_u16(), "message": msg });
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_content() -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::NO_CONTENT,
|
||||||
|
[(header::CACHE_CONTROL, "no-store")],
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /volumes/mount — called by the host agent after boot for each attached
|
||||||
|
/// volume. Resolves the block device by its virtio-blk serial, formats it with
|
||||||
|
/// ext4 only if it has no filesystem yet (existing data is never reformatted),
|
||||||
|
/// then mounts it at the requested path.
|
||||||
|
pub async fn post_mount(
|
||||||
|
State(_state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<MountRequest>,
|
||||||
|
) -> Response {
|
||||||
|
// Serials are host-generated hex tokens; reject anything else before it
|
||||||
|
// reaches a sysfs comparison / command argument.
|
||||||
|
if req.serial.is_empty() || !req.serial.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return json_error(StatusCode::BAD_REQUEST, "invalid volume serial");
|
||||||
|
}
|
||||||
|
if req.mount_path.is_empty() {
|
||||||
|
return json_error(StatusCode::BAD_REQUEST, "mount_path is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = match resolve_device_by_serial(&req.serial).await {
|
||||||
|
Some(d) => d,
|
||||||
|
None => {
|
||||||
|
return json_error(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
&format!("no block device with serial {}", req.serial),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// blkid exit status: 0 = a filesystem/signature is present (leave it alone),
|
||||||
|
// 2 = no recognized signature (format it), anything else = probe error.
|
||||||
|
// Tracked so the permissions of an existing volume are never touched — only
|
||||||
|
// a filesystem this call created gets opened up below.
|
||||||
|
let formatted = match has_filesystem(&device).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!(device, "volume already has a filesystem; skipping mkfs");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
if let Err(e) = mkfs_ext4(&device).await {
|
||||||
|
return json_error(StatusCode::INTERNAL_SERVER_ERROR, &e);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return json_error(StatusCode::INTERNAL_SERVER_ERROR, &e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&req.mount_path).await {
|
||||||
|
return json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("create mount dir: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No -t: let the kernel autodetect the filesystem, so a pre-existing
|
||||||
|
// non-ext4 volume still mounts.
|
||||||
|
match tokio::process::Command::new("mount")
|
||||||
|
.arg(&device)
|
||||||
|
.arg(&req.mount_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
if formatted {
|
||||||
|
open_mount_root(&req.mount_path);
|
||||||
|
}
|
||||||
|
tracing::info!(device, mount_path = %req.mount_path, "volume mounted");
|
||||||
|
no_content()
|
||||||
|
}
|
||||||
|
Ok(out) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
// Already mounted at the target is success for an idempotent retry.
|
||||||
|
if stderr.contains("already mounted") {
|
||||||
|
return no_content();
|
||||||
|
}
|
||||||
|
json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("mount failed: {}", stderr.trim()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => json_error(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("mount command failed: {e}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// open_mount_root relaxes the permissions of a just-formatted volume so every
|
||||||
|
/// user in the capsule can use it.
|
||||||
|
///
|
||||||
|
/// mkfs.ext4 leaves the filesystem root owned by root with mode 0755, which
|
||||||
|
/// locks out every non-root user — including the template's default user, which
|
||||||
|
/// is who the file API and exec run as. Without this a caller has to sudo to
|
||||||
|
/// write to their own volume. 1777 is /tmp's mode: world-writable, with the
|
||||||
|
/// sticky bit so one user cannot remove another's files.
|
||||||
|
///
|
||||||
|
/// Only ever called straight after a first-time format. Re-mounting an existing
|
||||||
|
/// volume leaves its permissions exactly as its owner last set them.
|
||||||
|
///
|
||||||
|
/// Applied to the mounted root rather than the directory created before the
|
||||||
|
/// mount — that one is hidden underneath and changing it would have no effect.
|
||||||
|
///
|
||||||
|
/// Best-effort: a failure here leaves a root-only volume that is still mounted
|
||||||
|
/// and holds its data, which is not worth failing the whole capsule create over.
|
||||||
|
fn open_mount_root(mount_path: &str) {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::set_permissions(mount_path, std::fs::Permissions::from_mode(0o1777)) {
|
||||||
|
tracing::warn!(
|
||||||
|
mount_path,
|
||||||
|
error = %e,
|
||||||
|
"failed to relax volume permissions; only root will be able to write to it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /volumes/unmount — called best-effort before a graceful capsule destroy.
|
||||||
|
/// Flushes buffered writes (sync) and unmounts so the backing file is
|
||||||
|
/// consistent. Idempotent: unmounting an already-unmounted path is fine.
|
||||||
|
pub async fn post_unmount(
|
||||||
|
State(_state): State<Arc<AppState>>,
|
||||||
|
Json(req): Json<UnmountRequest>,
|
||||||
|
) -> Response {
|
||||||
|
if req.mount_path.is_empty() {
|
||||||
|
return json_error(StatusCode::BAD_REQUEST, "mount_path is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync flushes all filesystems, guaranteeing the volume's data reaches its
|
||||||
|
// backing file even if the umount below is skipped or fails.
|
||||||
|
let _ = tokio::task::spawn_blocking(nix::unistd::sync).await;
|
||||||
|
|
||||||
|
let _ = tokio::process::Command::new("umount")
|
||||||
|
.arg(&req.mount_path)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
no_content()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// resolve_device_by_serial finds the /dev path of the virtio-blk device whose
|
||||||
|
/// serial matches. Polls briefly since a boot-time disk may not be fully
|
||||||
|
/// enumerated the instant envd is asked to mount it.
|
||||||
|
async fn resolve_device_by_serial(serial: &str) -> Option<String> {
|
||||||
|
for _ in 0..50 {
|
||||||
|
if let Some(dev) = find_block_by_serial(serial) {
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_block_by_serial(serial: &str) -> Option<String> {
|
||||||
|
let entries = std::fs::read_dir("/sys/block").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
// Only virtio-blk devices carry a serial we set; skip loop/ram/etc.
|
||||||
|
if !name.starts_with("vd") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let serial_path = format!("/sys/block/{name}/serial");
|
||||||
|
if let Ok(s) = std::fs::read_to_string(&serial_path) {
|
||||||
|
if s.trim() == serial {
|
||||||
|
return Some(format!("/dev/{name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// has_filesystem returns true when blkid detects any filesystem signature on
|
||||||
|
/// the device. Guards mkfs so existing data is never reformatted.
|
||||||
|
async fn has_filesystem(device: &str) -> Result<bool, String> {
|
||||||
|
let out = tokio::process::Command::new("blkid")
|
||||||
|
.arg(device)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("blkid spawn failed: {e}"))?;
|
||||||
|
match out.status.code() {
|
||||||
|
// 0 with output = a signature was printed. Guard on the output too so a
|
||||||
|
// blkid variant that always exits 0 (e.g. busybox) is handled: empty
|
||||||
|
// output means no filesystem.
|
||||||
|
Some(0) => Ok(!String::from_utf8_lossy(&out.stdout).trim().is_empty()),
|
||||||
|
// util-linux blkid: 2 = nothing detected.
|
||||||
|
Some(2) => Ok(false),
|
||||||
|
// 4 (usage) / 8 (error) / signal — treat as a probe failure.
|
||||||
|
other => Err(format!(
|
||||||
|
"blkid probe failed (exit {:?}): {}",
|
||||||
|
other,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mkfs_ext4(device: &str) -> Result<(), String> {
|
||||||
|
// -F: the target is a whole virtio-blk device, not a partition; without it
|
||||||
|
// mkfs.ext4 prompts and would hang. Safe here — only reached when blkid
|
||||||
|
// found no existing signature.
|
||||||
|
let out = tokio::process::Command::new("mkfs.ext4")
|
||||||
|
.args(["-q", "-F", device])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("mkfs.ext4 spawn failed: {e}"))?;
|
||||||
|
if out.status.success() {
|
||||||
|
tracing::info!(device, "formatted volume with ext4");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"mkfs.ext4 failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod cgroups;
|
mod cgroups;
|
||||||
|
mod cmd;
|
||||||
mod config;
|
mod config;
|
||||||
mod conntracker;
|
mod conntracker;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod execcontext;
|
mod execcontext;
|
||||||
mod host;
|
|
||||||
mod http;
|
mod http;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
@ -22,7 +22,6 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use config::{DEFAULT_PORT, DEFAULT_USER, WRENN_RUN_DIR};
|
use config::{DEFAULT_PORT, DEFAULT_USER, WRENN_RUN_DIR};
|
||||||
use execcontext::Defaults;
|
use execcontext::Defaults;
|
||||||
@ -41,12 +40,13 @@ const COMMIT: &str = {
|
|||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "envd", about = "Wrenn guest agent daemon")]
|
#[command(name = "envd", about = "Wrenn guest agent daemon")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
|
/// Client subcommand. When omitted, envd runs as the guest daemon.
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
|
||||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
||||||
#[arg(long = "isnotfc", default_value_t = false)]
|
|
||||||
is_not_fc: bool,
|
|
||||||
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
version: bool,
|
version: bool,
|
||||||
|
|
||||||
@ -60,6 +60,12 @@ struct Cli {
|
|||||||
cgroup_root: String,
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@ -73,66 +79,57 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_json = !cli.is_not_fc;
|
// Client subcommands are short-lived: run and exit before any daemon setup.
|
||||||
logging::init(use_json);
|
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) {
|
if let Err(e) = fs::create_dir_all(WRENN_RUN_DIR) {
|
||||||
tracing::error!(error = %e, "failed to create wrenn run directory");
|
tracing::error!(error = %e, "failed to create wrenn run directory");
|
||||||
}
|
}
|
||||||
|
|
||||||
let defaults = Defaults::new(DEFAULT_USER);
|
let defaults = Defaults::new(DEFAULT_USER);
|
||||||
let is_fc_str = if cli.is_not_fc { "false" } else { "true" };
|
|
||||||
defaults
|
defaults
|
||||||
.env_vars
|
.env_vars
|
||||||
.insert("WRENN_SANDBOX".into(), is_fc_str.into());
|
.insert("WRENN_SANDBOX".into(), "true".into());
|
||||||
|
|
||||||
let wrenn_sandbox_path = Path::new(WRENN_RUN_DIR).join(".WRENN_SANDBOX");
|
let wrenn_sandbox_path = Path::new(WRENN_RUN_DIR).join(".WRENN_SANDBOX");
|
||||||
if let Err(e) = fs::write(&wrenn_sandbox_path, is_fc_str.as_bytes()) {
|
if let Err(e) = fs::write(&wrenn_sandbox_path, b"true") {
|
||||||
tracing::error!(error = %e, "failed to write sandbox file");
|
tracing::error!(error = %e, "failed to write sandbox file");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancel = CancellationToken::new();
|
|
||||||
|
|
||||||
// MMDS polling (only in FC mode)
|
|
||||||
if !cli.is_not_fc {
|
|
||||||
let env_vars = Arc::clone(&defaults.env_vars);
|
|
||||||
let cancel_clone = cancel.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
host::mmds::poll_for_opts(env_vars, cancel_clone).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cgroup manager
|
// Cgroup manager
|
||||||
let cgroup_manager: Arc<dyn cgroups::CgroupManager> =
|
let cgroup_manager: Arc<dyn cgroups::CgroupManager> = match cgroups::Cgroup2Manager::new(
|
||||||
match cgroups::Cgroup2Manager::new(
|
&cli.cgroup_root,
|
||||||
&cli.cgroup_root,
|
&[
|
||||||
&[
|
(
|
||||||
(
|
cgroups::ProcessType::Pty,
|
||||||
cgroups::ProcessType::Pty,
|
"wrenn/pty",
|
||||||
"wrenn/pty",
|
&[] as &[(&str, &str)],
|
||||||
&[] as &[(&str, &str)],
|
),
|
||||||
),
|
(
|
||||||
(
|
cgroups::ProcessType::User,
|
||||||
cgroups::ProcessType::User,
|
"wrenn/user",
|
||||||
"wrenn/user",
|
&[] as &[(&str, &str)],
|
||||||
&[] as &[(&str, &str)],
|
),
|
||||||
),
|
(
|
||||||
(
|
cgroups::ProcessType::Socat,
|
||||||
cgroups::ProcessType::Socat,
|
"wrenn/socat",
|
||||||
"wrenn/socat",
|
&[] as &[(&str, &str)],
|
||||||
&[] as &[(&str, &str)],
|
),
|
||||||
),
|
],
|
||||||
],
|
) {
|
||||||
) {
|
Ok(m) => {
|
||||||
Ok(m) => {
|
tracing::info!("cgroup2 manager initialized");
|
||||||
tracing::info!("cgroup2 manager initialized");
|
Arc::new(m)
|
||||||
Arc::new(m)
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
||||||
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
Arc::new(cgroups::NoopCgroupManager)
|
||||||
Arc::new(cgroups::NoopCgroupManager)
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Port subsystem
|
// Port subsystem
|
||||||
let port_subsystem = Arc::new(PortSubsystem::new(Arc::clone(&cgroup_manager)));
|
let port_subsystem = Arc::new(PortSubsystem::new(Arc::clone(&cgroup_manager)));
|
||||||
@ -143,15 +140,20 @@ async fn main() {
|
|||||||
defaults,
|
defaults,
|
||||||
VERSION.to_string(),
|
VERSION.to_string(),
|
||||||
COMMIT.to_string(),
|
COMMIT.to_string(),
|
||||||
!cli.is_not_fc,
|
|
||||||
Some(Arc::clone(&port_subsystem)),
|
Some(Arc::clone(&port_subsystem)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Memory reclaimer — drop page cache when available memory is low.
|
||||||
|
// The balloon device can only reclaim pages the guest kernel freed.
|
||||||
|
{
|
||||||
|
let state_for_reclaimer = Arc::clone(&state);
|
||||||
|
std::thread::spawn(move || memory_reclaimer(state_for_reclaimer));
|
||||||
|
}
|
||||||
|
|
||||||
// RPC services (Connect protocol — serves Connect + gRPC + gRPC-Web on same port)
|
// RPC services (Connect protocol — serves Connect + gRPC + gRPC-Web on same port)
|
||||||
let connect_router = rpc::rpc_router(Arc::clone(&state));
|
let connect_router = rpc::rpc_router(Arc::clone(&state));
|
||||||
|
|
||||||
let app = http::router(Arc::clone(&state))
|
let app = http::router(Arc::clone(&state)).fallback_service(connect_router.into_axum_service());
|
||||||
.fallback_service(connect_router.into_axum_service());
|
|
||||||
|
|
||||||
// --cmd: spawn initial process if specified
|
// --cmd: spawn initial process if specified
|
||||||
if !cli.start_cmd.is_empty() {
|
if !cli.start_cmd.is_empty() {
|
||||||
@ -163,7 +165,12 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
|
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");
|
let listener = TcpListener::bind(addr).await.expect("failed to bind");
|
||||||
|
|
||||||
@ -180,7 +187,6 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
port_subsystem.stop();
|
port_subsystem.stop();
|
||||||
cancel.cancel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_initial_command(cmd: &str, state: &AppState) {
|
fn spawn_initial_command(cmd: &str, state: &AppState) {
|
||||||
@ -188,7 +194,8 @@ fn spawn_initial_command(cmd: &str, state: &AppState) {
|
|||||||
use crate::rpc::process_handler;
|
use crate::rpc::process_handler;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let user = match lookup_user(&state.defaults.user) {
|
let default_user = state.defaults.user();
|
||||||
|
let user = match lookup_user(&default_user) {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "cmd: failed to lookup user");
|
tracing::error!(error = %e, "cmd: failed to lookup user");
|
||||||
@ -197,11 +204,8 @@ fn spawn_initial_command(cmd: &str, state: &AppState) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let home = user.dir.to_string_lossy().to_string();
|
let home = user.dir.to_string_lossy().to_string();
|
||||||
let cwd = state
|
let default_workdir = state.defaults.workdir();
|
||||||
.defaults
|
let cwd = default_workdir.as_deref().unwrap_or(&home);
|
||||||
.workdir
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(&home);
|
|
||||||
|
|
||||||
match process_handler::spawn_process(
|
match process_handler::spawn_process(
|
||||||
cmd,
|
cmd,
|
||||||
@ -214,11 +218,43 @@ fn spawn_initial_command(cmd: &str, state: &AppState) {
|
|||||||
&user,
|
&user,
|
||||||
&state.defaults.env_vars,
|
&state.defaults.env_vars,
|
||||||
) {
|
) {
|
||||||
Ok(handle) => {
|
Ok(spawned) => {
|
||||||
tracing::info!(pid = handle.pid, cmd, "initial command spawned");
|
tracing::info!(pid = spawned.handle.pid, cmd, "initial command spawned");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, cmd, "failed to spawn initial command");
|
tracing::error!(error = %e, cmd, "failed to spawn initial command");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn memory_reclaimer(_state: Arc<AppState>) {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const CHECK_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
const DROP_THRESHOLD_PCT: u64 = 80;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(CHECK_INTERVAL);
|
||||||
|
|
||||||
|
let mut sys = sysinfo::System::new();
|
||||||
|
sys.refresh_memory();
|
||||||
|
let total = sys.total_memory();
|
||||||
|
let available = sys.available_memory();
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let used_pct = ((total - available) * 100) / total;
|
||||||
|
if used_pct >= DROP_THRESHOLD_PCT {
|
||||||
|
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") {
|
||||||
|
tracing::debug!(error = %e, "drop_caches failed");
|
||||||
|
} else {
|
||||||
|
let mut sys2 = sysinfo::System::new();
|
||||||
|
sys2.refresh_memory();
|
||||||
|
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 user;
|
|
||||||
pub mod path;
|
pub mod path;
|
||||||
|
pub mod user;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nix::unistd::{Gid, Uid};
|
use nix::unistd::{Gid, Uid};
|
||||||
|
|
||||||
fn expand_tilde(path: &str, home_dir: &str) -> Result<String, String> {
|
pub(crate) fn expand_tilde(path: &str, home_dir: &str) -> Result<String, String> {
|
||||||
if path.is_empty() || !path.starts_with('~') {
|
if path.is_empty() || !path.starts_with('~') {
|
||||||
return Ok(path.to_string());
|
return Ok(path.to_string());
|
||||||
}
|
}
|
||||||
@ -57,10 +57,18 @@ pub fn ensure_dirs(path: &str, uid: Uid, gid: Gid) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
fs::create_dir(¤t)
|
if let Err(ce) = fs::create_dir(¤t) {
|
||||||
.map_err(|e| format!("failed to create directory {current_str}: {e}"))?;
|
// Concurrent request may have created it between the stat
|
||||||
chown(¤t, Some(uid.as_raw()), Some(gid.as_raw()))
|
// and here — fine as long as it's a directory now. The
|
||||||
.map_err(|e| format!("failed to chown directory {current_str}: {e}"))?;
|
// winner did its own chown; don't re-chown their dir.
|
||||||
|
let now_dir = fs::metadata(¤t).map(|m| m.is_dir()).unwrap_or(false);
|
||||||
|
if !now_dir {
|
||||||
|
return Err(format!("failed to create directory {current_str}: {ce}"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chown(¤t, Some(uid.as_raw()), Some(gid.as_raw()))
|
||||||
|
.map_err(|e| format!("failed to chown directory {current_str}: {e}"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!("failed to stat directory {current_str}: {e}"));
|
return Err(format!("failed to stat directory {current_str}: {e}"));
|
||||||
@ -70,3 +78,161 @@ pub fn ensure_dirs(path: &str, uid: Uid, gid: Gid) -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// expand_tilde
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_empty_passthrough() {
|
||||||
|
assert_eq!(expand_tilde("", "/home/u").unwrap(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_no_tilde_passthrough() {
|
||||||
|
assert_eq!(expand_tilde("/absolute", "/home/u").unwrap(), "/absolute");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_bare() {
|
||||||
|
assert_eq!(expand_tilde("~", "/home/user").unwrap(), "/home/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_slash_path() {
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde("~/docs", "/home/user").unwrap(),
|
||||||
|
"/home/user/docs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_nested() {
|
||||||
|
assert_eq!(expand_tilde("~/a/b/c", "/h").unwrap(), "/h/a/b/c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilde_other_user_errors() {
|
||||||
|
assert!(expand_tilde("~bob/foo", "/home/user").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand_and_resolve
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_absolute_passthrough() {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_empty_no_default_falls_back_to_home() {
|
||||||
|
// Empty path with no default → joins "" with home_dir → returns home_dir
|
||||||
|
let result = expand_and_resolve("", "/home", None).unwrap();
|
||||||
|
assert_eq!(result, "/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_tilde_expands() {
|
||||||
|
assert_eq!(
|
||||||
|
expand_and_resolve("~/dir", "/home/u", None).unwrap(),
|
||||||
|
"/home/u/dir"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_relative_joins_home() {
|
||||||
|
let result = expand_and_resolve("subdir", "/tmp", None).unwrap();
|
||||||
|
// Relative path joined with home and canonicalized (or raw join on missing)
|
||||||
|
assert!(result.starts_with("/tmp"));
|
||||||
|
assert!(result.contains("subdir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_tilde_other_user_errors() {
|
||||||
|
assert!(expand_and_resolve("~bob", "/home/u", None).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure_dirs
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_dirs_creates_nested() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let path = tmp.path().join("a/b/c");
|
||||||
|
let uid = nix::unistd::getuid();
|
||||||
|
let gid = nix::unistd::getgid();
|
||||||
|
ensure_dirs(path.to_str().unwrap(), uid, gid).unwrap();
|
||||||
|
assert!(path.is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_dirs_existing_is_ok() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let uid = nix::unistd::getuid();
|
||||||
|
let gid = nix::unistd::getgid();
|
||||||
|
ensure_dirs(tmp.path().to_str().unwrap(), uid, gid).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_dirs_file_in_path_errors() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let file_path = tmp.path().join("afile");
|
||||||
|
std::fs::write(&file_path, "").unwrap();
|
||||||
|
let nested = file_path.join("subdir");
|
||||||
|
let uid = nix::unistd::getuid();
|
||||||
|
let gid = nix::unistd::getgid();
|
||||||
|
let result = ensure_dirs(nested.to_str().unwrap(), uid, gid);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("path is a file"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -37,6 +37,36 @@ pub fn read_tcp_connections() -> Vec<ConnStat> {
|
|||||||
conns
|
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>> {
|
fn parse_proc_net_tcp(path: &str, family: u32) -> io::Result<Vec<ConnStat>> {
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let reader = io::BufReader::new(file);
|
let reader = io::BufReader::new(file);
|
||||||
@ -92,7 +122,10 @@ fn parse_hex_addr(s: &str, family: u32) -> Option<(String, u32)> {
|
|||||||
if ip_bytes.len() != 4 {
|
if ip_bytes.len() != 4 {
|
||||||
return None;
|
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 {
|
} else {
|
||||||
if ip_bytes.len() != 16 {
|
if ip_bytes.len() != 16 {
|
||||||
return None;
|
return None;
|
||||||
@ -110,3 +143,223 @@ fn parse_hex_addr(s: &str, family: u32) -> Option<(String, u32)> {
|
|||||||
|
|
||||||
Some((ip_str, port))
|
Some((ip_str, port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
// tcp_state_name
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_all_known_codes() {
|
||||||
|
assert_eq!(tcp_state_name("01"), "ESTABLISHED");
|
||||||
|
assert_eq!(tcp_state_name("02"), "SYN_SENT");
|
||||||
|
assert_eq!(tcp_state_name("03"), "SYN_RECV");
|
||||||
|
assert_eq!(tcp_state_name("04"), "FIN_WAIT1");
|
||||||
|
assert_eq!(tcp_state_name("05"), "FIN_WAIT2");
|
||||||
|
assert_eq!(tcp_state_name("06"), "TIME_WAIT");
|
||||||
|
assert_eq!(tcp_state_name("07"), "CLOSE");
|
||||||
|
assert_eq!(tcp_state_name("08"), "CLOSE_WAIT");
|
||||||
|
assert_eq!(tcp_state_name("09"), "LAST_ACK");
|
||||||
|
assert_eq!(tcp_state_name("0A"), "LISTEN");
|
||||||
|
assert_eq!(tcp_state_name("0B"), "CLOSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_unknown_code() {
|
||||||
|
assert_eq!(tcp_state_name("FF"), "UNKNOWN");
|
||||||
|
assert_eq!(tcp_state_name("00"), "UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_hex_addr
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_localhost() {
|
||||||
|
let (ip, port) = parse_hex_addr("0100007F:0050", libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(ip, "127.0.0.1");
|
||||||
|
assert_eq!(port, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_any() {
|
||||||
|
let (ip, port) = parse_hex_addr("00000000:0035", libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(ip, "0.0.0.0");
|
||||||
|
assert_eq!(port, 53);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_real_address() {
|
||||||
|
// 192.168.1.1 in little-endian = 0101A8C0
|
||||||
|
let (ip, port) = parse_hex_addr("0101A8C0:01BB", libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(ip, "192.168.1.1");
|
||||||
|
assert_eq!(port, 443);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv4_wrong_byte_count_returns_none() {
|
||||||
|
assert!(parse_hex_addr("0100:0050", libc::AF_INET as u32).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_hex_returns_none() {
|
||||||
|
assert!(parse_hex_addr("ZZZZZZZZ:0050", libc::AF_INET as u32).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_colon_returns_none() {
|
||||||
|
assert!(parse_hex_addr("0100007F0050", libc::AF_INET as u32).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv6_loopback() {
|
||||||
|
// ::1 in /proc/net/tcp6 format: 00000000000000000000000001000000
|
||||||
|
let (ip, port) = parse_hex_addr(
|
||||||
|
"00000000000000000000000001000000:0035",
|
||||||
|
libc::AF_INET6 as u32,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ip, "::1");
|
||||||
|
assert_eq!(port, 53);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipv6_wrong_byte_count_returns_none() {
|
||||||
|
assert!(parse_hex_addr("0100007F:0050", libc::AF_INET6 as u32).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_proc_net_tcp
|
||||||
|
|
||||||
|
fn write_tcp_file(content: &str) -> tempfile::NamedTempFile {
|
||||||
|
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
f.write_all(content.as_bytes()).unwrap();
|
||||||
|
f.flush().unwrap();
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_file() {
|
||||||
|
let f = write_tcp_file(
|
||||||
|
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||||
|
);
|
||||||
|
let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap();
|
||||||
|
assert!(conns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_single_entry() {
|
||||||
|
let content = "\
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 00000000\n";
|
||||||
|
let f = write_tcp_file(content);
|
||||||
|
let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(conns.len(), 1);
|
||||||
|
assert_eq!(conns[0].local_ip, "127.0.0.1");
|
||||||
|
assert_eq!(conns[0].local_port, 80);
|
||||||
|
assert_eq!(conns[0].status, "LISTEN");
|
||||||
|
assert_eq!(conns[0].inode, 12345);
|
||||||
|
assert_eq!(conns[0].family, libc::AF_INET as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_skips_malformed_rows() {
|
||||||
|
let content = "\
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 00000000
|
||||||
|
bad line
|
||||||
|
1: short\n";
|
||||||
|
let f = write_tcp_file(content);
|
||||||
|
let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(conns.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multiple_entries() {
|
||||||
|
let content = "\
|
||||||
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||||
|
0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 100 1 00000000
|
||||||
|
1: 00000000:01BB 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 200 1 00000000\n";
|
||||||
|
let f = write_tcp_file(content);
|
||||||
|
let conns = parse_proc_net_tcp(f.path().to_str().unwrap(), libc::AF_INET as u32).unwrap();
|
||||||
|
assert_eq!(conns.len(), 2);
|
||||||
|
assert_eq!(conns[0].local_port, 80);
|
||||||
|
assert_eq!(conns[1].local_port, 443);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -57,7 +57,9 @@ impl Scanner {
|
|||||||
|
|
||||||
pub async fn scan_and_broadcast(&self, cancel: CancellationToken) {
|
pub async fn scan_and_broadcast(&self, cancel: CancellationToken) {
|
||||||
loop {
|
loop {
|
||||||
let conns = read_tcp_connections();
|
let conns = tokio::task::spawn_blocking(read_tcp_connections)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
{
|
{
|
||||||
let subs = self.subs.read().unwrap();
|
let subs = self.subs.read().unwrap();
|
||||||
|
|||||||
@ -53,9 +53,7 @@ pub fn build_entry_info(path: &str) -> Result<EntryInfo, ConnectError> {
|
|||||||
Err(_) => FileType::FILE_TYPE_UNSPECIFIED,
|
Err(_) => FileType::FILE_TYPE_UNSPECIFIED,
|
||||||
};
|
};
|
||||||
|
|
||||||
let target_mode = std::fs::metadata(p)
|
let target_mode = std::fs::metadata(p).map(|m| m.mode() & 0o7777).unwrap_or(0);
|
||||||
.map(|m| m.mode() & 0o7777)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
(target_type, target_mode, Some(target))
|
(target_type, target_mode, Some(target))
|
||||||
} else {
|
} else {
|
||||||
@ -140,3 +138,92 @@ fn format_permissions(mode: u32) -> String {
|
|||||||
}
|
}
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// format_permissions
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regular_file_755() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFREG | 0o755), "-rwxr-xr-x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_755() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFDIR | 0o755), "drwxr-xr-x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn symlink_777() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFLNK | 0o777), "Lrwxrwxrwx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regular_file_000() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFREG | 0o000), "----------");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regular_file_644() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFREG | 0o644), "-rw-r--r--");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_device() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFBLK | 0o660), "brw-rw----");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn char_device() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFCHR | 0o666), "crw-rw-rw-");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fifo() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFIFO | 0o644), "prw-r--r--");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn socket() {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFSOCK | 0o755), "Srwxr-xr-x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_type() {
|
||||||
|
assert_eq!(format_permissions(0o755), "?rwxr-xr-x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setuid_in_mode_only_affects_lower_bits() {
|
||||||
|
// setuid (0o4755) — format_permissions masks with 0o777, so same as 0o755
|
||||||
|
assert_eq!(
|
||||||
|
format_permissions(libc::S_IFREG | 0o4755),
|
||||||
|
format_permissions(libc::S_IFREG | 0o755),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_always_10_chars() {
|
||||||
|
for mode in [0o000, 0o777, 0o644, 0o755, 0o4755] {
|
||||||
|
assert_eq!(format_permissions(libc::S_IFREG | mode).len(), 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta_to_file_type — needs real filesystem
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meta_regular_file() {
|
||||||
|
let f = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let meta = std::fs::metadata(f.path()).unwrap();
|
||||||
|
assert_eq!(meta_to_file_type(&meta), FileType::FILE_TYPE_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meta_directory() {
|
||||||
|
let d = tempfile::TempDir::new().unwrap();
|
||||||
|
let meta = std::fs::metadata(d.path()).unwrap();
|
||||||
|
assert_eq!(meta_to_file_type(&meta), FileType::FILE_TYPE_DIRECTORY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -30,34 +30,27 @@ impl FilesystemServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_path(&self, path: &str, ctx: &Context) -> Result<String, ConnectError> {
|
fn resolve_path(&self, path: &str) -> Result<String, ConnectError> {
|
||||||
let username = extract_username(ctx).unwrap_or_else(|| self.state.defaults.user.clone());
|
let username = self.state.defaults.user();
|
||||||
let user = lookup_user(&username).map_err(|e| {
|
let user = lookup_user(&username).map_err(|e| {
|
||||||
ConnectError::new(ErrorCode::Unauthenticated, format!("invalid user: {e}"))
|
ConnectError::new(ErrorCode::Unauthenticated, format!("invalid user: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let home_dir = user.dir.to_string_lossy().to_string();
|
let home_dir = user.dir.to_string_lossy().to_string();
|
||||||
let default_workdir = self.state.defaults.workdir.as_deref();
|
let default_workdir = self.state.defaults.workdir();
|
||||||
|
|
||||||
expand_and_resolve(path, &home_dir, default_workdir)
|
expand_and_resolve(path, &home_dir, default_workdir.as_deref())
|
||||||
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))
|
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_username(ctx: &Context) -> Option<String> {
|
|
||||||
ctx.extensions.get::<AuthUser>().map(|u| u.0.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AuthUser(pub String);
|
|
||||||
|
|
||||||
impl Filesystem for FilesystemServiceImpl {
|
impl Filesystem for FilesystemServiceImpl {
|
||||||
async fn stat(
|
async fn stat(
|
||||||
&self,
|
&self,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
request: buffa::view::OwnedView<StatRequestView<'static>>,
|
request: buffa::view::OwnedView<StatRequestView<'static>>,
|
||||||
) -> Result<(StatResponse, Context), ConnectError> {
|
) -> Result<(StatResponse, Context), ConnectError> {
|
||||||
let path = self.resolve_path(request.path, &ctx)?;
|
let path = self.resolve_path(request.path)?;
|
||||||
let entry = build_entry_info(&path)?;
|
let entry = build_entry_info(&path)?;
|
||||||
Ok((
|
Ok((
|
||||||
StatResponse {
|
StatResponse {
|
||||||
@ -73,7 +66,7 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
request: buffa::view::OwnedView<MakeDirRequestView<'static>>,
|
request: buffa::view::OwnedView<MakeDirRequestView<'static>>,
|
||||||
) -> Result<(MakeDirResponse, Context), ConnectError> {
|
) -> Result<(MakeDirResponse, Context), ConnectError> {
|
||||||
let path = self.resolve_path(request.path, &ctx)?;
|
let path = self.resolve_path(request.path)?;
|
||||||
|
|
||||||
match std::fs::metadata(&path) {
|
match std::fs::metadata(&path) {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
@ -97,9 +90,8 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user.clone());
|
let username = self.state.defaults.user();
|
||||||
let user =
|
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
|
||||||
|
|
||||||
ensure_dirs(&path, user.uid, user.gid)
|
ensure_dirs(&path, user.uid, user.gid)
|
||||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
.map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||||
@ -119,12 +111,11 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
request: buffa::view::OwnedView<MoveRequestView<'static>>,
|
request: buffa::view::OwnedView<MoveRequestView<'static>>,
|
||||||
) -> Result<(MoveResponse, Context), ConnectError> {
|
) -> Result<(MoveResponse, Context), ConnectError> {
|
||||||
let source = self.resolve_path(request.source, &ctx)?;
|
let source = self.resolve_path(request.source)?;
|
||||||
let destination = self.resolve_path(request.destination, &ctx)?;
|
let destination = self.resolve_path(request.destination)?;
|
||||||
|
|
||||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user.clone());
|
let username = self.state.defaults.user();
|
||||||
let user =
|
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
|
||||||
|
|
||||||
if let Some(parent) = Path::new(&destination).parent() {
|
if let Some(parent) = Path::new(&destination).parent() {
|
||||||
ensure_dirs(&parent.to_string_lossy(), user.uid, user.gid)
|
ensure_dirs(&parent.to_string_lossy(), user.uid, user.gid)
|
||||||
@ -159,28 +150,35 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
depth = 1;
|
depth = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = self.resolve_path(request.path, &ctx)?;
|
let path = self.resolve_path(request.path)?;
|
||||||
|
|
||||||
let resolved = std::fs::canonicalize(&path).map_err(|e| {
|
// The recursive walk stats every entry (plus uid/gid lookups) — on a
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
// large tree that is seconds of blocking syscalls, so it runs on the
|
||||||
ConnectError::new(ErrorCode::NotFound, format!("path not found: {e}"))
|
// blocking pool instead of a runtime worker thread.
|
||||||
} else {
|
let entries = tokio::task::spawn_blocking(move || {
|
||||||
ConnectError::new(ErrorCode::Internal, format!("error resolving path: {e}"))
|
let resolved = std::fs::canonicalize(&path).map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
ConnectError::new(ErrorCode::NotFound, format!("path not found: {e}"))
|
||||||
|
} else {
|
||||||
|
ConnectError::new(ErrorCode::Internal, format!("error resolving path: {e}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let resolved_str = resolved.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let meta = std::fs::metadata(&resolved).map_err(|e| {
|
||||||
|
ConnectError::new(ErrorCode::Internal, format!("error getting file info: {e}"))
|
||||||
|
})?;
|
||||||
|
if !meta.is_dir() {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::InvalidArgument,
|
||||||
|
format!("path is not a directory: {path}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
})?;
|
|
||||||
let resolved_str = resolved.to_string_lossy().to_string();
|
|
||||||
|
|
||||||
let meta = std::fs::metadata(&resolved).map_err(|e| {
|
walk_dir(&path, &resolved_str, depth)
|
||||||
ConnectError::new(ErrorCode::Internal, format!("error getting file info: {e}"))
|
})
|
||||||
})?;
|
.await
|
||||||
if !meta.is_dir() {
|
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("list_dir task: {e}")))??;
|
||||||
return Err(ConnectError::new(
|
|
||||||
ErrorCode::InvalidArgument,
|
|
||||||
format!("path is not a directory: {path}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let entries = walk_dir(&path, &resolved_str, depth)?;
|
|
||||||
Ok((
|
Ok((
|
||||||
ListDirResponse {
|
ListDirResponse {
|
||||||
entries,
|
entries,
|
||||||
@ -195,18 +193,30 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
request: buffa::view::OwnedView<RemoveRequestView<'static>>,
|
request: buffa::view::OwnedView<RemoveRequestView<'static>>,
|
||||||
) -> Result<(RemoveResponse, Context), ConnectError> {
|
) -> Result<(RemoveResponse, Context), ConnectError> {
|
||||||
let path = self.resolve_path(request.path, &ctx)?;
|
let path = self.resolve_path(request.path)?;
|
||||||
|
|
||||||
if let Err(e1) = std::fs::remove_dir_all(&path) {
|
// remove_dir_all recurses through the whole tree — blocking pool, not
|
||||||
if let Err(e2) = std::fs::remove_file(&path) {
|
// a runtime worker thread.
|
||||||
return Err(ConnectError::new(
|
tokio::task::spawn_blocking(move || {
|
||||||
ErrorCode::Internal,
|
if let Err(e1) = std::fs::remove_dir_all(&path) {
|
||||||
format!("error removing: {e1}; also tried as file: {e2}"),
|
if let Err(e2) = std::fs::remove_file(&path) {
|
||||||
));
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::Internal,
|
||||||
|
format!("error removing: {e1}; also tried as file: {e2}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("remove task: {e}")))??;
|
||||||
|
|
||||||
Ok((RemoveResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
RemoveResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn watch_dir(
|
async fn watch_dir(
|
||||||
@ -233,7 +243,7 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
) -> Result<(CreateWatcherResponse, Context), ConnectError> {
|
) -> Result<(CreateWatcherResponse, Context), ConnectError> {
|
||||||
use notify::{RecursiveMode, Watcher};
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
|
||||||
let path = self.resolve_path(request.path, &ctx)?;
|
let path = self.resolve_path(request.path)?;
|
||||||
let recursive = request.recursive;
|
let recursive = request.recursive;
|
||||||
|
|
||||||
if let Ok(true) = crate::rpc::entry::is_network_mount(&path) {
|
if let Ok(true) = crate::rpc::entry::is_network_mount(&path) {
|
||||||
@ -247,8 +257,8 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
let events: Arc<Mutex<Vec<FilesystemEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
let events: Arc<Mutex<Vec<FilesystemEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let events_cb = Arc::clone(&events);
|
let events_cb = Arc::clone(&events);
|
||||||
|
|
||||||
let mut watcher = notify::recommended_watcher(
|
let mut watcher =
|
||||||
move |res: Result<notify::Event, notify::Error>| {
|
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||||
if let Ok(event) = res {
|
if let Ok(event) = res {
|
||||||
let event_type = match event.kind {
|
let event_type = match event.kind {
|
||||||
notify::EventKind::Create(_) => EventType::EVENT_TYPE_CREATE,
|
notify::EventKind::Create(_) => EventType::EVENT_TYPE_CREATE,
|
||||||
@ -275,11 +285,13 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
.map_err(|e| {
|
||||||
.map_err(|e| {
|
ConnectError::new(
|
||||||
ConnectError::new(ErrorCode::Internal, format!("failed to create watcher: {e}"))
|
ErrorCode::Internal,
|
||||||
})?;
|
format!("failed to create watcher: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let mode = if recursive {
|
let mode = if recursive {
|
||||||
RecursiveMode::Recursive
|
RecursiveMode::Recursive
|
||||||
@ -342,7 +354,12 @@ impl Filesystem for FilesystemServiceImpl {
|
|||||||
) -> Result<(RemoveWatcherResponse, Context), ConnectError> {
|
) -> Result<(RemoveWatcherResponse, Context), ConnectError> {
|
||||||
let watcher_id: &str = request.watcher_id;
|
let watcher_id: &str = request.watcher_id;
|
||||||
self.watchers.remove(watcher_id);
|
self.watchers.remove(watcher_id);
|
||||||
Ok((RemoveWatcherResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
RemoveWatcherResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
pub mod pb;
|
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
|
pub mod filesystem_service;
|
||||||
|
pub mod pb;
|
||||||
pub mod process_handler;
|
pub mod process_handler;
|
||||||
pub mod process_service;
|
pub mod process_service;
|
||||||
pub mod filesystem_service;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::rpc::process_service::ProcessServiceImpl;
|
|
||||||
use crate::rpc::filesystem_service::FilesystemServiceImpl;
|
use crate::rpc::filesystem_service::FilesystemServiceImpl;
|
||||||
|
use crate::rpc::process_service::ProcessServiceImpl;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use pb::process::ProcessExt;
|
|
||||||
use pb::filesystem::FilesystemExt;
|
use pb::filesystem::FilesystemExt;
|
||||||
|
use pb::process::ProcessExt;
|
||||||
|
|
||||||
/// Build the connect-rust Router with both RPC services registered.
|
/// Build the connect-rust Router with both RPC services registered.
|
||||||
pub fn rpc_router(state: Arc<AppState>) -> connectrpc::Router {
|
pub fn rpc_router(state: Arc<AppState>) -> connectrpc::Router {
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
#![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;
|
||||||
use ::buffa_types;
|
use ::buffa_types;
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
|
use std::os::fd::{AsFd, OwnedFd};
|
||||||
|
use std::os::unix::io::{AsRawFd, RawFd};
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use connectrpc::{ConnectError, ErrorCode};
|
use connectrpc::{ConnectError, ErrorCode};
|
||||||
use nix::pty::{openpty, Winsize};
|
use nix::pty::{Winsize, openpty};
|
||||||
use nix::sys::signal::{self, Signal};
|
use nix::sys::signal::{self, Signal};
|
||||||
use nix::unistd::Pid;
|
use nix::unistd::Pid;
|
||||||
|
use tokio::io::Interest;
|
||||||
|
use tokio::io::unix::AsyncFd;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::rpc::pb::process::*;
|
use crate::rpc::pb::process::*;
|
||||||
@ -15,6 +21,28 @@ const STD_CHUNK_SIZE: usize = 32768;
|
|||||||
const PTY_CHUNK_SIZE: usize = 16384;
|
const PTY_CHUNK_SIZE: usize = 16384;
|
||||||
const BROADCAST_CAPACITY: usize = 4096;
|
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;
|
||||||
|
|
||||||
|
// Bound on how long one input write may wait for the target process to drain
|
||||||
|
// its buffer. The stdin pipe / pty master is O_NONBLOCK, so a full buffer
|
||||||
|
// parks the writing task (never an OS thread); within this window a
|
||||||
|
// briefly-full buffer recovers transparently, past it the write fails with
|
||||||
|
// ResourceExhausted instead of hanging forever on a process that stopped
|
||||||
|
// reading its input.
|
||||||
|
const INPUT_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum DataEvent {
|
pub enum DataEvent {
|
||||||
Stdout(Vec<u8>),
|
Stdout(Vec<u8>),
|
||||||
@ -30,6 +58,121 @@ pub struct EndEvent {
|
|||||||
pub error: Option<String>,
|
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(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
// The pty master is O_NONBLOCK for the input write path and
|
||||||
|
// shares its file description with this reader. Wait for
|
||||||
|
// readability and retry; on POLLHUP/POLLERR the retried read
|
||||||
|
// returns 0/EIO and ends the loop.
|
||||||
|
let mut pfd = libc::pollfd {
|
||||||
|
fd,
|
||||||
|
events: libc::POLLIN,
|
||||||
|
revents: 0,
|
||||||
|
};
|
||||||
|
if unsafe { libc::poll(&mut pfd, 1, -1) } < 0 {
|
||||||
|
let errno = std::io::Error::last_os_error().raw_os_error();
|
||||||
|
if errno != Some(libc::EINTR) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !acc.is_empty() {
|
||||||
|
publish(acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProcessHandle {
|
pub struct ProcessHandle {
|
||||||
pub config: ProcessConfig,
|
pub config: ProcessConfig,
|
||||||
pub tag: Option<String>,
|
pub tag: Option<String>,
|
||||||
@ -37,9 +180,15 @@ pub struct ProcessHandle {
|
|||||||
|
|
||||||
data_tx: broadcast::Sender<DataEvent>,
|
data_tx: broadcast::Sender<DataEvent>,
|
||||||
end_tx: broadcast::Sender<EndEvent>,
|
end_tx: broadcast::Sender<EndEvent>,
|
||||||
|
ended: Mutex<Option<EndEvent>>,
|
||||||
|
output_log: Mutex<OutputLog>,
|
||||||
|
|
||||||
stdin: Mutex<Option<std::process::ChildStdin>>,
|
stdin: Mutex<Option<std::process::ChildStdin>>,
|
||||||
pty_master: Mutex<Option<std::fs::File>>,
|
pty_master: Mutex<Option<std::fs::File>>,
|
||||||
|
// Serializes input writes so concurrent chunks land in order. A tokio
|
||||||
|
// mutex: a writer parked on a full buffer holds it across an await, and
|
||||||
|
// the next writer waits as a suspended task — no OS thread is pinned.
|
||||||
|
input_gate: tokio::sync::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessHandle {
|
impl ProcessHandle {
|
||||||
@ -47,42 +196,79 @@ impl ProcessHandle {
|
|||||||
self.data_tx.subscribe()
|
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> {
|
pub fn subscribe_end(&self) -> broadcast::Receiver<EndEvent> {
|
||||||
self.end_tx.subscribe()
|
self.end_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cached_end(&self) -> Option<EndEvent> {
|
||||||
|
self.ended.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn send_signal(&self, sig: Signal) -> Result<(), ConnectError> {
|
pub fn send_signal(&self, sig: Signal) -> Result<(), ConnectError> {
|
||||||
signal::kill(Pid::from_raw(self.pid as i32), sig).map_err(|e| {
|
// Signal the whole process group (negative pid), not just the immediate
|
||||||
|
// /bin/sh wrapper. Otherwise children the process spawned are orphaned
|
||||||
|
// and keep running. Both spawn paths make the process a group leader
|
||||||
|
// (setsid for pty, setpgid for pipe), so pgid == pid.
|
||||||
|
signal::kill(Pid::from_raw(-(self.pid as i32)), sig).map_err(|e| {
|
||||||
ConnectError::new(ErrorCode::Internal, format!("error sending signal: {e}"))
|
ConnectError::new(ErrorCode::Internal, format!("error sending signal: {e}"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_stdin(&self, data: &[u8]) -> Result<(), ConnectError> {
|
pub async fn write_stdin(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||||
use std::io::Write;
|
let _ordered = self.input_gate.lock().await;
|
||||||
let mut guard = self.stdin.lock().unwrap();
|
// Dup the fd under the std mutex, then release it before awaiting:
|
||||||
match guard.as_mut() {
|
// close_stdin stays responsive while a write is parked, and the fd
|
||||||
Some(stdin) => stdin.write_all(data).map_err(|e| {
|
// stays valid for this write even if stdin is closed concurrently.
|
||||||
ConnectError::new(ErrorCode::Internal, format!("error writing to stdin: {e}"))
|
let fd = {
|
||||||
}),
|
let guard = self.stdin.lock().unwrap();
|
||||||
None => Err(ConnectError::new(
|
match guard.as_ref() {
|
||||||
ErrorCode::FailedPrecondition,
|
Some(stdin) => dup_writer_fd(stdin, "stdin")?,
|
||||||
"stdin not enabled or closed",
|
None => {
|
||||||
)),
|
return Err(ConnectError::new(
|
||||||
}
|
ErrorCode::FailedPrecondition,
|
||||||
|
"stdin not enabled or closed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_nonblocking(fd, data, "stdin").await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_pty(&self, data: &[u8]) -> Result<(), ConnectError> {
|
pub async fn write_pty(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||||
use std::io::Write;
|
let _ordered = self.input_gate.lock().await;
|
||||||
let mut guard = self.pty_master.lock().unwrap();
|
let fd = {
|
||||||
match guard.as_mut() {
|
let guard = self.pty_master.lock().unwrap();
|
||||||
Some(master) => master.write_all(data).map_err(|e| {
|
match guard.as_ref() {
|
||||||
ConnectError::new(ErrorCode::Internal, format!("error writing to pty: {e}"))
|
Some(master) => dup_writer_fd(master, "pty")?,
|
||||||
}),
|
None => {
|
||||||
None => Err(ConnectError::new(
|
return Err(ConnectError::new(
|
||||||
ErrorCode::FailedPrecondition,
|
ErrorCode::FailedPrecondition,
|
||||||
"pty not assigned to process",
|
"pty not assigned to process",
|
||||||
)),
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
write_nonblocking(fd, data, "pty").await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn close_stdin(&self) -> Result<(), ConnectError> {
|
pub fn close_stdin(&self) -> Result<(), ConnectError> {
|
||||||
@ -128,6 +314,116 @@ impl ProcessHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dup_writer_fd(f: &impl AsFd, what: &str) -> Result<OwnedFd, ConnectError> {
|
||||||
|
f.as_fd()
|
||||||
|
.try_clone_to_owned()
|
||||||
|
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("dup {what} fd: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `data` to a non-blocking fd from async context. Small interactive
|
||||||
|
/// writes complete inline; a full buffer surfaces as WouldBlock and we await
|
||||||
|
/// writability with a deadline, so a process that stopped reading its input
|
||||||
|
/// costs a parked task — never a pinned OS thread.
|
||||||
|
async fn write_nonblocking(fd: OwnedFd, data: &[u8], what: &str) -> Result<(), ConnectError> {
|
||||||
|
let afd = AsyncFd::with_interest(fd, Interest::WRITABLE)
|
||||||
|
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("register {what} fd: {e}")))?;
|
||||||
|
let deadline = tokio::time::Instant::now() + INPUT_WRITE_TIMEOUT;
|
||||||
|
let mut written = 0usize;
|
||||||
|
while written < data.len() {
|
||||||
|
let rest = &data[written..];
|
||||||
|
let n = unsafe {
|
||||||
|
libc::write(
|
||||||
|
afd.get_ref().as_raw_fd(),
|
||||||
|
rest.as_ptr() as *const libc::c_void,
|
||||||
|
rest.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if n >= 0 {
|
||||||
|
written += n as usize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
match err.kind() {
|
||||||
|
std::io::ErrorKind::WouldBlock => {
|
||||||
|
match tokio::time::timeout_at(deadline, afd.writable()).await {
|
||||||
|
Ok(Ok(mut ready)) => ready.clear_ready(),
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::Internal,
|
||||||
|
format!("error waiting for {what} to become writable: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::ResourceExhausted,
|
||||||
|
format!(
|
||||||
|
"{what} input buffer full ({written} of {} bytes written): process is not reading its input",
|
||||||
|
data.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::Interrupted => {}
|
||||||
|
_ => {
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::Internal,
|
||||||
|
format!("error writing to {what}: {err}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_nonblocking(fd: RawFd) -> std::io::Result<()> {
|
||||||
|
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||||
|
if flags < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SpawnedProcess {
|
||||||
|
pub handle: Arc<ProcessHandle>,
|
||||||
|
pub data_rx: broadcast::Receiver<DataEvent>,
|
||||||
|
pub end_rx: broadcast::Receiver<EndEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch the child to the target gid/uid, dropping supplementary groups first
|
||||||
|
/// and checking every step. Order is load-bearing: supplementary groups must be
|
||||||
|
/// cleared while still privileged (setgroups after setuid fails), otherwise a
|
||||||
|
/// non-root child inherits PID 1's root supplementary groups. Checking the
|
||||||
|
/// return values turns a failed drop into a failed spawn instead of silently
|
||||||
|
/// executing the command as root.
|
||||||
|
///
|
||||||
|
/// Runs inside `pre_exec`, so it must stay async-signal-safe: raw libc calls
|
||||||
|
/// only, no allocation.
|
||||||
|
///
|
||||||
|
/// When envd is not running as root (dev/test hosts), it holds no privilege to
|
||||||
|
/// drop — `setgroups`/`setgid`/`setuid` would fail with EPERM. In that case the
|
||||||
|
/// child already runs unprivileged as the current user, so skip the drop.
|
||||||
|
unsafe fn switch_user(uid: libc::uid_t, gid: libc::gid_t) -> std::io::Result<()> {
|
||||||
|
unsafe {
|
||||||
|
if libc::geteuid() != 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if libc::setgroups(0, std::ptr::null()) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if libc::setgid(gid) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
if libc::setuid(uid) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_process(
|
pub fn spawn_process(
|
||||||
cmd_str: &str,
|
cmd_str: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
@ -138,13 +434,16 @@ pub fn spawn_process(
|
|||||||
tag: Option<String>,
|
tag: Option<String>,
|
||||||
user: &nix::unistd::User,
|
user: &nix::unistd::User,
|
||||||
default_env_vars: &dashmap::DashMap<String, String>,
|
default_env_vars: &dashmap::DashMap<String, String>,
|
||||||
) -> Result<Arc<ProcessHandle>, ConnectError> {
|
) -> Result<SpawnedProcess, ConnectError> {
|
||||||
let mut env: Vec<(String, String)> = Vec::new();
|
let mut env: Vec<(String, String)> = Vec::new();
|
||||||
env.push(("PATH".into(), std::env::var("PATH").unwrap_or_default()));
|
env.push(("PATH".into(), std::env::var("PATH").unwrap_or_default()));
|
||||||
let home = user.dir.to_string_lossy().to_string();
|
let home = user.dir.to_string_lossy().to_string();
|
||||||
env.push(("HOME".into(), home));
|
env.push(("HOME".into(), home));
|
||||||
env.push(("USER".into(), user.name.clone()));
|
env.push(("USER".into(), user.name.clone()));
|
||||||
env.push(("LOGNAME".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| {
|
default_env_vars.iter().for_each(|entry| {
|
||||||
env.push((entry.key().clone(), entry.value().clone()));
|
env.push((entry.key().clone(), entry.value().clone()));
|
||||||
@ -154,10 +453,54 @@ pub fn spawn_process(
|
|||||||
env.push((k.clone(), v.clone()));
|
env.push((k.clone(), v.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset the child's nice value only when envd itself was started at an
|
||||||
|
// elevated nice value (delta > 0 means raising the nice number / lowering
|
||||||
|
// priority, which is permitted for non-root processes). A non-root process
|
||||||
|
// cannot improve its priority, so skip the `nice` wrapper otherwise — it
|
||||||
|
// would fail with EPERM ("cannot set niceness: permission denied") for
|
||||||
|
// 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 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}""#)
|
||||||
|
} else {
|
||||||
|
r#""$@""#.to_string()
|
||||||
|
};
|
||||||
|
let nice_prefix = if nice_delta > 0 {
|
||||||
|
format!("/usr/bin/nice -n {nice_delta} ")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
let oom_script = format!(
|
let oom_script = format!(
|
||||||
r#"echo 100 > /proc/$$/oom_score_adj && exec /usr/bin/nice -n {} "${{@}}""#,
|
r#"echo 100 > /proc/$$/oom_score_adj
|
||||||
nice_delta
|
{profile_source}
|
||||||
|
exec {nice_prefix}{target}"#
|
||||||
);
|
);
|
||||||
let mut wrapper_args = vec![
|
let mut wrapper_args = vec![
|
||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
@ -196,7 +539,7 @@ pub fn spawn_process(
|
|||||||
let master_fd = pty_result.master;
|
let master_fd = pty_result.master;
|
||||||
let slave_fd = pty_result.slave;
|
let slave_fd = pty_result.slave;
|
||||||
|
|
||||||
let mut command = std::process::Command::new("/bin/sh");
|
let mut command = std::process::Command::new(&shell);
|
||||||
command
|
command
|
||||||
.args(&wrapper_args)
|
.args(&wrapper_args)
|
||||||
.env_clear()
|
.env_clear()
|
||||||
@ -218,8 +561,7 @@ pub fn spawn_process(
|
|||||||
if slave_raw > 2 {
|
if slave_raw > 2 {
|
||||||
libc::close(slave_raw);
|
libc::close(slave_raw);
|
||||||
}
|
}
|
||||||
libc::setgid(gid);
|
switch_user(uid, gid)?;
|
||||||
libc::setuid(uid);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -229,13 +571,29 @@ pub fn spawn_process(
|
|||||||
command.stderr(Stdio::null());
|
command.stderr(Stdio::null());
|
||||||
|
|
||||||
let child = command.spawn().map_err(|e| {
|
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);
|
drop(slave_fd);
|
||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
let master_file: std::fs::File = master_fd.into();
|
let master_file: std::fs::File = master_fd.into();
|
||||||
|
// O_NONBLOCK is per file description, so it covers the write path and
|
||||||
|
// the reader clone alike; coalesce_read_loop handles the resulting
|
||||||
|
// WouldBlock by polling. Without it a write would block an OS thread,
|
||||||
|
// so treat failure as a failed spawn.
|
||||||
|
if let Err(e) = set_nonblocking(master_file.as_raw_fd()) {
|
||||||
|
let mut child = child;
|
||||||
|
let _ = signal::kill(Pid::from_raw(-(pid as i32)), Signal::SIGKILL);
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::Internal,
|
||||||
|
format!("set pty master non-blocking: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
let master_clone = master_file.try_clone().unwrap();
|
let master_clone = master_file.try_clone().unwrap();
|
||||||
|
|
||||||
let handle = Arc::new(ProcessHandle {
|
let handle = Arc::new(ProcessHandle {
|
||||||
@ -244,52 +602,57 @@ pub fn spawn_process(
|
|||||||
pid,
|
pid,
|
||||||
data_tx: data_tx.clone(),
|
data_tx: data_tx.clone(),
|
||||||
end_tx: end_tx.clone(),
|
end_tx: end_tx.clone(),
|
||||||
|
ended: Mutex::new(None),
|
||||||
|
output_log: Mutex::new(OutputLog::default()),
|
||||||
stdin: Mutex::new(None),
|
stdin: Mutex::new(None),
|
||||||
pty_master: Mutex::new(Some(master_file)),
|
pty_master: Mutex::new(Some(master_file)),
|
||||||
|
input_gate: tokio::sync::Mutex::new(()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let data_tx_clone = data_tx.clone();
|
let data_rx = handle.subscribe_data();
|
||||||
std::thread::spawn(move || {
|
let end_rx = handle.subscribe_end();
|
||||||
let mut master = master_clone;
|
|
||||||
let mut buf = vec![0u8; PTY_CHUNK_SIZE];
|
let handle_for_reader = Arc::clone(&handle);
|
||||||
loop {
|
let pty_reader = std::thread::spawn(move || {
|
||||||
match master.read(&mut buf) {
|
coalesce_read_loop(master_clone, PTY_CHUNK_SIZE, |chunk| {
|
||||||
Ok(0) => break,
|
handle_for_reader.publish_data(DataEvent::Pty(chunk));
|
||||||
Ok(n) => {
|
});
|
||||||
let _ = data_tx_clone.send(DataEvent::Pty(buf[..n].to_vec()));
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let end_tx_clone = end_tx.clone();
|
let end_tx_clone = end_tx.clone();
|
||||||
|
let handle_for_waiter = Arc::clone(&handle);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut child = child;
|
let mut child = child;
|
||||||
match child.wait() {
|
let status = child.wait();
|
||||||
Ok(s) => {
|
// Drain the pty to EOF before publishing the end event so trailing
|
||||||
let _ = end_tx_clone.send(EndEvent {
|
// output is never lost to a process-exit/pty-read race.
|
||||||
exit_code: s.code().unwrap_or(-1),
|
let _ = pty_reader.join();
|
||||||
exited: s.code().is_some(),
|
let end_event = match status {
|
||||||
status: format!("{s}"),
|
Ok(s) => EndEvent {
|
||||||
error: None,
|
exit_code: s.code().unwrap_or(-1),
|
||||||
});
|
exited: s.code().is_some(),
|
||||||
}
|
status: format!("{s}"),
|
||||||
Err(e) => {
|
error: None,
|
||||||
let _ = end_tx_clone.send(EndEvent {
|
},
|
||||||
exit_code: -1,
|
Err(e) => EndEvent {
|
||||||
exited: false,
|
exit_code: -1,
|
||||||
status: "error".into(),
|
exited: false,
|
||||||
error: Some(e.to_string()),
|
status: "error".into(),
|
||||||
});
|
error: Some(e.to_string()),
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
*handle_for_waiter.ended.lock().unwrap() = Some(end_event.clone());
|
||||||
|
let _ = end_tx_clone.send(end_event);
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!(pid, cmd = cmd_str, "process started (pty)");
|
tracing::info!(pid, cmd = cmd_str, "process started (pty)");
|
||||||
Ok(handle)
|
Ok(SpawnedProcess {
|
||||||
|
handle,
|
||||||
|
data_rx,
|
||||||
|
end_rx,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
let mut command = std::process::Command::new("/bin/sh");
|
let mut command = std::process::Command::new(&shell);
|
||||||
command
|
command
|
||||||
.args(&wrapper_args)
|
.args(&wrapper_args)
|
||||||
.env_clear()
|
.env_clear()
|
||||||
@ -306,8 +669,12 @@ pub fn spawn_process(
|
|||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
command.pre_exec(move || {
|
command.pre_exec(move || {
|
||||||
libc::setgid(gid);
|
// Become a process-group leader so SendSignal can kill the
|
||||||
libc::setuid(uid);
|
// whole group, not just this wrapper. The pty path gets this
|
||||||
|
// for free via setsid().
|
||||||
|
nix::unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0))
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||||
|
switch_user(uid, gid)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -321,72 +688,90 @@ pub fn spawn_process(
|
|||||||
let stdout = child.stdout.take();
|
let stdout = child.stdout.take();
|
||||||
let stderr = child.stderr.take();
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
// Non-blocking stdin is what lets write_stdin fail fast instead of
|
||||||
|
// pinning an OS thread when the process stops draining its input.
|
||||||
|
// Only the pipe's write end is affected — the child's read end is a
|
||||||
|
// separate file description.
|
||||||
|
if let Some(s) = stdin.as_ref() {
|
||||||
|
if let Err(e) = set_nonblocking(s.as_raw_fd()) {
|
||||||
|
let _ = signal::kill(Pid::from_raw(-(pid as i32)), Signal::SIGKILL);
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(ConnectError::new(
|
||||||
|
ErrorCode::Internal,
|
||||||
|
format!("set stdin non-blocking: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let handle = Arc::new(ProcessHandle {
|
let handle = Arc::new(ProcessHandle {
|
||||||
config,
|
config,
|
||||||
tag,
|
tag,
|
||||||
pid,
|
pid,
|
||||||
data_tx: data_tx.clone(),
|
data_tx: data_tx.clone(),
|
||||||
end_tx: end_tx.clone(),
|
end_tx: end_tx.clone(),
|
||||||
|
ended: Mutex::new(None),
|
||||||
|
output_log: Mutex::new(OutputLog::default()),
|
||||||
stdin: Mutex::new(stdin),
|
stdin: Mutex::new(stdin),
|
||||||
pty_master: Mutex::new(None),
|
pty_master: Mutex::new(None),
|
||||||
|
input_gate: tokio::sync::Mutex::new(()),
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(mut out) = stdout {
|
let data_rx = handle.subscribe_data();
|
||||||
let tx = data_tx.clone();
|
let end_rx = handle.subscribe_end();
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
let mut output_readers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
loop {
|
|
||||||
match out.read(&mut buf) {
|
if let Some(out) = stdout {
|
||||||
Ok(0) => break,
|
let handle_for_reader = Arc::clone(&handle);
|
||||||
Ok(n) => {
|
output_readers.push(std::thread::spawn(move || {
|
||||||
let _ = tx.send(DataEvent::Stdout(buf[..n].to_vec()));
|
coalesce_read_loop(out, STD_CHUNK_SIZE, |chunk| {
|
||||||
}
|
handle_for_reader.publish_data(DataEvent::Stdout(chunk));
|
||||||
Err(_) => break,
|
});
|
||||||
}
|
}));
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut err_pipe) = stderr {
|
if let Some(err_pipe) = stderr {
|
||||||
let tx = data_tx.clone();
|
let handle_for_reader = Arc::clone(&handle);
|
||||||
std::thread::spawn(move || {
|
output_readers.push(std::thread::spawn(move || {
|
||||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
coalesce_read_loop(err_pipe, STD_CHUNK_SIZE, |chunk| {
|
||||||
loop {
|
handle_for_reader.publish_data(DataEvent::Stderr(chunk));
|
||||||
match err_pipe.read(&mut buf) {
|
});
|
||||||
Ok(0) => break,
|
}));
|
||||||
Ok(n) => {
|
|
||||||
let _ = tx.send(DataEvent::Stderr(buf[..n].to_vec()));
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let end_tx_clone = end_tx.clone();
|
let end_tx_clone = end_tx.clone();
|
||||||
|
let handle_for_waiter = Arc::clone(&handle);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
match child.wait() {
|
let status = child.wait();
|
||||||
Ok(s) => {
|
// Drain stdout/stderr to EOF before publishing the end event so
|
||||||
let _ = end_tx_clone.send(EndEvent {
|
// trailing output is never lost to a process-exit/pipe-read race.
|
||||||
exit_code: s.code().unwrap_or(-1),
|
for reader in output_readers {
|
||||||
exited: s.code().is_some(),
|
let _ = reader.join();
|
||||||
status: format!("{s}"),
|
|
||||||
error: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = end_tx_clone.send(EndEvent {
|
|
||||||
exit_code: -1,
|
|
||||||
exited: false,
|
|
||||||
status: "error".into(),
|
|
||||||
error: Some(e.to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let end_event = match status {
|
||||||
|
Ok(s) => EndEvent {
|
||||||
|
exit_code: s.code().unwrap_or(-1),
|
||||||
|
exited: s.code().is_some(),
|
||||||
|
status: format!("{s}"),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(e) => EndEvent {
|
||||||
|
exit_code: -1,
|
||||||
|
exited: false,
|
||||||
|
status: "error".into(),
|
||||||
|
error: Some(e.to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
*handle_for_waiter.ended.lock().unwrap() = Some(end_event.clone());
|
||||||
|
let _ = end_tx_clone.send(end_event);
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!(pid, cmd = cmd_str, "process started (pipe)");
|
tracing::info!(pid, cmd = cmd_str, "process started (pipe)");
|
||||||
Ok(handle)
|
Ok(SpawnedProcess {
|
||||||
|
handle,
|
||||||
|
data_rx,
|
||||||
|
end_rx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,6 +782,117 @@ fn current_nice() -> i32 {
|
|||||||
if *libc::__errno_location() != 0 {
|
if *libc::__errno_location() != 0 {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
20 - prio
|
// getpriority(PRIO_PROCESS, 0) returns the nice value directly,
|
||||||
|
// in the range [-20, 19]; the normal default is 0.
|
||||||
|
prio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn spawn(cmd: &str, enable_stdin: bool, pty: Option<(u16, u16)>) -> SpawnedProcess {
|
||||||
|
let user = nix::unistd::User::from_uid(nix::unistd::getuid())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
spawn_process(
|
||||||
|
cmd,
|
||||||
|
&[],
|
||||||
|
&HashMap::new(),
|
||||||
|
"/tmp",
|
||||||
|
pty,
|
||||||
|
enable_stdin,
|
||||||
|
None,
|
||||||
|
&user,
|
||||||
|
&dashmap::DashMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the accumulated bytes from `recv` contain `needle`.
|
||||||
|
async fn recv_until_contains(
|
||||||
|
rx: &mut broadcast::Receiver<DataEvent>,
|
||||||
|
needle: &[u8],
|
||||||
|
what: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut acc: Vec<u8> = Vec::new();
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(DataEvent::Stdout(d)) | Ok(DataEvent::Pty(d)) => {
|
||||||
|
acc.extend_from_slice(&d);
|
||||||
|
if acc.windows(needle.len()).any(|w| w == needle) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DataEvent::Stderr(_)) => {}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||||
|
Err(e) => panic!("{what}: data stream closed early: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("{what}: output never contained expected bytes"));
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn write_to_stuck_stdin_errors_instead_of_hanging() {
|
||||||
|
// sleep never reads stdin: the 64K pipe buffer fills and the write
|
||||||
|
// must fail with ResourceExhausted after the bounded wait — the old
|
||||||
|
// write_all here blocked a pool thread forever.
|
||||||
|
let spawned = spawn("sleep 60", true, None);
|
||||||
|
let handle = Arc::clone(&spawned.handle);
|
||||||
|
let big = vec![b'x'; 1024 * 1024];
|
||||||
|
let writer = tokio::spawn(async move { handle.write_stdin(&big).await });
|
||||||
|
|
||||||
|
// While that write is parked, the agent must stay fully responsive:
|
||||||
|
// a fresh process spawns, runs, and reports its exit.
|
||||||
|
let mut quick = spawn("true", false, None);
|
||||||
|
let end = tokio::time::timeout(Duration::from_secs(10), quick.end_rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("agent unresponsive while stdin write pending")
|
||||||
|
.expect("end event");
|
||||||
|
assert!(end.exited);
|
||||||
|
|
||||||
|
let err = tokio::time::timeout(INPUT_WRITE_TIMEOUT + Duration::from_secs(10), writer)
|
||||||
|
.await
|
||||||
|
.expect("stdin write hung past its deadline")
|
||||||
|
.expect("writer task panicked")
|
||||||
|
.expect_err("write into a full pipe nobody reads must error");
|
||||||
|
assert!(
|
||||||
|
matches!(err.code, ErrorCode::ResourceExhausted),
|
||||||
|
"expected ResourceExhausted, got {:?}",
|
||||||
|
err.code
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = spawned.handle.send_signal(Signal::SIGKILL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn stdin_write_and_eof_still_work() {
|
||||||
|
let mut spawned = spawn("cat", true, None);
|
||||||
|
spawned.handle.write_stdin(b"hello\n").await.unwrap();
|
||||||
|
recv_until_contains(&mut spawned.data_rx, b"hello\n", "cat stdout").await;
|
||||||
|
|
||||||
|
// close_stdin must still deliver EOF with the non-blocking pipe.
|
||||||
|
spawned.handle.close_stdin().unwrap();
|
||||||
|
let end = tokio::time::timeout(Duration::from_secs(10), spawned.end_rx.recv())
|
||||||
|
.await
|
||||||
|
.expect("cat did not exit after stdin EOF")
|
||||||
|
.expect("end event");
|
||||||
|
assert_eq!(end.exit_code, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn pty_write_and_echo_still_work() {
|
||||||
|
// O_NONBLOCK on the pty master is shared with the output reader;
|
||||||
|
// this covers both directions surviving it.
|
||||||
|
let mut spawned = spawn("cat", false, Some((80, 24)));
|
||||||
|
spawned.handle.write_pty(b"hello\r").await.unwrap();
|
||||||
|
recv_until_contains(&mut spawned.data_rx, b"hello", "pty echo").await;
|
||||||
|
let _ = spawned.handle.send_signal(Signal::SIGKILL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,9 +4,10 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use connectrpc::{ConnectError, Context, ErrorCode};
|
use connectrpc::{ConnectError, Context, ErrorCode};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use futures::Stream;
|
use futures::{Stream, StreamExt};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::permissions::path::expand_and_resolve;
|
use crate::permissions::path::{expand_and_resolve, expand_tilde};
|
||||||
use crate::permissions::user::lookup_user;
|
use crate::permissions::user::lookup_user;
|
||||||
use crate::rpc::pb::process::*;
|
use crate::rpc::pb::process::*;
|
||||||
use crate::rpc::process_handler::{self, DataEvent, ProcessHandle};
|
use crate::rpc::process_handler::{self, DataEvent, ProcessHandle};
|
||||||
@ -14,14 +15,14 @@ use crate::state::AppState;
|
|||||||
|
|
||||||
pub struct ProcessServiceImpl {
|
pub struct ProcessServiceImpl {
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
processes: DashMap<u32, Arc<ProcessHandle>>,
|
processes: Arc<DashMap<u32, Arc<ProcessHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProcessServiceImpl {
|
impl ProcessServiceImpl {
|
||||||
pub fn new(state: Arc<AppState>) -> Self {
|
pub fn new(state: Arc<AppState>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
state,
|
state,
|
||||||
processes: DashMap::new(),
|
processes: Arc::new(DashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,17 +67,21 @@ impl ProcessServiceImpl {
|
|||||||
fn spawn_from_request(
|
fn spawn_from_request(
|
||||||
&self,
|
&self,
|
||||||
request: &StartRequestView<'_>,
|
request: &StartRequestView<'_>,
|
||||||
) -> Result<Arc<ProcessHandle>, ConnectError> {
|
) -> Result<process_handler::SpawnedProcess, ConnectError> {
|
||||||
let proc_config = request.process.as_option().ok_or_else(|| {
|
let proc_config = request.process.as_option().ok_or_else(|| {
|
||||||
ConnectError::new(ErrorCode::InvalidArgument, "process config required")
|
ConnectError::new(ErrorCode::InvalidArgument, "process config required")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let username = self.state.defaults.user.clone();
|
// Per-request user overrides the sandbox default when provided.
|
||||||
let user =
|
let username = if proc_config.user.is_empty() {
|
||||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
self.state.defaults.user()
|
||||||
|
} else {
|
||||||
|
proc_config.user.to_string()
|
||||||
|
};
|
||||||
|
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||||
|
|
||||||
let cmd: &str = proc_config.cmd;
|
let cmd_raw: &str = proc_config.cmd;
|
||||||
let args: Vec<String> = proc_config.args.iter().map(|s| s.to_string()).collect();
|
let args_raw: Vec<String> = proc_config.args.iter().map(|s| s.to_string()).collect();
|
||||||
let envs: HashMap<String, String> = proc_config
|
let envs: HashMap<String, String> = proc_config
|
||||||
.envs
|
.envs
|
||||||
.iter()
|
.iter()
|
||||||
@ -84,8 +89,17 @@ impl ProcessServiceImpl {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let home_dir = user.dir.to_string_lossy().to_string();
|
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 cwd_str: &str = proc_config.cwd.unwrap_or("");
|
||||||
let cwd = expand_and_resolve(cwd_str, &home_dir, self.state.defaults.workdir.as_deref())
|
let default_workdir = self.state.defaults.workdir();
|
||||||
|
let cwd = expand_and_resolve(cwd_str, &home_dir, default_workdir.as_deref())
|
||||||
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))?;
|
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))?;
|
||||||
|
|
||||||
let effective_cwd = if cwd.is_empty() { "/" } else { &cwd };
|
let effective_cwd = if cwd.is_empty() { "/" } else { &cwd };
|
||||||
@ -116,8 +130,8 @@ impl ProcessServiceImpl {
|
|||||||
"process.Start request"
|
"process.Start request"
|
||||||
);
|
);
|
||||||
|
|
||||||
let handle = process_handler::spawn_process(
|
let spawned = process_handler::spawn_process(
|
||||||
cmd,
|
&cmd,
|
||||||
&args,
|
&args,
|
||||||
&envs,
|
&envs,
|
||||||
effective_cwd,
|
effective_cwd,
|
||||||
@ -128,17 +142,28 @@ impl ProcessServiceImpl {
|
|||||||
&self.state.defaults.env_vars,
|
&self.state.defaults.env_vars,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
self.processes.insert(handle.pid, Arc::clone(&handle));
|
self.processes
|
||||||
|
.insert(spawned.handle.pid, Arc::clone(&spawned.handle));
|
||||||
|
|
||||||
let processes = self.processes.clone();
|
let processes = Arc::clone(&self.processes);
|
||||||
let pid = handle.pid;
|
let pid = spawned.handle.pid;
|
||||||
let mut end_rx = handle.subscribe_end();
|
// Subscribe before checking cached_end so the prune cannot be lost to a
|
||||||
|
// race: a short-lived process can exit and broadcast its end event
|
||||||
|
// before this task runs. A broadcast receiver only sees messages sent
|
||||||
|
// after subscribe(), so a late subscribe would miss the event forever
|
||||||
|
// (recv() never returns Closed either — the handle keeps end_tx alive
|
||||||
|
// until it leaves the map, which only this task does). The waiter sets
|
||||||
|
// ended before sending end_tx, so cached_end() is a reliable fallback.
|
||||||
|
let mut cleanup_end_rx = spawned.handle.subscribe_end();
|
||||||
|
let already_ended = spawned.handle.cached_end().is_some();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = end_rx.recv().await;
|
if !already_ended {
|
||||||
|
let _ = cleanup_end_rx.recv().await;
|
||||||
|
}
|
||||||
processes.remove(&pid);
|
processes.remove(&pid);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(handle)
|
Ok(spawned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,27 +207,13 @@ impl Process for ProcessServiceImpl {
|
|||||||
),
|
),
|
||||||
ConnectError,
|
ConnectError,
|
||||||
> {
|
> {
|
||||||
let handle = self.spawn_from_request(&request)?;
|
let spawned = self.spawn_from_request(&request)?;
|
||||||
let pid = handle.pid;
|
let pid = spawned.handle.pid;
|
||||||
|
|
||||||
let mut data_rx = handle.subscribe_data();
|
// Start subscribes before any output is produced, so there is nothing to
|
||||||
let mut end_rx = handle.subscribe_end();
|
// replay and the process cannot have ended yet.
|
||||||
|
let stream = process_event_stream(pid, Vec::new(), spawned.data_rx, spawned.end_rx, None)
|
||||||
let stream = async_stream::stream! {
|
.map(|r| r.map(wrap_start_response));
|
||||||
yield Ok(make_start_response(pid));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match data_rx.recv().await {
|
|
||||||
Ok(ev) => yield Ok(make_data_start_response(ev)),
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(end) = end_rx.recv().await {
|
|
||||||
yield Ok(make_end_start_response(end));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((Box::pin(stream), ctx))
|
Ok((Box::pin(stream), ctx))
|
||||||
}
|
}
|
||||||
@ -224,40 +235,17 @@ impl Process for ProcessServiceImpl {
|
|||||||
let handle = self.get_process_by_selector(selector)?;
|
let handle = self.get_process_by_selector(selector)?;
|
||||||
let pid = handle.pid;
|
let pid = handle.pid;
|
||||||
|
|
||||||
let mut data_rx = handle.subscribe_data();
|
// Snapshot buffered output + subscribe live atomically, then read the
|
||||||
let mut end_rx = handle.subscribe_end();
|
// 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 cached_end = handle.cached_end();
|
||||||
|
|
||||||
let stream = async_stream::stream! {
|
let stream = process_event_stream(pid, replay, data_rx, end_rx, cached_end)
|
||||||
yield Ok(ConnectResponse {
|
.map(|r| r.map(wrap_connect_response));
|
||||||
event: buffa::MessageField::some(ProcessEvent {
|
|
||||||
event: Some(process_event::Event::Start(Box::new(
|
|
||||||
process_event::StartEvent { pid, ..Default::default() },
|
|
||||||
))),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match data_rx.recv().await {
|
|
||||||
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) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(end) = end_rx.recv().await {
|
|
||||||
yield Ok(ConnectResponse {
|
|
||||||
event: buffa::MessageField::some(make_end_event(end)),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((Box::pin(stream), ctx))
|
Ok((Box::pin(stream), ctx))
|
||||||
}
|
}
|
||||||
@ -278,7 +266,12 @@ impl Process for ProcessServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((UpdateResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
UpdateResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn stream_input(
|
async fn stream_input(
|
||||||
@ -287,11 +280,11 @@ impl Process for ProcessServiceImpl {
|
|||||||
mut requests: Pin<
|
mut requests: Pin<
|
||||||
Box<
|
Box<
|
||||||
dyn Stream<
|
dyn Stream<
|
||||||
Item = Result<
|
Item = Result<
|
||||||
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
||||||
ConnectError,
|
ConnectError,
|
||||||
>,
|
>,
|
||||||
> + Send,
|
> + Send,
|
||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
) -> Result<(StreamInputResponse, Context), ConnectError> {
|
) -> Result<(StreamInputResponse, Context), ConnectError> {
|
||||||
@ -312,7 +305,7 @@ impl Process for ProcessServiceImpl {
|
|||||||
ConnectError::new(ErrorCode::FailedPrecondition, "no start event received")
|
ConnectError::new(ErrorCode::FailedPrecondition, "no start event received")
|
||||||
})?;
|
})?;
|
||||||
if let Some(input) = data.input.as_option() {
|
if let Some(input) = data.input.as_option() {
|
||||||
write_input(h, input)?;
|
write_input(h, input).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(stream_input_request::EventView::Keepalive(_)) => {}
|
Some(stream_input_request::EventView::Keepalive(_)) => {}
|
||||||
@ -320,7 +313,12 @@ impl Process for ProcessServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((StreamInputResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
StreamInputResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_input(
|
async fn send_input(
|
||||||
@ -334,10 +332,15 @@ impl Process for ProcessServiceImpl {
|
|||||||
let handle = self.get_process_by_selector(selector)?;
|
let handle = self.get_process_by_selector(selector)?;
|
||||||
|
|
||||||
if let Some(input) = request.input.as_option() {
|
if let Some(input) = request.input.as_option() {
|
||||||
write_input(&handle, input)?;
|
write_input(&handle, input).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((SendInputResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
SendInputResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_signal(
|
async fn send_signal(
|
||||||
@ -357,12 +360,17 @@ impl Process for ProcessServiceImpl {
|
|||||||
return Err(ConnectError::new(
|
return Err(ConnectError::new(
|
||||||
ErrorCode::InvalidArgument,
|
ErrorCode::InvalidArgument,
|
||||||
"invalid or unspecified signal",
|
"invalid or unspecified signal",
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handle.send_signal(sig)?;
|
handle.send_signal(sig)?;
|
||||||
Ok((SendSignalResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
SendSignalResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn close_stdin(
|
async fn close_stdin(
|
||||||
@ -375,29 +383,131 @@ impl Process for ProcessServiceImpl {
|
|||||||
})?;
|
})?;
|
||||||
let handle = self.get_process_by_selector(selector)?;
|
let handle = self.get_process_by_selector(selector)?;
|
||||||
handle.close_stdin()?;
|
handle.close_stdin()?;
|
||||||
Ok((CloseStdinResponse { ..Default::default() }, ctx))
|
Ok((
|
||||||
|
CloseStdinResponse {
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_input(handle: &ProcessHandle, input: &ProcessInputView) -> Result<(), ConnectError> {
|
// Input writes go straight to the fd, which is O_NONBLOCK (set at spawn):
|
||||||
|
// small interactive writes complete inline on the async worker, and a full
|
||||||
|
// buffer parks the task awaiting writability with a bounded deadline inside
|
||||||
|
// write_stdin / write_pty — no blocking-pool thread is ever pinned by a
|
||||||
|
// process that stopped reading its input.
|
||||||
|
async fn write_input(
|
||||||
|
handle: &Arc<ProcessHandle>,
|
||||||
|
input: &ProcessInputView<'_>,
|
||||||
|
) -> Result<(), ConnectError> {
|
||||||
match &input.input {
|
match &input.input {
|
||||||
Some(process_input::InputView::Pty(d)) => handle.write_pty(d),
|
Some(process_input::InputView::Pty(d)) => handle.write_pty(d).await,
|
||||||
Some(process_input::InputView::Stdin(d)) => handle.write_stdin(d),
|
Some(process_input::InputView::Stdin(d)) => handle.write_stdin(d).await,
|
||||||
None => Ok(()),
|
None => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_start_response(pid: u32) -> StartResponse {
|
/// 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 {
|
||||||
StartResponse {
|
StartResponse {
|
||||||
event: buffa::MessageField::some(ProcessEvent {
|
event: buffa::MessageField::some(event),
|
||||||
event: Some(process_event::Event::Start(Box::new(
|
..Default::default()
|
||||||
process_event::StartEvent {
|
}
|
||||||
pid,
|
}
|
||||||
..Default::default()
|
|
||||||
},
|
fn wrap_connect_response(event: ProcessEvent) -> ConnectResponse {
|
||||||
))),
|
ConnectResponse {
|
||||||
..Default::default()
|
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()
|
||||||
|
},
|
||||||
|
))),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -419,13 +529,6 @@ 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 {
|
fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
||||||
ProcessEvent {
|
ProcessEvent {
|
||||||
event: Some(process_event::Event::End(Box::new(
|
event: Some(process_event::Event::End(Box::new(
|
||||||
@ -441,9 +544,110 @@ fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_end_start_response(end: process_handler::EndEvent) -> StartResponse {
|
#[cfg(test)]
|
||||||
StartResponse {
|
mod tests {
|
||||||
event: buffa::MessageField::some(make_end_event(end)),
|
use super::*;
|
||||||
..Default::default()
|
|
||||||
|
#[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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::auth::token::SecureToken;
|
use crate::auth::token::SecureToken;
|
||||||
use crate::conntracker::ConnTracker;
|
use crate::conntracker::ConnTracker;
|
||||||
@ -11,14 +11,48 @@ pub struct AppState {
|
|||||||
pub defaults: Defaults,
|
pub defaults: Defaults,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub commit: String,
|
pub commit: String,
|
||||||
pub is_fc: bool,
|
|
||||||
pub needs_restore: AtomicBool,
|
|
||||||
pub last_set_time: AtomicMax,
|
pub last_set_time: AtomicMax,
|
||||||
pub access_token: SecureToken,
|
pub access_token: SecureToken,
|
||||||
pub conn_tracker: ConnTracker,
|
pub conn_tracker: ConnTracker,
|
||||||
pub port_subsystem: Option<Arc<PortSubsystem>>,
|
pub port_subsystem: Option<Arc<PortSubsystem>>,
|
||||||
pub cpu_used_pct: AtomicU32,
|
pub cpu_used_pct: AtomicU32,
|
||||||
pub cpu_count: 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
|
||||||
|
/// ch.snapshot writes a self-contained memory-ranges). `mem_preload_started`
|
||||||
|
/// ensures only one loader runs; `mem_preload_done` lets concurrent callers
|
||||||
|
/// rendezvous; `mem_preload_cancel` lets a teardown abort the loader.
|
||||||
|
pub mem_preload_started: AtomicBool,
|
||||||
|
pub mem_preload_done: AtomicBool,
|
||||||
|
pub mem_preload_cancel: AtomicBool,
|
||||||
|
/// See `reset_preload_run` for the per-run reset shared by /init and the
|
||||||
|
/// POST /memory/preload start/retry paths.
|
||||||
|
///
|
||||||
|
/// Bumped by every /init lifecycle change. A loader thread captures the
|
||||||
|
/// value at spawn and refuses to run — or to publish results — once it no
|
||||||
|
/// longer matches, so a thread that survived a pause/resume (the VM can be
|
||||||
|
/// frozen mid-walk) cannot store a stale `done=true` for the NEXT
|
||||||
|
/// lifecycle's preload. Publication happens under `mem_preload_error`'s
|
||||||
|
/// mutex, which /init also holds while bumping, closing the
|
||||||
|
/// freeze-between-check-and-store window.
|
||||||
|
pub mem_preload_generation: AtomicU64,
|
||||||
|
pub mem_preload_regions: AtomicU64,
|
||||||
|
pub mem_preload_pages: AtomicU64,
|
||||||
|
pub mem_preload_bytes: AtomicU64,
|
||||||
|
pub mem_preload_elapsed_us: AtomicU64,
|
||||||
|
/// 0 = unset, 1 = /dev/mem, 2 = /proc/kcore.
|
||||||
|
pub mem_preload_source: AtomicU8,
|
||||||
|
pub mem_preload_error: Mutex<Option<String>>,
|
||||||
|
|
||||||
|
/// Last lifecycle ID seen on /init. Used to detect post-resume calls so
|
||||||
|
/// envd can refresh port forwarders and remount NFS volumes.
|
||||||
|
lifecycle_id: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@ -26,26 +60,36 @@ impl AppState {
|
|||||||
defaults: Defaults,
|
defaults: Defaults,
|
||||||
version: String,
|
version: String,
|
||||||
commit: String,
|
commit: String,
|
||||||
is_fc: bool,
|
|
||||||
port_subsystem: Option<Arc<PortSubsystem>>,
|
port_subsystem: Option<Arc<PortSubsystem>>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
let state = Arc::new(Self {
|
let state = Arc::new(Self {
|
||||||
defaults,
|
defaults,
|
||||||
version,
|
version,
|
||||||
commit,
|
commit,
|
||||||
is_fc,
|
|
||||||
needs_restore: AtomicBool::new(false),
|
|
||||||
last_set_time: AtomicMax::new(),
|
last_set_time: AtomicMax::new(),
|
||||||
access_token: SecureToken::new(),
|
access_token: SecureToken::new(),
|
||||||
conn_tracker: ConnTracker::new(),
|
conn_tracker: ConnTracker::new(),
|
||||||
port_subsystem,
|
port_subsystem,
|
||||||
cpu_used_pct: AtomicU32::new(0),
|
cpu_used_pct: AtomicU32::new(0),
|
||||||
cpu_count: 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),
|
||||||
|
mem_preload_generation: AtomicU64::new(0),
|
||||||
|
mem_preload_regions: AtomicU64::new(0),
|
||||||
|
mem_preload_pages: AtomicU64::new(0),
|
||||||
|
mem_preload_bytes: AtomicU64::new(0),
|
||||||
|
mem_preload_elapsed_us: AtomicU64::new(0),
|
||||||
|
mem_preload_source: AtomicU8::new(0),
|
||||||
|
mem_preload_error: Mutex::new(None),
|
||||||
|
lifecycle_id: Mutex::new(None),
|
||||||
});
|
});
|
||||||
|
|
||||||
let state_clone = Arc::clone(&state);
|
let state_clone = Arc::clone(&state);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
cpu_sampler(state_clone);
|
activity_sampler(state_clone);
|
||||||
});
|
});
|
||||||
|
|
||||||
state
|
state
|
||||||
@ -58,16 +102,59 @@ impl AppState {
|
|||||||
pub fn cpu_count(&self) -> u32 {
|
pub fn cpu_count(&self) -> u32 {
|
||||||
self.cpu_count.load(Ordering::Relaxed)
|
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:
|
||||||
|
/// Clears the per-run memory-preload result fields (done flag, counters,
|
||||||
|
/// source, error). Shared by /init's lifecycle reset and POST
|
||||||
|
/// /memory/preload's start/retry paths so the two field lists cannot
|
||||||
|
/// drift. The caller MUST hold the `mem_preload_error` mutex and pass its
|
||||||
|
/// guard's contents in — that mutex is the critical section that also
|
||||||
|
/// serializes the loader thread's result publication.
|
||||||
|
pub fn reset_preload_run(&self, err: &mut Option<String>) {
|
||||||
|
*err = None;
|
||||||
|
self.mem_preload_done.store(false, Ordering::SeqCst);
|
||||||
|
self.mem_preload_regions.store(0, Ordering::SeqCst);
|
||||||
|
self.mem_preload_pages.store(0, Ordering::SeqCst);
|
||||||
|
self.mem_preload_bytes.store(0, Ordering::SeqCst);
|
||||||
|
self.mem_preload_elapsed_us.store(0, Ordering::SeqCst);
|
||||||
|
self.mem_preload_source.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// boot-time /init doesn't need port-subsystem restart since the
|
||||||
|
/// subsystem hasn't been started yet by anything else.
|
||||||
|
pub fn bump_lifecycle(&self, new_id: &str) -> bool {
|
||||||
|
let mut guard = self.lifecycle_id.lock().unwrap();
|
||||||
|
let changed = match guard.as_deref() {
|
||||||
|
Some(existing) => existing != new_id,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
*guard = Some(new_id.to_owned());
|
||||||
|
changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cpu_sampler(state: Arc<AppState>) {
|
fn activity_sampler(state: Arc<AppState>) {
|
||||||
use sysinfo::System;
|
use sysinfo::System;
|
||||||
|
|
||||||
let mut sys = System::new();
|
let mut sys = System::new();
|
||||||
sys.refresh_cpu_all();
|
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 {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
sys.refresh_cpu_all();
|
sys.refresh_cpu_all();
|
||||||
|
|
||||||
let pct = sys.global_cpu_usage();
|
let pct = sys.global_cpu_usage();
|
||||||
@ -83,5 +170,73 @@ fn cpu_sampler(state: Arc<AppState>) {
|
|||||||
state
|
state
|
||||||
.cpu_count
|
.cpu_count
|
||||||
.store(sys.cpus().len() as u32, Ordering::Relaxed);
|
.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))
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,10 @@ impl AtomicMax {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> i64 {
|
||||||
|
self.val.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the stored value to `new` if `new` is strictly greater than
|
/// Sets the stored value to `new` if `new` is strictly greater than
|
||||||
/// the current value. Returns `true` if the value was updated.
|
/// the current value. Returns `true` if the value was updated.
|
||||||
pub fn set_to_greater(&self, new: i64) -> bool {
|
pub fn set_to_greater(&self, new: i64) -> bool {
|
||||||
@ -19,15 +23,78 @@ impl AtomicMax {
|
|||||||
if new <= current {
|
if new <= current {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match self.val.compare_exchange_weak(
|
match self
|
||||||
current,
|
.val
|
||||||
new,
|
.compare_exchange_weak(current, new, Ordering::Release, Ordering::Relaxed)
|
||||||
Ordering::Release,
|
{
|
||||||
Ordering::Relaxed,
|
|
||||||
) {
|
|
||||||
Ok(_) => return true,
|
Ok(_) => return true,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_value_is_i64_min() {
|
||||||
|
let m = AtomicMax::new();
|
||||||
|
assert_eq!(m.get(), i64::MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn updates_when_larger() {
|
||||||
|
let m = AtomicMax::new();
|
||||||
|
assert!(m.set_to_greater(0));
|
||||||
|
assert_eq!(m.get(), 0);
|
||||||
|
assert!(m.set_to_greater(100));
|
||||||
|
assert_eq!(m.get(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_false_when_equal() {
|
||||||
|
let m = AtomicMax::new();
|
||||||
|
m.set_to_greater(42);
|
||||||
|
assert!(!m.set_to_greater(42));
|
||||||
|
assert_eq!(m.get(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_false_when_smaller() {
|
||||||
|
let m = AtomicMax::new();
|
||||||
|
m.set_to_greater(100);
|
||||||
|
assert!(!m.set_to_greater(50));
|
||||||
|
assert_eq!(m.get(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_convergence() {
|
||||||
|
let m = Arc::new(AtomicMax::new());
|
||||||
|
let threads: Vec<_> = (0..8)
|
||||||
|
.map(|t| {
|
||||||
|
let m = Arc::clone(&m);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for i in (t * 100)..((t + 1) * 100) {
|
||||||
|
m.set_to_greater(i);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for t in threads {
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(m.get(), 799);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn i64_max_boundary() {
|
||||||
|
let m = AtomicMax::new();
|
||||||
|
assert!(m.set_to_greater(i64::MAX));
|
||||||
|
assert!(!m.set_to_greater(i64::MAX));
|
||||||
|
assert!(!m.set_to_greater(0));
|
||||||
|
assert_eq!(m.get(), i64::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
379
frontend/bun.lock
Normal file
379
frontend/bun.lock
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "frontend",
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"shiki": "^4.0.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource-variable/manrope": "^5.2.8",
|
||||||
|
"@fontsource/alice": "^5.2.8",
|
||||||
|
"@fontsource/instrument-serif": "^5.2.8",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"bits-ui": "^2.16.3",
|
||||||
|
"svelte": "^5.51.0",
|
||||||
|
"svelte-check": "^4.4.2",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "2.8.1" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
|
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||||
|
|
||||||
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "1.7.5", "@floating-ui/utils": "0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||||
|
|
||||||
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||||
|
|
||||||
|
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
|
||||||
|
|
||||||
|
"@fontsource-variable/manrope": ["@fontsource-variable/manrope@5.2.8", "", {}, "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw=="],
|
||||||
|
|
||||||
|
"@fontsource/alice": ["@fontsource/alice@5.2.8", "", {}, "sha512-EDpK9aFXsaRKdyZpgFu8d5+zmE07yIaFxqVeKrYQJjdQpEhWDZA+naLflHwQQmMbLMJK3a4X/RAm5MCScT93NA=="],
|
||||||
|
|
||||||
|
"@fontsource/instrument-serif": ["@fontsource/instrument-serif@5.2.8", "", {}, "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="],
|
||||||
|
|
||||||
|
"@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "0.5.21" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "0.3.13", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "3.1.2", "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||||
|
|
||||||
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "0.10.1" }, "peerDependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||||
|
|
||||||
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
|
"@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4", "hast-util-to-html": "9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
|
||||||
|
|
||||||
|
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "oniguruma-to-es": "4.3.5" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
|
||||||
|
|
||||||
|
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
|
||||||
|
|
||||||
|
"@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
|
||||||
|
|
||||||
|
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
|
||||||
|
|
||||||
|
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
|
||||||
|
|
||||||
|
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||||
|
|
||||||
|
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "8.16.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||||
|
|
||||||
|
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "2.57.1" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||||
|
|
||||||
|
"@sveltejs/kit": ["@sveltejs/kit@2.57.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "@sveltejs/acorn-typescript": "1.0.9", "@types/cookie": "0.6.0", "acorn": "8.16.0", "cookie": "0.6.0", "devalue": "5.7.1", "esm-env": "1.2.2", "kleur": "4.1.5", "magic-string": "0.30.21", "mrmime": "2.0.1", "set-cookie-parser": "3.1.0", "sirv": "3.0.2" }, "optionalDependencies": { "typescript": "6.0.2" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "7.0.0", "svelte": "5.55.3", "vite": "8.0.8" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "4.3.1", "magic-string": "0.30.21", "obug": "2.1.1", "vitefu": "1.1.3" }, "peerDependencies": { "svelte": "5.55.3", "vite": "8.0.8" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="],
|
||||||
|
|
||||||
|
"@swc/helpers": ["@swc/helpers@0.5.21", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "enhanced-resolve": "5.20.1", "jiti": "2.6.1", "lightningcss": "1.32.0", "magic-string": "0.30.21", "source-map-js": "1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "8.0.8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
|
|
||||||
|
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||||
|
|
||||||
|
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
|
||||||
|
|
||||||
|
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
|
||||||
|
|
||||||
|
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||||
|
|
||||||
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"bits-ui": ["bits-ui@2.17.3", "", { "dependencies": { "@floating-ui/core": "1.7.5", "@floating-ui/dom": "1.7.6", "esm-env": "1.2.2", "runed": "0.35.1", "svelte-toolbelt": "0.10.6", "tabbable": "6.4.0" }, "peerDependencies": { "@internationalized/date": "3.12.0", "svelte": "5.55.3" } }, "sha512-Bef41uY9U2jaBJHPhcPvmBNkGec5Wx2z6eioDsTmsaR2vH4QoaOcPi75gzCG3+/2TNr6v/qBwzgWNPYCxNtrEA=="],
|
||||||
|
|
||||||
|
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||||
|
|
||||||
|
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||||
|
|
||||||
|
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||||
|
|
||||||
|
"chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "0.3.4" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "4.1.2" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
|
||||||
|
|
||||||
|
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.2" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||||
|
|
||||||
|
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||||
|
|
||||||
|
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "optionalDependencies": { "picomatch": "4.0.4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "3.0.4", "@types/unist": "3.0.3", "ccount": "2.0.1", "comma-separated-tokens": "2.0.3", "hast-util-whitespace": "3.0.0", "html-void-elements": "3.0.0", "mdast-util-to-hast": "13.2.1", "property-information": "7.1.0", "space-separated-tokens": "2.0.2", "stringify-entities": "4.0.4", "zwitch": "2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||||
|
|
||||||
|
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "3.0.4" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||||
|
|
||||||
|
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||||
|
|
||||||
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
|
|
||||||
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "3.0.4", "@types/mdast": "4.0.4", "@ungap/structured-clone": "1.3.0", "devlop": "1.1.0", "micromark-util-sanitize-uri": "2.0.1", "trim-lines": "3.0.1", "unist-util-position": "5.0.0", "unist-util-visit": "5.1.0", "vfile": "6.0.3" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||||
|
|
||||||
|
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||||
|
|
||||||
|
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||||
|
|
||||||
|
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "2.1.1", "micromark-util-encode": "2.0.1", "micromark-util-symbol": "2.0.1" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||||
|
|
||||||
|
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||||
|
|
||||||
|
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
|
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
|
||||||
|
|
||||||
|
"oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "0.12.1", "regex": "6.1.0", "regex-recursion": "6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||||
|
|
||||||
|
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||||
|
|
||||||
|
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||||
|
|
||||||
|
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||||
|
|
||||||
|
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "2.0.3", "esm-env": "1.2.2", "lz-string": "1.5.0" }, "optionalDependencies": { "@sveltejs/kit": "2.57.1" }, "peerDependencies": { "svelte": "5.55.3" } }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||||
|
|
||||||
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "1.2.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||||
|
|
||||||
|
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "10.0.2", "@types/hast": "3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||||
|
|
||||||
|
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "1.0.0-next.29", "mrmime": "2.0.1", "totalist": "3.0.1" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||||
|
|
||||||
|
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "2.1.0", "character-entities-legacy": "3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||||
|
|
||||||
|
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||||
|
|
||||||
|
"svelte": ["svelte@5.55.3", "", { "dependencies": { "@jridgewell/remapping": "2.3.5", "@jridgewell/sourcemap-codec": "1.5.5", "@sveltejs/acorn-typescript": "1.0.9", "@types/estree": "1.0.8", "@types/trusted-types": "2.0.7", "acorn": "8.16.0", "aria-query": "5.3.1", "axobject-query": "4.1.0", "clsx": "2.1.1", "devalue": "5.7.1", "esm-env": "1.2.2", "esrap": "2.2.5", "is-reference": "3.0.3", "locate-character": "3.0.0", "magic-string": "0.30.21", "zimmerframe": "1.1.4" } }, "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ=="],
|
||||||
|
|
||||||
|
"svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "chokidar": "4.0.3", "fdir": "6.5.0", "picocolors": "1.1.1", "sade": "1.8.1" }, "peerDependencies": { "svelte": "5.55.3", "typescript": "6.0.2" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="],
|
||||||
|
|
||||||
|
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "2.1.1", "runed": "0.35.1", "style-to-object": "1.0.14" }, "peerDependencies": { "svelte": "5.55.3" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||||
|
|
||||||
|
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
|
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||||
|
|
||||||
|
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||||
|
|
||||||
|
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||||
|
|
||||||
|
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||||
|
|
||||||
|
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1", "unist-util-visit-parents": "6.0.2" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||||
|
|
||||||
|
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-is": "6.0.1" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||||
|
|
||||||
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile-message": "4.0.3" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "1.32.0", "picomatch": "4.0.4", "postcss": "8.5.9", "rolldown": "1.0.0-rc.15", "tinyglobby": "0.2.16" }, "optionalDependencies": { "fsevents": "2.3.3", "jiti": "2.6.1" }, "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|
||||||
|
"vitefu": ["vitefu@1.1.3", "", { "optionalDependencies": { "vite": "8.0.8" } }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||||
|
|
||||||
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
1564
frontend/pnpm-lock.yaml
generated
1564
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,9 @@ export async function destroyAdminCapsule(id: string): Promise<ApiResult<void>>
|
|||||||
return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`);
|
return apiFetch('DELETE', `/api/v1/admin/capsules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function snapshotAdminCapsule(id: string, name?: string): Promise<ApiResult<Snapshot>> {
|
// Async: returns 202 with the capsule now in the "snapshotting" state. The
|
||||||
|
// template lands later (watch template.snapshot.create or poll templates).
|
||||||
|
export async function snapshotAdminCapsule(id: string, name?: string): Promise<ApiResult<Capsule>> {
|
||||||
return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name });
|
return apiFetch('POST', `/api/v1/admin/capsules/${id}/snapshot`, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +37,10 @@ export async function listPlatformTemplates(): Promise<ApiResult<Snapshot[]>> {
|
|||||||
size_bytes: t.size_bytes,
|
size_bytes: t.size_bytes,
|
||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
platform: true,
|
platform: true,
|
||||||
|
protected: t.protected,
|
||||||
|
public: false,
|
||||||
|
owned: false,
|
||||||
|
team_slug: 'wrenn',
|
||||||
}));
|
}));
|
||||||
return { ok: true, data: snapshots };
|
return { ok: true, data: snapshots };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,3 +26,7 @@ export async function listAdminUsers(page: number = 1): Promise<ApiResult<AdminU
|
|||||||
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
|
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
|
||||||
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
|
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setUserAdmin(id: string, admin: boolean): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PUT', `/api/v1/admin/users/${id}/admin`, { admin });
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
import { apiFailure } from '$lib/api/client';
|
||||||
|
|
||||||
export type AuthResponse = {
|
export type AuthResponse = {
|
||||||
token: string;
|
|
||||||
user_id: string;
|
user_id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role: string;
|
||||||
|
is_admin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignupResponse = {
|
export type SignupResponse = {
|
||||||
@ -30,14 +33,15 @@ async function authFetch<T = AuthResponse>(url: string, body: Record<string, str
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = data?.error?.message ?? 'Something went wrong';
|
const failure = apiFailure(data);
|
||||||
return { ok: false, error: message };
|
return { ok: false, error: failure.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, data: data as T };
|
return { ok: true, data: data as T };
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
|
|||||||
|
|
||||||
export type BuildLogEntry = {
|
export type BuildLogEntry = {
|
||||||
step: number;
|
step: number;
|
||||||
phase: string; // "pre-build", "recipe", or "post-build"
|
phase: string; // "pre-build", "recipe", "post-build", or "healthcheck"
|
||||||
cmd: string;
|
cmd: string;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
stderr: string;
|
stderr: string;
|
||||||
@ -41,9 +41,34 @@ export type CreateBuildParams = {
|
|||||||
vcpus?: number;
|
vcpus?: number;
|
||||||
memory_mb?: number;
|
memory_mb?: number;
|
||||||
skip_pre_post?: boolean;
|
skip_pre_post?: boolean;
|
||||||
|
run_as_root?: boolean;
|
||||||
archive?: File;
|
archive?: File;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// BuildStreamEvent is one message from the live build console WebSocket
|
||||||
|
// (GET /v1/admin/builds/{id}/stream). It mirrors the backend event shape.
|
||||||
|
export type BuildStreamEvent = {
|
||||||
|
type: 'step-start' | 'output' | 'step-end' | 'build-status' | 'ping';
|
||||||
|
step?: number;
|
||||||
|
phase?: string;
|
||||||
|
cmd?: string;
|
||||||
|
data?: string; // base64-encoded PTY output bytes
|
||||||
|
exit?: number;
|
||||||
|
ok?: boolean;
|
||||||
|
elapsed_ms?: number;
|
||||||
|
status?: string;
|
||||||
|
current_step?: number;
|
||||||
|
total_steps?: number;
|
||||||
|
error?: string;
|
||||||
|
t?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// buildStreamUrl returns the WebSocket URL for a build's live console.
|
||||||
|
export function buildStreamUrl(id: string): string {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/api/v1/admin/builds/${id}/stream`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
|
export async function createBuild(params: CreateBuildParams): Promise<ApiResult<Build>> {
|
||||||
if (params.archive) {
|
if (params.archive) {
|
||||||
// Use multipart when an archive file is provided.
|
// Use multipart when an archive file is provided.
|
||||||
@ -72,12 +97,20 @@ export type AdminTemplate = {
|
|||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** True for built-in system base templates, which cannot be deleted. */
|
||||||
|
protected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
|
export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>> {
|
||||||
return apiFetch('GET', '/api/v1/admin/templates');
|
return apiFetch('GET', '/api/v1/admin/templates');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renameAdminTemplate(name: string, newName: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/admin/templates/${encodeURIComponent(name)}`, {
|
||||||
|
new_name: newName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
|
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
|
||||||
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
|
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,51 @@
|
|||||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
|
// Mirror of the backend state machine. Keep in sync with the `status` enum
|
||||||
|
// on the Capsule schema in internal/api/openapi.yaml.
|
||||||
|
export type CapsuleStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'starting'
|
||||||
|
| 'running'
|
||||||
|
| 'pausing'
|
||||||
|
| 'paused'
|
||||||
|
| 'snapshotting'
|
||||||
|
| 'resuming'
|
||||||
|
| 'stopping'
|
||||||
|
| 'hibernated'
|
||||||
|
| 'stopped'
|
||||||
|
| 'missing'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
// States from which a user may resume the capsule.
|
||||||
|
export const RESUMABLE_STATUSES: ReadonlySet<CapsuleStatus> = new Set([
|
||||||
|
'paused',
|
||||||
|
'hibernated'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Transient states where lifecycle actions should be disabled.
|
||||||
|
export const TRANSIENT_STATUSES: ReadonlySet<CapsuleStatus> = new Set([
|
||||||
|
'pending',
|
||||||
|
'starting',
|
||||||
|
'pausing',
|
||||||
|
'snapshotting',
|
||||||
|
'resuming',
|
||||||
|
'stopping'
|
||||||
|
]);
|
||||||
|
|
||||||
export type Capsule = {
|
export type Capsule = {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: CapsuleStatus;
|
||||||
template: string;
|
template: string;
|
||||||
vcpus: number;
|
vcpus: number;
|
||||||
memory_mb: number;
|
memory_mb: number;
|
||||||
timeout_sec: number;
|
timeout_sec: number;
|
||||||
guest_ip?: string;
|
|
||||||
host_ip?: string;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
started_at?: string;
|
started_at?: string;
|
||||||
last_active_at?: string;
|
last_active_at?: string;
|
||||||
last_updated: string;
|
last_updated: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
disk_size_mb: number;
|
||||||
|
disk_used_mb?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -55,17 +88,68 @@ export type Snapshot = {
|
|||||||
size_bytes: number;
|
size_bytes: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
platform: boolean;
|
platform: boolean;
|
||||||
|
/** True for built-in system base templates, which cannot be deleted. */
|
||||||
|
protected?: boolean;
|
||||||
|
/** True when the template is published and launchable by other teams. */
|
||||||
|
public: boolean;
|
||||||
|
/** True when the template belongs to the viewing team. */
|
||||||
|
owned: boolean;
|
||||||
|
/** Slug of the owning team. Foreign public templates launch as `<team_slug>/<name>`. */
|
||||||
|
team_slug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Snapshot>> {
|
export type SnapshotPage = {
|
||||||
|
templates: Snapshot[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSnapshotsParams = {
|
||||||
|
type?: string;
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshots are async: the call returns 202 with the capsule now in the
|
||||||
|
// "snapshotting" state. The resulting template arrives later via the
|
||||||
|
// template.snapshot.create SSE event (or by polling listSnapshots).
|
||||||
|
export async function createSnapshot(capsuleId: string, name?: string): Promise<ApiResult<Capsule>> {
|
||||||
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
|
return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: capsuleId, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSnapshots(typeFilter?: string): Promise<ApiResult<Snapshot[]>> {
|
export async function listSnapshots(params: ListSnapshotsParams = {}): Promise<ApiResult<SnapshotPage>> {
|
||||||
const url = typeFilter ? `/api/v1/snapshots?type=${typeFilter}` : '/api/v1/snapshots';
|
const q = new URLSearchParams();
|
||||||
return apiFetch('GET', url);
|
if (params.type) q.set('type', params.type);
|
||||||
|
if (params.q) q.set('q', params.q);
|
||||||
|
if (params.page) q.set('page', String(params.page));
|
||||||
|
if (params.per_page) q.set('per_page', String(params.per_page));
|
||||||
|
const qs = q.toString();
|
||||||
|
return apiFetch('GET', qs ? `/api/v1/snapshots?${qs}` : '/api/v1/snapshots');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSnapshot(name: string): Promise<ApiResult<void>> {
|
export async function deleteSnapshot(name: string): Promise<ApiResult<void>> {
|
||||||
return apiFetch('DELETE', `/api/v1/snapshots/${name}`);
|
return apiFetch('DELETE', `/api/v1/snapshots/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a template the team owns. Renaming unpublishes it, so any
|
||||||
|
* `<team_slug>/<name>` references other teams held stop resolving.
|
||||||
|
*/
|
||||||
|
export async function renameTemplate(name: string, newName: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/snapshots/${encodeURIComponent(name)}`, {
|
||||||
|
new_name: newName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish or unpublish a template the team owns. */
|
||||||
|
export async function setTemplateVisibility(
|
||||||
|
name: string,
|
||||||
|
isPublic: boolean
|
||||||
|
): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/snapshots/${encodeURIComponent(name)}/visibility`, {
|
||||||
|
public: isPublic
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,14 +22,14 @@ export const PROVIDERS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const EVENT_TYPES = [
|
export const EVENT_TYPES = [
|
||||||
{ value: 'capsule.created', group: 'Capsule' },
|
{ value: 'capsule.create', group: 'Capsule', label: 'Capsule create' },
|
||||||
{ value: 'capsule.running', group: 'Capsule' },
|
{ value: 'capsule.pause', group: 'Capsule', label: 'Capsule pause' },
|
||||||
{ value: 'capsule.paused', group: 'Capsule' },
|
{ value: 'capsule.resume', group: 'Capsule', label: 'Capsule resume' },
|
||||||
{ value: 'capsule.destroyed', group: 'Capsule' },
|
{ value: 'capsule.destroy', group: 'Capsule', label: 'Capsule destroy' },
|
||||||
{ value: 'template.snapshot.created', group: 'Template' },
|
{ value: 'template.snapshot.create', group: 'Template', label: 'Snapshot create' },
|
||||||
{ value: 'template.snapshot.deleted', group: 'Template' },
|
{ value: 'template.snapshot.delete', group: 'Template', label: 'Snapshot delete' },
|
||||||
{ value: 'host.up', group: 'Host' },
|
{ value: 'host.up', group: 'Host', label: 'Host up' },
|
||||||
{ value: 'host.down', group: 'Host' }
|
{ value: 'host.down', group: 'Host', label: 'Host down' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function listChannels(): Promise<ApiResult<Channel[]>> {
|
export async function listChannels(): Promise<ApiResult<Channel[]>> {
|
||||||
|
|||||||
@ -1,44 +1,114 @@
|
|||||||
import { auth } from '$lib/auth.svelte';
|
import { goto } from '$app/navigation';
|
||||||
|
import { auth, readCSRFToken } from '$lib/auth.svelte';
|
||||||
|
|
||||||
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
export type ApiFailure = {
|
||||||
|
ok: false;
|
||||||
|
/** Human-readable message, ready to show in a toast. */
|
||||||
|
error: string;
|
||||||
|
/** Machine-readable error code, e.g. "sandbox_not_running". */
|
||||||
|
code?: string;
|
||||||
|
/** Server request ID — quote it when reporting issues. */
|
||||||
|
requestId?: string;
|
||||||
|
/** Whether retrying the same request may succeed. */
|
||||||
|
retryable?: boolean;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResult<T> = { ok: true; data: T } | ApiFailure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the API error envelope
|
||||||
|
* `{"error":{code,message,request_id,retryable,details}}` into an ApiFailure.
|
||||||
|
* Internal errors get the request ID appended so users can quote it.
|
||||||
|
*/
|
||||||
|
export function apiFailure(data: unknown, fallback = 'Something went wrong'): ApiFailure {
|
||||||
|
const e = (data as { error?: Record<string, unknown> } | null)?.error;
|
||||||
|
let message = typeof e?.message === 'string' && e.message ? e.message : fallback;
|
||||||
|
const requestId = typeof e?.request_id === 'string' ? e.request_id : undefined;
|
||||||
|
if (requestId && e?.code === 'internal_error') {
|
||||||
|
message += ` (ref: ${requestId})`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
code: typeof e?.code === 'string' ? e.code : undefined,
|
||||||
|
requestId,
|
||||||
|
retryable: e?.retryable === true,
|
||||||
|
details: (e?.details as Record<string, unknown> | undefined) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseResponse<T>(res: Response): Promise<ApiResult<T>> {
|
||||||
|
if (res.status === 204 || res.status === 202) {
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return { ok: true, data: undefined as T };
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
return { ok: true, data: data as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
auth.clearUser();
|
||||||
|
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
|
||||||
|
goto('/login', { replaceState: true });
|
||||||
|
return new Promise<ApiResult<T>>(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
// Non-JSON body (e.g. a 502/504 from the proxy when the control plane
|
||||||
|
// is unreachable). Surface the status rather than masking it as a
|
||||||
|
// connection failure in the caller's catch.
|
||||||
|
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
||||||
|
return { ok: true, data: undefined as T };
|
||||||
|
}
|
||||||
|
if (!res.ok) return apiFailure(data);
|
||||||
|
return { ok: true, data: data as T };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachCSRF(headers: Record<string, string>, method: string): void {
|
||||||
|
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return;
|
||||||
|
const token = readCSRFToken();
|
||||||
|
if (token) headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
export async function apiFetch<T>(method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
attachCSRF(headers, method);
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
body: body ? JSON.stringify(body) : undefined
|
body: body ? JSON.stringify(body) : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) return { ok: true, data: undefined as T };
|
return await parseResponse<T>(res);
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
|
||||||
return { ok: true, data: data as T };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: 'Unable to connect to the server' };
|
return { ok: false, error: 'Unable to connect to the server' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetchMultipart<T>(method: string, path: string, formData: FormData): Promise<ApiResult<T>> {
|
export async function apiFetchMultipart<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ApiResult<T>> {
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
attachCSRF(headers, method);
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) return { ok: true, data: undefined as T };
|
return await parseResponse<T>(res);
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' };
|
|
||||||
return { ok: true, data: data as T };
|
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: 'Unable to connect to the server' };
|
return { ok: false, error: 'Unable to connect to the server' };
|
||||||
}
|
}
|
||||||
|
|||||||
169
frontend/src/lib/api/events.ts
Normal file
169
frontend/src/lib/api/events.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import type { Capsule } from '$lib/api/capsules';
|
||||||
|
|
||||||
|
// Mirror the SSE event names emitted by pkg/events. Keep in sync with the
|
||||||
|
// `SSEEvent.event` enum in internal/api/openapi.yaml.
|
||||||
|
export type SSEEventKind =
|
||||||
|
| 'connected'
|
||||||
|
| 'capsule.create'
|
||||||
|
| 'capsule.pause'
|
||||||
|
| 'capsule.resume'
|
||||||
|
| 'capsule.destroy'
|
||||||
|
| 'capsule.state.changed'
|
||||||
|
| 'template.snapshot.create'
|
||||||
|
| 'template.snapshot.delete'
|
||||||
|
| 'host.up'
|
||||||
|
| 'host.down';
|
||||||
|
|
||||||
|
export type SSEEventOutcome = 'success' | 'error';
|
||||||
|
|
||||||
|
export type SSEEvent = {
|
||||||
|
event: SSEEventKind;
|
||||||
|
outcome?: SSEEventOutcome;
|
||||||
|
timestamp: string;
|
||||||
|
team_id: string;
|
||||||
|
actor: { type: string; id?: string; name?: string };
|
||||||
|
resource: { id: string; type: string };
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
error?: string;
|
||||||
|
sandbox?: Capsule | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isSSEEvent(x: unknown): x is SSEEvent {
|
||||||
|
if (!x || typeof x !== 'object') return false;
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.event === 'string' &&
|
||||||
|
typeof o.resource === 'object' &&
|
||||||
|
o.resource !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SSEEventHandler = (event: SSEEvent) => void;
|
||||||
|
|
||||||
|
export type EventStreamConnection = {
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the SSE event stream. Returns a handle to close the connection.
|
||||||
|
* Automatically reconnects on disconnect with exponential backoff. The
|
||||||
|
* browser sends the wrenn_sid cookie automatically on EventSource so no
|
||||||
|
* ticket exchange is required.
|
||||||
|
*/
|
||||||
|
export function connectEventStream(
|
||||||
|
onEvent: SSEEventHandler,
|
||||||
|
opts?: { admin?: boolean }
|
||||||
|
): EventStreamConnection {
|
||||||
|
let closed = false;
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let backoff = 1000;
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (closed) return;
|
||||||
|
if (reconnectTimeout) return;
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = null;
|
||||||
|
connect();
|
||||||
|
}, backoff);
|
||||||
|
backoff = Math.min(backoff * 2, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectNow() {
|
||||||
|
if (closed) return;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
backoff = 1000;
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
const isAdmin = opts?.admin ?? false;
|
||||||
|
const url = isAdmin ? '/api/v1/admin/events/stream' : '/api/v1/events/stream';
|
||||||
|
|
||||||
|
eventSource = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
backoff = 1000;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.addEventListener('capsule.create', handleEvent);
|
||||||
|
eventSource.addEventListener('capsule.pause', handleEvent);
|
||||||
|
eventSource.addEventListener('capsule.resume', handleEvent);
|
||||||
|
eventSource.addEventListener('capsule.destroy', handleEvent);
|
||||||
|
eventSource.addEventListener('capsule.state.changed', handleEvent);
|
||||||
|
eventSource.addEventListener('template.snapshot.create', handleEvent);
|
||||||
|
eventSource.addEventListener('template.snapshot.delete', handleEvent);
|
||||||
|
eventSource.addEventListener('host.up', handleEvent);
|
||||||
|
eventSource.addEventListener('host.down', handleEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(e: MessageEvent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(e.data);
|
||||||
|
if (!isSSEEvent(parsed)) {
|
||||||
|
console.warn('SSE event failed shape validation, dropping', parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onEvent(parsed);
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed messages.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDisconnected() {
|
||||||
|
return !eventSource || eventSource.readyState !== EventSource.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOnline() {
|
||||||
|
if (isDisconnected()) reconnectNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibility() {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'visible' && isDisconnected()) {
|
||||||
|
reconnectNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
closed = true;
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return { close };
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { auth } from '$lib/auth.svelte';
|
import { readCSRFToken } from '$lib/auth.svelte';
|
||||||
import { type ApiResult } from '$lib/api/client';
|
import { apiFailure, apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
export type FileEntry = {
|
export type FileEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -20,10 +20,6 @@ export type ListDirResponse = {
|
|||||||
|
|
||||||
const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB
|
const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a file can be previewed as text in the browser.
|
|
||||||
* Binary/unreadable extensions and files > 10 MB should be downloaded instead.
|
|
||||||
*/
|
|
||||||
const BINARY_EXTENSIONS = new Set([
|
const BINARY_EXTENSIONS = new Set([
|
||||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg',
|
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg',
|
||||||
'.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm',
|
'.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm',
|
||||||
@ -53,23 +49,8 @@ export function formatFileSize(bytes: number): string {
|
|||||||
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise<ApiResult<ListDirResponse>> {
|
export function listDir(capsuleId: string, path: string, depth = 1, basePath = '/api/v1/capsules'): Promise<ApiResult<ListDirResponse>> {
|
||||||
try {
|
return apiFetch<ListDirResponse>('POST', `${basePath}/${capsuleId}/files/list`, { path, depth });
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
|
||||||
|
|
||||||
const res = await fetch(`${basePath}/${capsuleId}/files/list`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ path, depth }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Failed to list directory' };
|
|
||||||
return { ok: true, data: data as ListDirResponse };
|
|
||||||
} catch {
|
|
||||||
return { ok: false, error: 'Unable to connect to the server' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readFile(
|
export async function readFile(
|
||||||
@ -78,13 +59,18 @@ export async function readFile(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
basePath = '/api/v1/capsules',
|
basePath = '/api/v1/capsules',
|
||||||
): Promise<ApiResult<string>> {
|
): Promise<ApiResult<string>> {
|
||||||
|
// /files/read returns raw bytes (potentially binary) so we cannot route it
|
||||||
|
// through apiFetch which assumes JSON. We still inject the CSRF token via
|
||||||
|
// the shared cookie reader.
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
const csrf = readCSRFToken();
|
||||||
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
@ -92,7 +78,8 @@ export async function readFile(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
try {
|
try {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
|
const failure = apiFailure(data, 'Failed to read file');
|
||||||
|
return { ok: false, error: failure.error };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: `HTTP ${res.status}` };
|
return { ok: false, error: `HTTP ${res.status}` };
|
||||||
}
|
}
|
||||||
@ -117,16 +104,26 @@ export async function downloadFile(
|
|||||||
basePath = '/api/v1/capsules',
|
basePath = '/api/v1/capsules',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
const csrf = readCSRFToken();
|
||||||
|
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||||
|
|
||||||
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
const res = await fetch(`${basePath}/${capsuleId}/files/read`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Download failed');
|
if (!res.ok) {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Download failed (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
throw new Error(apiFailure(data, 'Download failed').error);
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@ -136,6 +133,5 @@ export async function downloadFile(
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
// Delay revocation so the browser has time to start the download
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiFetch } from './client';
|
import { apiFetch, type ApiFailure } from './client';
|
||||||
|
|
||||||
export type Host = {
|
export type Host = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,8 +17,15 @@ export type Host = {
|
|||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
running_vcpus: number;
|
||||||
|
running_memory_mb: number;
|
||||||
|
running_disk_mb: number;
|
||||||
|
paused_memory_mb: number;
|
||||||
|
paused_disk_mb: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminHost = Host;
|
||||||
|
|
||||||
export type CreateHostParams = {
|
export type CreateHostParams = {
|
||||||
type: 'regular' | 'byoc';
|
type: 'regular' | 'byoc';
|
||||||
team_id?: string;
|
team_id?: string;
|
||||||
@ -35,6 +42,10 @@ export async function listHosts(): Promise<{ ok: true; data: Host[] } | { ok: fa
|
|||||||
return apiFetch<Host[]>('GET', '/api/v1/hosts');
|
return apiFetch<Host[]>('GET', '/api/v1/hosts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAdminHosts(): Promise<{ ok: true; data: AdminHost[] } | { ok: false; error: string }> {
|
||||||
|
return apiFetch<AdminHost[]>('GET', '/api/v1/admin/hosts');
|
||||||
|
}
|
||||||
|
|
||||||
export async function createHost(
|
export async function createHost(
|
||||||
params: CreateHostParams
|
params: CreateHostParams
|
||||||
): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> {
|
): Promise<{ ok: true; data: CreateHostResult } | { ok: false; error: string }> {
|
||||||
@ -44,11 +55,11 @@ export async function createHost(
|
|||||||
export async function deleteHost(
|
export async function deleteHost(
|
||||||
id: string,
|
id: string,
|
||||||
force = false
|
force = false
|
||||||
): Promise<{ ok: true } | { ok: false; error: string; sandbox_ids?: string[] }> {
|
): Promise<{ ok: true } | ApiFailure> {
|
||||||
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
|
const url = `/api/v1/hosts/${id}${force ? '?force=true' : ''}`;
|
||||||
const res = await apiFetch<void>('DELETE', url);
|
const res = await apiFetch<void>('DELETE', url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return res as { ok: false; error: string };
|
return res;
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,26 @@
|
|||||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
import type { AuthResponse } from '$lib/api/auth';
|
|
||||||
|
|
||||||
export type MeResponse = {
|
export type MeResponse = {
|
||||||
|
user_id: string;
|
||||||
|
team_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
|
is_admin: boolean;
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
providers: string[];
|
providers: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionRow = {
|
||||||
|
id: string;
|
||||||
|
user_agent: string;
|
||||||
|
ip_address: string;
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
current: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChangePasswordBody = {
|
export type ChangePasswordBody = {
|
||||||
current_password?: string;
|
current_password?: string;
|
||||||
new_password: string;
|
new_password: string;
|
||||||
@ -17,7 +30,7 @@ export type ChangePasswordBody = {
|
|||||||
export const getMe = (): Promise<ApiResult<MeResponse>> =>
|
export const getMe = (): Promise<ApiResult<MeResponse>> =>
|
||||||
apiFetch('GET', '/api/v1/me');
|
apiFetch('GET', '/api/v1/me');
|
||||||
|
|
||||||
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
|
export const updateName = (name: string): Promise<ApiResult<void>> =>
|
||||||
apiFetch('PATCH', '/api/v1/me', { name });
|
apiFetch('PATCH', '/api/v1/me', { name });
|
||||||
|
|
||||||
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
|
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
|
||||||
@ -40,3 +53,15 @@ export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =
|
|||||||
|
|
||||||
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
|
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
|
||||||
apiFetch('DELETE', '/api/v1/me', { confirmation });
|
apiFetch('DELETE', '/api/v1/me', { confirmation });
|
||||||
|
|
||||||
|
export const listSessions = (): Promise<ApiResult<{ sessions: SessionRow[] }>> =>
|
||||||
|
apiFetch('GET', '/api/v1/me/sessions');
|
||||||
|
|
||||||
|
export const revokeSession = (id: string): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('DELETE', `/api/v1/me/sessions/${id}`);
|
||||||
|
|
||||||
|
export const logout = (): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('POST', '/api/v1/auth/logout');
|
||||||
|
|
||||||
|
export const logoutAll = (): Promise<ApiResult<void>> =>
|
||||||
|
apiFetch('POST', '/api/v1/auth/logout-all');
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||||
|
import type { AuthResponse } from '$lib/api/auth';
|
||||||
|
|
||||||
export type TeamMember = {
|
export type TeamMember = {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@ -13,6 +14,12 @@ export type TeamInfo = {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Set only while within the 60-day slug cooldown; earliest next change. */
|
||||||
|
slug_change_allowed_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SlugCheck = {
|
||||||
|
available: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TeamDetail = {
|
export type TeamDetail = {
|
||||||
@ -42,9 +49,7 @@ export async function createTeam(name: string): Promise<ApiResult<TeamWithRole>>
|
|||||||
return apiFetch('POST', '/api/v1/teams', { name });
|
return apiFetch('POST', '/api/v1/teams', { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchTeam(
|
export async function switchTeam(teamId: string): Promise<ApiResult<AuthResponse>> {
|
||||||
teamId: string
|
|
||||||
): Promise<ApiResult<{ token: string; user_id: string; team_id: string; email: string; name: string }>> {
|
|
||||||
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
|
return apiFetch('POST', '/api/v1/auth/switch-team', { team_id: teamId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +61,22 @@ export async function updateTeam(id: string, name: string): Promise<ApiResult<vo
|
|||||||
return apiFetch('PATCH', `/api/v1/teams/${id}`, { name });
|
return apiFetch('PATCH', `/api/v1/teams/${id}`, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the team's URL slug. Allowed once every 60 days; the previous slug is
|
||||||
|
* reserved for 30 days. Admin or owner only.
|
||||||
|
*/
|
||||||
|
export async function changeTeamSlug(id: string, slug: string): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PATCH', `/api/v1/teams/${id}/slug`, { slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a candidate slug is available to this team (format, reserved
|
||||||
|
* words, uniqueness, reservation). Does not consider the 60-day cooldown.
|
||||||
|
*/
|
||||||
|
export async function checkTeamSlug(slug: string): Promise<ApiResult<SlugCheck>> {
|
||||||
|
return apiFetch('GET', `/api/v1/teams/slug-check?slug=${encodeURIComponent(slug)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function addMember(id: string, email: string): Promise<ApiResult<TeamMember>> {
|
export async function addMember(id: string, email: string): Promise<ApiResult<TeamMember>> {
|
||||||
return apiFetch('POST', `/api/v1/teams/${id}/members`, { email });
|
return apiFetch('POST', `/api/v1/teams/${id}/members`, { email });
|
||||||
}
|
}
|
||||||
@ -98,6 +119,8 @@ export type AdminTeam = {
|
|||||||
owner_email: string;
|
owner_email: string;
|
||||||
active_sandbox_count: number;
|
active_sandbox_count: number;
|
||||||
channel_count: number;
|
channel_count: number;
|
||||||
|
running_vcpus: number;
|
||||||
|
running_memory_mb: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminTeamsResponse = {
|
export type AdminTeamsResponse = {
|
||||||
|
|||||||
@ -1,35 +1,22 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
// Cookie-backed session auth. The browser holds the opaque session via the
|
||||||
token: 'wrenn_token',
|
// httpOnly `wrenn_sid` cookie (set by the server on login). JS never reads
|
||||||
userId: 'wrenn_user_id',
|
// the session id; identity state is hydrated from GET /v1/me on app boot
|
||||||
teamId: 'wrenn_team_id',
|
// and after login/team-switch.
|
||||||
email: 'wrenn_email',
|
|
||||||
name: 'wrenn_name'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function isTokenExpired(token: string): boolean {
|
export type Me = {
|
||||||
try {
|
user_id: string;
|
||||||
const payload = token.split('.')[1];
|
team_id: string;
|
||||||
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
email: string;
|
||||||
const { exp } = JSON.parse(decoded);
|
name: string;
|
||||||
return Date.now() / 1000 >= exp;
|
role: string;
|
||||||
} catch {
|
is_admin: boolean;
|
||||||
return true;
|
has_password?: boolean;
|
||||||
}
|
providers?: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
function decodeJWTPayload(token: string): Record<string, unknown> {
|
|
||||||
try {
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAuth() {
|
function createAuth() {
|
||||||
let token = $state<string | null>(null);
|
|
||||||
let userId = $state<string | null>(null);
|
let userId = $state<string | null>(null);
|
||||||
let teamId = $state<string | null>(null);
|
let teamId = $state<string | null>(null);
|
||||||
let email = $state<string | null>(null);
|
let email = $state<string | null>(null);
|
||||||
@ -38,33 +25,58 @@ function createAuth() {
|
|||||||
let role = $state<string>('member');
|
let role = $state<string>('member');
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
|
|
||||||
// Initialize from localStorage synchronously at module load.
|
function setUser(data: Me) {
|
||||||
if (typeof window !== 'undefined') {
|
userId = data.user_id;
|
||||||
const stored = localStorage.getItem(STORAGE_KEYS.token);
|
teamId = data.team_id;
|
||||||
if (stored && !isTokenExpired(stored)) {
|
email = data.email;
|
||||||
token = stored;
|
name = data.name;
|
||||||
userId = localStorage.getItem(STORAGE_KEYS.userId);
|
role = data.role || 'member';
|
||||||
teamId = localStorage.getItem(STORAGE_KEYS.teamId);
|
isAdmin = Boolean(data.is_admin);
|
||||||
email = localStorage.getItem(STORAGE_KEYS.email);
|
}
|
||||||
name = localStorage.getItem(STORAGE_KEYS.name);
|
|
||||||
const payload = decodeJWTPayload(stored);
|
function clearUser() {
|
||||||
isAdmin = Boolean(payload.is_admin);
|
userId = null;
|
||||||
role = String(payload.role || 'member');
|
teamId = null;
|
||||||
} else if (stored) {
|
email = null;
|
||||||
// Expired — clean up.
|
name = null;
|
||||||
for (const key of Object.values(STORAGE_KEYS)) {
|
isAdmin = false;
|
||||||
localStorage.removeItem(key);
|
role = 'member';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/me', { credentials: 'same-origin' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as Me;
|
||||||
|
setUser(data);
|
||||||
|
} else {
|
||||||
|
clearUser();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
clearUser();
|
||||||
}
|
}
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthenticated = $derived(token !== null && !isTokenExpired(token));
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch('/api/v1/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRF-Token': readCSRFToken() ?? '' }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
clearUser();
|
||||||
|
await goto('/login');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get token() {
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
get userId() {
|
get userId() {
|
||||||
return userId;
|
return userId;
|
||||||
},
|
},
|
||||||
@ -84,45 +96,26 @@ function createAuth() {
|
|||||||
return role;
|
return role;
|
||||||
},
|
},
|
||||||
get isAuthenticated() {
|
get isAuthenticated() {
|
||||||
return isAuthenticated;
|
return userId !== null;
|
||||||
},
|
},
|
||||||
get initialized() {
|
get initialized() {
|
||||||
return initialized;
|
return initialized;
|
||||||
},
|
},
|
||||||
|
|
||||||
login(data: { token: string; user_id: string; team_id: string; email: string; name: string }) {
|
setUser,
|
||||||
token = data.token;
|
clearUser,
|
||||||
userId = data.user_id;
|
init,
|
||||||
teamId = data.team_id;
|
logout
|
||||||
email = data.email;
|
|
||||||
name = data.name;
|
|
||||||
const payload = decodeJWTPayload(data.token);
|
|
||||||
isAdmin = Boolean(payload.is_admin);
|
|
||||||
role = String(payload.role || 'member');
|
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEYS.token, data.token);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.userId, data.user_id);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.teamId, data.team_id);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.email, data.email);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.name, data.name);
|
|
||||||
},
|
|
||||||
|
|
||||||
logout() {
|
|
||||||
token = null;
|
|
||||||
userId = null;
|
|
||||||
teamId = null;
|
|
||||||
email = null;
|
|
||||||
name = null;
|
|
||||||
isAdmin = false;
|
|
||||||
role = 'member';
|
|
||||||
|
|
||||||
for (const key of Object.values(STORAGE_KEYS)) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
goto('/login');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCSRFToken returns the value of the wrenn_csrf cookie, or null if absent.
|
||||||
|
// Exported so client.ts can attach the X-CSRF-Token header without duplicating
|
||||||
|
// the parser.
|
||||||
|
export function readCSRFToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)wrenn_csrf=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export const auth = createAuth();
|
export const auth = createAuth();
|
||||||
|
|||||||
196
frontend/src/lib/build-console-ws.svelte.ts
Normal file
196
frontend/src/lib/build-console-ws.svelte.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
// build-console-ws.svelte.ts — WebSocket client for the live admin build
|
||||||
|
// console. Connects to GET /v1/admin/builds/{id}/stream, which replays the
|
||||||
|
// completed-step history then live-tails events. The client maps events to a
|
||||||
|
// reactive step list and forwards raw PTY output to a terminal writer.
|
||||||
|
|
||||||
|
import { buildStreamUrl, type BuildStreamEvent } from '$lib/api/builds';
|
||||||
|
|
||||||
|
export type StepStatus = 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
|
export type BuildStep = {
|
||||||
|
step: number;
|
||||||
|
phase: string;
|
||||||
|
cmd: string;
|
||||||
|
status: StepStatus;
|
||||||
|
exit: number | null;
|
||||||
|
elapsedMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConsoleConnState = 'connecting' | 'connected' | 'closed' | 'error';
|
||||||
|
|
||||||
|
const RECONNECT_DELAY = 1500;
|
||||||
|
|
||||||
|
// ANSI truecolor escapes matching the Wrenn palette.
|
||||||
|
const dim = (s: string) => `\x1b[38;2;107;104;98m${s}\x1b[0m`; // text-tertiary
|
||||||
|
const sage = (s: string) => `\x1b[38;2;137;167;133m${s}\x1b[0m`; // accent-mid
|
||||||
|
const red = (s: string) => `\x1b[38;2;207;129;114m${s}\x1b[0m`; // red
|
||||||
|
|
||||||
|
// Binary-safe base64 decode for raw PTY bytes.
|
||||||
|
function decodeBase64(b64: string): string {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(status: string): boolean {
|
||||||
|
return status === 'success' || status === 'failed' || status === 'cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createBuildConsole wires a build's event WebSocket to reactive state.
|
||||||
|
* Call connect() with a terminal write function once the terminal exists,
|
||||||
|
* and disconnect() on teardown.
|
||||||
|
*/
|
||||||
|
export function createBuildConsole(buildId: string) {
|
||||||
|
let connState = $state<ConsoleConnState>('connecting');
|
||||||
|
let steps = $state<BuildStep[]>([]);
|
||||||
|
let buildStatus = $state('');
|
||||||
|
let currentStep = $state(0);
|
||||||
|
let totalSteps = $state(0);
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let writeTerm: ((text: string) => void) | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
function upsertStep(step: number, patch: Partial<BuildStep>) {
|
||||||
|
const idx = steps.findIndex((s) => s.step === step);
|
||||||
|
if (idx === -1) {
|
||||||
|
steps = [
|
||||||
|
...steps,
|
||||||
|
{
|
||||||
|
step,
|
||||||
|
phase: patch.phase ?? '',
|
||||||
|
cmd: patch.cmd ?? '',
|
||||||
|
status: patch.status ?? 'running',
|
||||||
|
exit: patch.exit ?? null,
|
||||||
|
elapsedMs: patch.elapsedMs ?? null
|
||||||
|
}
|
||||||
|
].sort((a, b) => a.step - b.step);
|
||||||
|
} else {
|
||||||
|
// Immutable replace so the reactive array re-renders the step list.
|
||||||
|
steps = steps.map((s, i) => (i === idx ? { ...s, ...patch } : s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryLine(status: string): string {
|
||||||
|
if (status === 'success') return `\r\n${sage('● build succeeded')}\r\n`;
|
||||||
|
if (status === 'failed') return `\r\n${red('● build failed')}\r\n`;
|
||||||
|
return `\r\n${dim('● build ' + status)}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(ev: BuildStreamEvent) {
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'step-start':
|
||||||
|
upsertStep(ev.step ?? 0, {
|
||||||
|
phase: ev.phase,
|
||||||
|
cmd: ev.cmd,
|
||||||
|
status: 'running',
|
||||||
|
exit: null,
|
||||||
|
elapsedMs: null
|
||||||
|
});
|
||||||
|
writeTerm?.(`\r\n${sage('▸')} ${dim('step ' + ev.step)} ${ev.cmd ?? ''}\r\n`);
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
if (ev.data) writeTerm?.(decodeBase64(ev.data));
|
||||||
|
break;
|
||||||
|
case 'step-end': {
|
||||||
|
const ok = ev.ok ?? false;
|
||||||
|
upsertStep(ev.step ?? 0, {
|
||||||
|
phase: ev.phase,
|
||||||
|
cmd: ev.cmd,
|
||||||
|
status: ok ? 'success' : 'failed',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'build-status':
|
||||||
|
if (ev.status) buildStatus = ev.status;
|
||||||
|
if (typeof ev.total_steps === 'number' && ev.total_steps > 0) totalSteps = ev.total_steps;
|
||||||
|
if (typeof ev.current_step === 'number' && ev.current_step > currentStep) {
|
||||||
|
currentStep = ev.current_step;
|
||||||
|
}
|
||||||
|
if (ev.error) errorMessage = ev.error;
|
||||||
|
if (ev.status && isTerminal(ev.status)) writeTerm?.(summaryLine(ev.status));
|
||||||
|
break;
|
||||||
|
case 'ping':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
connState = 'connecting';
|
||||||
|
ws = new WebSocket(buildStreamUrl(buildId));
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connState = 'connected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
handle(JSON.parse(e.data) as BuildStreamEvent);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed frames
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (disposed) return;
|
||||||
|
// A finished build closes cleanly; nothing more to stream.
|
||||||
|
if (isTerminal(buildStatus)) {
|
||||||
|
connState = 'closed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unexpected drop mid-build: reconnect and resume from history.
|
||||||
|
connState = 'connecting';
|
||||||
|
writeTerm?.(`\r\n${dim('[reconnecting...]')}\r\n`);
|
||||||
|
reconnectTimer = setTimeout(open, RECONNECT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (!disposed) connState = 'error';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get connState() {
|
||||||
|
return connState;
|
||||||
|
},
|
||||||
|
get steps() {
|
||||||
|
return steps;
|
||||||
|
},
|
||||||
|
get buildStatus() {
|
||||||
|
return buildStatus;
|
||||||
|
},
|
||||||
|
get currentStep() {
|
||||||
|
return currentStep;
|
||||||
|
},
|
||||||
|
get totalSteps() {
|
||||||
|
return totalSteps;
|
||||||
|
},
|
||||||
|
get errorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** connect opens the WebSocket; write receives terminal output. */
|
||||||
|
connect(write: (text: string) => void) {
|
||||||
|
if (disposed) return;
|
||||||
|
writeTerm = write;
|
||||||
|
open();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** disconnect tears down the WebSocket and cancels any reconnect. */
|
||||||
|
disconnect() {
|
||||||
|
disposed = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
ws?.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
198
frontend/src/lib/components/BuildConsole.svelte
Normal file
198
frontend/src/lib/components/BuildConsole.svelte
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick, untrack } from 'svelte';
|
||||||
|
import type { Build } from '$lib/api/builds';
|
||||||
|
import { createBuildConsole } from '$lib/build-console-ws.svelte';
|
||||||
|
import BuildStepList from './BuildStepList.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
buildId: string;
|
||||||
|
build: Build;
|
||||||
|
onStatusChange?: (status: string) => void;
|
||||||
|
};
|
||||||
|
let { buildId, build, onStatusChange }: Props = $props();
|
||||||
|
|
||||||
|
const bc = createBuildConsole(untrack(() => buildId));
|
||||||
|
|
||||||
|
let containerRef = $state<HTMLDivElement>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let term: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let fitAddon: any = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let fitDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
const stepTotal = $derived(bc.totalSteps || build.total_steps);
|
||||||
|
|
||||||
|
const TERM_THEME = {
|
||||||
|
background: '#0a0c0b',
|
||||||
|
foreground: '#d0cdc6',
|
||||||
|
cursor: '#0a0c0b',
|
||||||
|
cursorAccent: '#0a0c0b',
|
||||||
|
selectionBackground: 'rgba(94, 140, 88, 0.25)',
|
||||||
|
selectionForeground: '#eae7e2',
|
||||||
|
black: '#1a1e1c',
|
||||||
|
red: '#cf8172',
|
||||||
|
green: '#5e8c58',
|
||||||
|
yellow: '#d4a73c',
|
||||||
|
blue: '#5a9fd4',
|
||||||
|
magenta: '#b07ab8',
|
||||||
|
cyan: '#5aafb0',
|
||||||
|
white: '#d0cdc6',
|
||||||
|
brightBlack: '#454340',
|
||||||
|
brightRed: '#e09585',
|
||||||
|
brightGreen: '#89a785',
|
||||||
|
brightYellow: '#e0c070',
|
||||||
|
brightBlue: '#7ab8e0',
|
||||||
|
brightMagenta: '#c898cf',
|
||||||
|
brightCyan: '#7ac5c6',
|
||||||
|
brightWhite: '#eae7e2'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Propagate live build status to the parent (drives the cancel button).
|
||||||
|
$effect(() => {
|
||||||
|
if (bc.buildStatus) onStatusChange?.(bc.buildStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
function connLabel(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
return 'Live';
|
||||||
|
case 'connecting':
|
||||||
|
return 'Connecting';
|
||||||
|
case 'closed':
|
||||||
|
return 'Ended';
|
||||||
|
default:
|
||||||
|
return 'Disconnected';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||||
|
import('@xterm/xterm'),
|
||||||
|
import('@xterm/addon-fit')
|
||||||
|
]);
|
||||||
|
await import('@xterm/xterm/css/xterm.css');
|
||||||
|
await tick();
|
||||||
|
// The component may have been destroyed during the awaits above.
|
||||||
|
if (!alive || !containerRef) return;
|
||||||
|
|
||||||
|
fitAddon = new FitAddon();
|
||||||
|
term = new Terminal({
|
||||||
|
disableStdin: true,
|
||||||
|
cursorBlink: false,
|
||||||
|
cursorStyle: 'underline',
|
||||||
|
fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
theme: TERM_THEME,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: true
|
||||||
|
});
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(containerRef);
|
||||||
|
requestAnimationFrame(() => fitAddon?.fit());
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (fitDebounce) clearTimeout(fitDebounce);
|
||||||
|
fitDebounce = setTimeout(() => fitAddon?.fit(), 50);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef);
|
||||||
|
|
||||||
|
bc.connect((text) => term?.write(text));
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
alive = false;
|
||||||
|
if (fitDebounce) clearTimeout(fitDebounce);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
bc.disconnect();
|
||||||
|
term?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<!-- Console toolbar -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] px-4 py-2"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">{buildId}</span>
|
||||||
|
<span class="font-mono text-label tabular-nums text-[var(--color-text-tertiary)]">
|
||||||
|
{bc.currentStep}/{stepTotal}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
{#if bc.connState === 'connected'}
|
||||||
|
<span class="relative flex h-[7px] w-[7px]">
|
||||||
|
<span
|
||||||
|
class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"
|
||||||
|
></span>
|
||||||
|
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
{:else if bc.connState === 'connecting'}
|
||||||
|
<svg
|
||||||
|
class="animate-spin text-[var(--color-text-tertiary)]"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="h-[7px] w-[7px] rounded-full {bc.connState === 'error'
|
||||||
|
? 'bg-[var(--color-red)]'
|
||||||
|
: 'bg-[var(--color-text-muted)]'}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-label text-[var(--color-text-tertiary)]">{connLabel(bc.connState)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal + step list -->
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<div class="relative min-w-0 flex-1 bg-[var(--color-bg-0)]">
|
||||||
|
<div bind:this={containerRef} class="terminal-host absolute inset-0"></div>
|
||||||
|
</div>
|
||||||
|
<aside
|
||||||
|
class="flex w-72 shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-bg-1)]"
|
||||||
|
>
|
||||||
|
<BuildStepList steps={bc.steps} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if bc.errorMessage}
|
||||||
|
<div
|
||||||
|
class="border-t border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-2 font-mono text-meta text-[var(--color-red)]"
|
||||||
|
>
|
||||||
|
{bc.errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.terminal-host :global(.xterm) {
|
||||||
|
padding: 12px 4px 12px 16px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.terminal-host :global(.xterm-viewport),
|
||||||
|
.terminal-host :global(.xterm-screen) {
|
||||||
|
background-color: #0a0c0b !important;
|
||||||
|
}
|
||||||
|
.terminal-host :global(.xterm-viewport) {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(94, 140, 88, 0.18) transparent;
|
||||||
|
}
|
||||||
|
.terminal-host :global(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.terminal-host :global(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||||
|
background: rgba(94, 140, 88, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.terminal-host :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background: rgba(94, 140, 88, 0.32);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
127
frontend/src/lib/components/BuildStepList.svelte
Normal file
127
frontend/src/lib/components/BuildStepList.svelte
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BuildStep } from '$lib/build-console-ws.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
steps: BuildStep[];
|
||||||
|
};
|
||||||
|
let { steps }: Props = $props();
|
||||||
|
|
||||||
|
// [keyword, rest] split of a recipe instruction.
|
||||||
|
function splitInstruction(cmd: string): [string, string] {
|
||||||
|
const idx = cmd.indexOf(' ');
|
||||||
|
if (idx === -1) return [cmd.toUpperCase(), ''];
|
||||||
|
return [cmd.slice(0, idx).toUpperCase(), cmd.slice(idx + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function keywordColor(keyword: string): string {
|
||||||
|
switch (keyword) {
|
||||||
|
case 'RUN':
|
||||||
|
return 'var(--color-blue)';
|
||||||
|
case 'START':
|
||||||
|
return 'var(--color-accent-bright)';
|
||||||
|
case 'ENV':
|
||||||
|
return 'var(--color-amber)';
|
||||||
|
case 'USER':
|
||||||
|
return 'var(--color-accent)';
|
||||||
|
case 'COPY':
|
||||||
|
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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-2.5">
|
||||||
|
<span class="text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
|
||||||
|
Steps
|
||||||
|
</span>
|
||||||
|
{#if steps.length > 0}
|
||||||
|
<span class="font-mono text-label tabular-nums text-[var(--color-text-muted)]">
|
||||||
|
{steps.length}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{#each steps as s (s.step)}
|
||||||
|
{@const [kw, rest] = splitInstruction(s.cmd)}
|
||||||
|
<div class="border-b border-[var(--color-border)] px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
{#if s.status === 'running'}
|
||||||
|
<span class="relative flex h-2 w-2 shrink-0">
|
||||||
|
<span
|
||||||
|
class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-blue)] opacity-60"
|
||||||
|
></span>
|
||||||
|
<span class="relative inline-flex h-2 w-2 rounded-full bg-[var(--color-blue)]"></span>
|
||||||
|
</span>
|
||||||
|
{:else if s.status === 'success'}
|
||||||
|
<svg
|
||||||
|
class="shrink-0"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-accent-bright)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="shrink-0"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-red)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="font-mono text-label tabular-nums text-[var(--color-text-muted)]">
|
||||||
|
{s.step}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
{#if s.exit !== null && s.exit !== 0}
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-[var(--color-red)]/10 px-1.5 py-0.5 font-mono text-label text-[var(--color-red)]"
|
||||||
|
>
|
||||||
|
exit {s.exit}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.elapsedMs !== null}
|
||||||
|
<span class="font-mono text-label tabular-nums text-[var(--color-text-muted)]">
|
||||||
|
{formatMs(s.elapsedMs)}
|
||||||
|
</span>
|
||||||
|
{/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}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if steps.length === 0}
|
||||||
|
<div class="px-4 py-8 text-center text-meta text-[var(--color-text-muted)]">
|
||||||
|
Waiting for the first step...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@ -11,7 +11,7 @@
|
|||||||
};
|
};
|
||||||
let { open, onclose, oncreated, templateSource = 'team' }: Props = $props();
|
let { open, onclose, oncreated, templateSource = 'team' }: Props = $props();
|
||||||
|
|
||||||
let createForm = $state<CreateCapsuleParams>({ template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
|
let createForm = $state<CreateCapsuleParams>({ template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, timeout_sec: 0 });
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let createError = $state<string | null>(null);
|
let createError = $state<string | null>(null);
|
||||||
|
|
||||||
@ -24,27 +24,43 @@
|
|||||||
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
let inputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||||
let listEl = $state<HTMLUListElement | undefined>(undefined);
|
let listEl = $state<HTMLUListElement | undefined>(undefined);
|
||||||
|
|
||||||
|
// The reference the API understands. Anything the team doesn't own launches
|
||||||
|
// as "<slug>/<name>" — foreign public templates by their team slug, platform
|
||||||
|
// templates as "wrenn/<name>". Own templates (and any missing a slug) use
|
||||||
|
// their bare name.
|
||||||
|
function templateRef(t: Snapshot): string {
|
||||||
|
return t.owned || !t.team_slug ? t.name : `${t.team_slug}/${t.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve selected template for type indicator + snapshot locking
|
// Resolve selected template for type indicator + snapshot locking
|
||||||
let selectedTemplate = $derived(
|
let selectedTemplate = $derived(
|
||||||
templates.find((t) => t.name === createForm.template)
|
templates.find((t) => templateRef(t) === createForm.template)
|
||||||
);
|
);
|
||||||
let selectedIsSnapshot = $derived(selectedTemplate?.type === 'snapshot');
|
let selectedIsSnapshot = $derived(selectedTemplate?.type === 'snapshot');
|
||||||
|
|
||||||
let filtered = $derived.by(() => {
|
let filtered = $derived.by(() => {
|
||||||
const q = templateQuery.toLowerCase();
|
const q = templateQuery.toLowerCase();
|
||||||
if (!q) return templates;
|
if (!q) return templates;
|
||||||
return templates.filter((t) => t.name.toLowerCase().includes(q));
|
return templates.filter(
|
||||||
|
(t) => templateRef(t).toLowerCase().includes(q) || t.name.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch templates when dialog opens
|
// Fetch templates when dialog opens
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open && templates.length === 0 && !templatesLoading) {
|
if (open && templates.length === 0 && !templatesLoading) {
|
||||||
templatesLoading = true;
|
templatesLoading = true;
|
||||||
const fetcher = templateSource === 'platform' ? listPlatformTemplates : listSnapshots;
|
if (templateSource === 'platform') {
|
||||||
fetcher().then((result) => {
|
listPlatformTemplates().then((result) => {
|
||||||
if (result.ok) templates = result.data;
|
if (result.ok) templates = result.data;
|
||||||
templatesLoading = false;
|
templatesLoading = false;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
listSnapshots().then((result) => {
|
||||||
|
if (result.ok) templates = result.data.templates;
|
||||||
|
templatesLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (open) {
|
if (open) {
|
||||||
templateQuery = createForm.template ?? '';
|
templateQuery = createForm.template ?? '';
|
||||||
@ -52,8 +68,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function selectTemplate(t: Snapshot) {
|
function selectTemplate(t: Snapshot) {
|
||||||
createForm.template = t.name;
|
const ref = templateRef(t);
|
||||||
templateQuery = t.name;
|
createForm.template = ref;
|
||||||
|
templateQuery = ref;
|
||||||
// Pre-fill specs from the template if available
|
// Pre-fill specs from the template if available
|
||||||
if (t.vcpus) createForm.vcpus = t.vcpus;
|
if (t.vcpus) createForm.vcpus = t.vcpus;
|
||||||
if (t.memory_mb) createForm.memory_mb = t.memory_mb;
|
if (t.memory_mb) createForm.memory_mb = t.memory_mb;
|
||||||
@ -116,17 +133,20 @@
|
|||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
creating = true;
|
creating = true;
|
||||||
createError = null;
|
createError = null;
|
||||||
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
|
try {
|
||||||
const result = await creator(createForm);
|
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
|
||||||
if (result.ok) {
|
const result = await creator(createForm);
|
||||||
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
|
if (result.ok) {
|
||||||
templateQuery = 'minimal';
|
createForm = { template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, timeout_sec: 0 };
|
||||||
oncreated?.(result.data);
|
templateQuery = 'minimal-ubuntu';
|
||||||
onclose();
|
onclose();
|
||||||
} else {
|
oncreated?.(result.data);
|
||||||
createError = result.error;
|
} else {
|
||||||
|
createError = result.error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
}
|
}
|
||||||
creating = false;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -203,7 +223,7 @@
|
|||||||
{templateQuery ? 'No matching templates' : 'No templates available'}
|
{templateQuery ? 'No matching templates' : 'No templates available'}
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
{#each filtered as t, i (t.name)}
|
{#each filtered as t, i (templateRef(t))}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
@ -212,7 +232,7 @@
|
|||||||
{i === highlightIdx
|
{i === highlightIdx
|
||||||
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
|
||||||
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-4)]'}
|
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-4)]'}
|
||||||
{createForm.template === t.name ? 'font-medium' : ''}"
|
{createForm.template === templateRef(t) ? 'font-medium' : ''}"
|
||||||
onmousedown={(e) => { e.preventDefault(); selectTemplate(t); }}
|
onmousedown={(e) => { e.preventDefault(); selectTemplate(t); }}
|
||||||
onmouseenter={() => { highlightIdx = i; }}
|
onmouseenter={() => { highlightIdx = i; }}
|
||||||
>
|
>
|
||||||
@ -226,7 +246,16 @@
|
|||||||
base
|
base
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate font-mono text-meta">{t.name}</span>
|
<span class="truncate font-mono text-meta">
|
||||||
|
{#if !t.owned && t.team_slug}<span class="text-[var(--color-text-tertiary)]">{t.team_slug}/</span>{/if}{t.name}
|
||||||
|
</span>
|
||||||
|
{#if t.public}
|
||||||
|
<span class="shrink-0 text-[var(--color-accent-mid)]" title="Public template">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<!-- Specs hint -->
|
<!-- Specs hint -->
|
||||||
{#if t.vcpus && t.memory_mb}
|
{#if t.vcpus && t.memory_mb}
|
||||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
<span class="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
@ -234,7 +263,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Selected check -->
|
<!-- Selected check -->
|
||||||
{#if createForm.template === t.name}
|
{#if createForm.template === templateRef(t)}
|
||||||
<svg class="ml-auto shrink-0 text-[var(--color-accent)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="ml-auto shrink-0 text-[var(--color-accent)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -213,6 +213,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lastDataKey = '';
|
||||||
updateCharts();
|
updateCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +234,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
loadMetrics();
|
||||||
const mod = await import('chart.js/auto');
|
const mod = await import('chart.js/auto');
|
||||||
ChartJS = mod.Chart;
|
ChartJS = mod.Chart;
|
||||||
|
|
||||||
|
|||||||
@ -100,7 +100,7 @@
|
|||||||
teamPopoverOpen = false;
|
teamPopoverOpen = false;
|
||||||
const result = await switchTeam(teamId);
|
const result = await switchTeam(teamId);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
auth.login(result.data);
|
auth.setUser(result.data);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,7 +113,7 @@
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const switchResult = await switchTeam(result.data.id);
|
const switchResult = await switchTeam(result.data.id);
|
||||||
if (switchResult.ok) {
|
if (switchResult.ok) {
|
||||||
auth.login(switchResult.data);
|
auth.setUser(switchResult.data);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
createTeamError = switchResult.error;
|
createTeamError = switchResult.error;
|
||||||
|
|||||||
@ -1,14 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createSnapshot } from '$lib/api/capsules';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { createSnapshot, type Capsule } from '$lib/api/capsules';
|
||||||
|
import type { ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
|
type SnapshotFn = (capsuleId: string, name?: string) => Promise<ApiResult<Capsule>>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
capsuleId: string;
|
capsuleId: string;
|
||||||
pauseFirst?: boolean;
|
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onsnapshot?: () => void;
|
onsnapshot?: (capsule: Capsule) => void;
|
||||||
|
title?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
hint?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
pendingLabel?: string;
|
||||||
|
snapshotFn?: SnapshotFn;
|
||||||
|
description?: Snippet;
|
||||||
};
|
};
|
||||||
let { open, capsuleId, pauseFirst = false, onclose, onsnapshot }: Props = $props();
|
let {
|
||||||
|
open,
|
||||||
|
capsuleId,
|
||||||
|
onclose,
|
||||||
|
onsnapshot,
|
||||||
|
title = 'Capture snapshot',
|
||||||
|
label = 'Snapshot name',
|
||||||
|
placeholder = 'e.g. after-apt-install, pre-migration',
|
||||||
|
hint = 'Leave blank to use an auto-generated name.',
|
||||||
|
confirmLabel = 'Start snapshot',
|
||||||
|
pendingLabel = 'Starting...',
|
||||||
|
snapshotFn = createSnapshot,
|
||||||
|
description
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let snapshotName = $state('');
|
let snapshotName = $state('');
|
||||||
let snapshotting = $state(false);
|
let snapshotting = $state(false);
|
||||||
@ -22,14 +46,15 @@
|
|||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
snapshotting = true;
|
snapshotting = true;
|
||||||
error = null;
|
error = null;
|
||||||
const result = await createSnapshot(capsuleId, snapshotName.trim() || undefined);
|
const result = await snapshotFn(capsuleId, snapshotName.trim() || undefined);
|
||||||
if (result.ok) {
|
if (!result.ok) {
|
||||||
reset();
|
|
||||||
onsnapshot?.();
|
|
||||||
onclose();
|
|
||||||
} else {
|
|
||||||
error = result.error;
|
error = result.error;
|
||||||
|
snapshotting = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
reset();
|
||||||
|
onsnapshot?.(result.data);
|
||||||
|
onclose();
|
||||||
snapshotting = false;
|
snapshotting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +66,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet defaultDescription()}
|
||||||
|
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule moves to a <span class="font-mono text-[var(--color-blue)]">snapshotting</span> state while its memory and disk are written to a new template, then returns to running. This runs in the background; you'll be notified when it completes.</p>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
@ -59,24 +88,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Capture snapshot</h2>
|
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">{title}</h2>
|
||||||
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{capsuleId}</p>
|
<p class="mt-0.5 text-meta text-[var(--color-text-muted)] font-mono">{capsuleId}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 pt-5 pb-6 space-y-4">
|
<div class="px-6 pt-5 pb-6 space-y-4">
|
||||||
{#if pauseFirst}
|
{@render (description ?? defaultDescription)()}
|
||||||
<div class="flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/25 bg-[var(--color-amber)]/8 px-3 py-2.5">
|
|
||||||
<svg class="mt-px shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
|
||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-meta text-[var(--color-amber)] leading-relaxed">This capsule will be <strong class="font-semibold">paused first</strong>, then its full state (memory + disk) will be captured.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-ui text-[var(--color-text-tertiary)]">The capsule's current state (memory + disk) will be captured and stored as a reusable snapshot.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
<div class="rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||||
@ -86,7 +104,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 flex items-baseline justify-between">
|
<div class="mb-1.5 flex items-baseline justify-between">
|
||||||
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">Snapshot name</label>
|
<label class="text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="snapshot-name">{label}</label>
|
||||||
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
|
<span class="text-meta text-[var(--color-text-muted)]">optional</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -95,10 +113,10 @@
|
|||||||
bind:value={snapshotName}
|
bind:value={snapshotName}
|
||||||
disabled={snapshotting}
|
disabled={snapshotting}
|
||||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
|
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-50"
|
||||||
placeholder="e.g. after-apt-install, pre-migration"
|
placeholder={placeholder}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleConfirm(); }}
|
onkeydown={(e) => { if (e.key === 'Enter' && !snapshotting) handleConfirm(); }}
|
||||||
/>
|
/>
|
||||||
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Leave blank to use an auto-generated name.</p>
|
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">{hint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-1">
|
<div class="flex justify-end gap-3 pt-1">
|
||||||
@ -118,9 +136,9 @@
|
|||||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
</svg>
|
</svg>
|
||||||
Capturing...
|
{pendingLabel}
|
||||||
{:else}
|
{:else}
|
||||||
Capture snapshot
|
{confirmLabel}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -78,19 +78,6 @@
|
|||||||
brightWhite: '#eae7e2',
|
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 {
|
function getWsUrl(): string {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
|
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
|
||||||
@ -104,6 +91,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Keystroke buffers come from TextEncoder and are always ArrayBuffer-backed
|
||||||
|
// (never SharedArrayBuffer); assert that so send() accepts them as BufferSource.
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(data as Uint8Array<ArrayBuffer>);
|
||||||
|
} catch {
|
||||||
|
// Connection closing — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
||||||
const idx = sessions.findIndex(s => s.id === id);
|
const idx = sessions.findIndex(s => s.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
@ -230,7 +229,7 @@
|
|||||||
if (!int) return;
|
if (!int) return;
|
||||||
int.inputFlushTimer = null;
|
int.inputFlushTimer = null;
|
||||||
if (!int.inputBuffer) return;
|
if (!int.inputBuffer) return;
|
||||||
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
|
wsSendBinary(int.ws, new TextEncoder().encode(int.inputBuffer));
|
||||||
int.inputBuffer = '';
|
int.inputBuffer = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,7 +254,7 @@
|
|||||||
const int = internals.get(id);
|
const int = internals.get(id);
|
||||||
if (!int) return;
|
if (!int) return;
|
||||||
|
|
||||||
if (!auth.token) {
|
if (!auth.isAuthenticated) {
|
||||||
updateSession(id, { state: 'error', errorMessage: 'Not authenticated' });
|
updateSession(id, { state: 'error', errorMessage: 'Not authenticated' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -263,13 +262,15 @@
|
|||||||
const display = sessions.find(s => s.id === id);
|
const display = sessions.find(s => s.id === id);
|
||||||
const tag = reconnectTag ?? display?.ptyTag;
|
const tag = reconnectTag ?? display?.ptyTag;
|
||||||
|
|
||||||
|
// Browser sends wrenn_sid cookie on the WS upgrade automatically (same-origin).
|
||||||
const ws = new WebSocket(getWsUrl());
|
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;
|
int.ws = ws;
|
||||||
updateSession(id, { state: 'connecting', errorMessage: null });
|
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
// Send auth as the first message (JWT no longer in URL).
|
|
||||||
wsSend(ws, JSON.stringify({ type: 'auth', token: auth.token }));
|
|
||||||
const { cols, rows } = int.term;
|
const { cols, rows } = int.term;
|
||||||
const msg: Record<string, unknown> = {
|
const msg: Record<string, unknown> = {
|
||||||
type: tag ? 'connect' : 'start',
|
type: tag ? 'connect' : 'start',
|
||||||
@ -279,13 +280,18 @@
|
|||||||
if (tag) {
|
if (tag) {
|
||||||
msg.tag = tag;
|
msg.tag = tag;
|
||||||
} else {
|
} else {
|
||||||
msg.cmd = '/bin/bash';
|
// No cmd: the server launches the user's default login shell.
|
||||||
msg.envs = { TERM: 'xterm-256color' };
|
msg.envs = { TERM: 'xterm-256color' };
|
||||||
}
|
}
|
||||||
wsSend(ws, JSON.stringify(msg));
|
wsSend(ws, JSON.stringify(msg));
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
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 {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
@ -297,9 +303,6 @@
|
|||||||
});
|
});
|
||||||
if (activeSessionId === id) int.term.focus();
|
if (activeSessionId === id) int.term.focus();
|
||||||
break;
|
break;
|
||||||
case 'output':
|
|
||||||
if (msg.data) int.term.write(fromBase64(msg.data));
|
|
||||||
break;
|
|
||||||
case 'exit':
|
case 'exit':
|
||||||
closeSession(id);
|
closeSession(id);
|
||||||
break;
|
break;
|
||||||
|
|||||||
39
frontend/src/lib/lifecycle-toasts.ts
Normal file
39
frontend/src/lib/lifecycle-toasts.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { SSEEvent } from '$lib/api/events';
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
|
||||||
|
// Terminal copy per lifecycle verb. Success and failure are paired so the two
|
||||||
|
// can never drift apart.
|
||||||
|
const VERBS: Record<string, { done: string; failed: string }> = {
|
||||||
|
'capsule.create': { done: 'Capsule created', failed: 'Capsule failed to start' },
|
||||||
|
'capsule.pause': { done: 'Capsule paused', failed: 'Capsule failed to pause' },
|
||||||
|
'capsule.resume': { done: 'Capsule resumed', failed: 'Capsule failed to resume' },
|
||||||
|
'capsule.destroy': { done: 'Capsule destroyed', failed: 'Capsule failed to destroy' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surfaces lifecycle outcomes as toasts. Only system-actor events with an
|
||||||
|
* outcome are terminal: the user-actor events published at request-accept time
|
||||||
|
* carry a premature outcome (the operation has only been accepted, not yet
|
||||||
|
* completed) and are skipped, so each operation toasts exactly once.
|
||||||
|
*/
|
||||||
|
export function lifecycleToast(event: SSEEvent): void {
|
||||||
|
if (event.actor?.type !== 'system' || !event.outcome) return;
|
||||||
|
|
||||||
|
if (event.event === 'template.snapshot.create') {
|
||||||
|
const name = event.resource?.id;
|
||||||
|
if (event.outcome === 'success') {
|
||||||
|
toast.success(name ? `Snapshot "${name}" captured` : 'Snapshot captured');
|
||||||
|
} else {
|
||||||
|
toast.error(event.error ? `Snapshot failed: ${event.error}` : 'Snapshot failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verb = VERBS[event.event];
|
||||||
|
if (!verb) return;
|
||||||
|
if (event.outcome === 'success') {
|
||||||
|
toast.success(verb.done);
|
||||||
|
} else {
|
||||||
|
toast.error(event.error ? `${verb.failed}: ${event.error}` : verb.failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/src/lib/sse.svelte.ts
Normal file
75
frontend/src/lib/sse.svelte.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { connectEventStream, type SSEEvent, type EventStreamConnection } from '$lib/api/events';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
|
type SSEListener = (event: SSEEvent) => void;
|
||||||
|
|
||||||
|
let connection: EventStreamConnection | null = null;
|
||||||
|
let adminConnection: EventStreamConnection | null = null;
|
||||||
|
let listeners = new Set<SSEListener>();
|
||||||
|
let adminListeners = new Set<SSEListener>();
|
||||||
|
let started = false;
|
||||||
|
let adminStarted = false;
|
||||||
|
|
||||||
|
function dispatch(event: SSEEvent) {
|
||||||
|
for (const fn of listeners) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminDispatch(event: SSEEvent) {
|
||||||
|
for (const fn of adminListeners) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConnected() {
|
||||||
|
if (connection || !auth.isAuthenticated) return;
|
||||||
|
connection = connectEventStream(dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAdminConnected() {
|
||||||
|
if (adminConnection || !auth.isAuthenticated) return;
|
||||||
|
adminConnection = connectEventStream(adminDispatch, { admin: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSSE() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
ensureConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSSE() {
|
||||||
|
started = false;
|
||||||
|
connection?.close();
|
||||||
|
connection = null;
|
||||||
|
listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAdminSSE() {
|
||||||
|
if (adminStarted) return;
|
||||||
|
adminStarted = true;
|
||||||
|
ensureAdminConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAdminSSE() {
|
||||||
|
adminStarted = false;
|
||||||
|
adminConnection?.close();
|
||||||
|
adminConnection = null;
|
||||||
|
adminListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSSE(fn: SSEListener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
ensureConnected();
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAdminSSE(fn: SSEListener): () => void {
|
||||||
|
adminListeners.add(fn);
|
||||||
|
ensureAdminConnected();
|
||||||
|
return () => {
|
||||||
|
adminListeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user