diff --git a/.github/workflows/boot-init.yml b/.github/workflows/boot-init.yml index 2c834f44..59631a4e 100644 --- a/.github/workflows/boot-init.yml +++ b/.github/workflows/boot-init.yml @@ -8,6 +8,7 @@ on: branches: [main] paths: - "boot/init/**" + - ".github/workflows/boot-init.yml" pull_request: paths: - "boot/init/**" @@ -27,5 +28,5 @@ jobs: working-directory: boot/init run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked diff --git a/.github/workflows/build-boot.yml b/.github/workflows/build-boot.yml index 17d9b8a1..6037132c 100644 --- a/.github/workflows/build-boot.yml +++ b/.github/workflows/build-boot.yml @@ -38,8 +38,8 @@ jobs: working-directory: boot/init run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked build: needs: test diff --git a/.github/workflows/build-os-images.yml b/.github/workflows/build-os-images.yml index 99fd6b43..c566c451 100644 --- a/.github/workflows/build-os-images.yml +++ b/.github/workflows/build-os-images.yml @@ -76,9 +76,12 @@ jobs: } # A changed carrier (boot/silkd) refolds into every image; a manual - # run or a first push (no BEFORE) rebuilds all. Otherwise only the - # git-changed image dirs rebuild. - if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then + # run, a first push (no BEFORE), or a change to how the images are + # built rebuilds all. Otherwise only the git-changed image dirs + # rebuild. The workflow diff is last so a manual run never runs git. + wf=.github/workflows + if [[ "$CARRIER_CHANGED" == "true" || "$MANUAL" == "true" || "$BEFORE" == "0000000000000000000000000000000000000000" ]] || + git diff --name-only "$BEFORE" "$SHA" -- "$wf/build-os-images.yml" "$wf/images.yml" | grep -q .; then all_names all_changed="" else @@ -109,7 +112,7 @@ jobs: needs_no_cache="true" elif [[ -n "$all_changed" ]]; then while IFS= read -r f; do - [[ -z "$f" ]] && continue + [[ -z "$f" || "$f" == *.md ]] && continue depth=$(echo "$f" | awk -F'/' '{print NF}') base_name=$(basename "$f") if [[ "$depth" -eq 3 ]] || { [[ "$depth" -eq 4 ]] && [[ "$base_name" != "Dockerfile" ]]; }; then @@ -195,7 +198,7 @@ jobs: - name: Hash image inputs id: inputs run: | - hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) + hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) echo "hash=$hash" >> "$GITHUB_OUTPUT" - name: Discover secret files @@ -286,7 +289,7 @@ jobs: - name: Hash image inputs id: inputs run: | - hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) + hash=$(find "os-image/${{ matrix.name }}" -type f ! -name Dockerfile ! -name '*.md' -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1) echo "hash=$hash" >> "$GITHUB_OUTPUT" - name: Discover secret files diff --git a/.github/workflows/build-silkd.yml b/.github/workflows/build-silkd.yml index 27d6d644..fcde41a1 100644 --- a/.github/workflows/build-silkd.yml +++ b/.github/workflows/build-silkd.yml @@ -34,8 +34,8 @@ jobs: working-directory: silkd run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked build: needs: test diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 7ba5a3aa..312bc82a 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -15,6 +15,10 @@ on: - "silkd/**" - "protocol/**" - "os-image/**" + - ".github/workflows/images.yml" + - ".github/workflows/build-os-images.yml" + - ".github/workflows/build-silkd.yml" + - ".github/workflows/build-boot.yml" workflow_dispatch: inputs: no-cache: @@ -55,9 +59,12 @@ jobs: boot=true; silkd=true; osimage=true else changed() { git diff --name-only "$BEFORE" "$SHA" -- "$@" | grep -q .; } - changed boot/ && boot=true || boot=false - changed silkd/ protocol/ && silkd=true || silkd=false - changed os-image/ && osimage=true || osimage=false + # Each area carries its own build workflow: changing how an image is + # built must rebuild it, or the run goes green having built nothing. + wf=.github/workflows + changed boot/ $wf/build-boot.yml && boot=true || boot=false + changed silkd/ protocol/ $wf/build-silkd.yml && silkd=true || silkd=false + changed os-image/ $wf/build-os-images.yml $wf/images.yml && osimage=true || osimage=false fi { echo "boot=$boot" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 95c46158..f0812f54 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -58,7 +58,7 @@ jobs: # The in-repo base SDK installs first, so an adapter release never # resolves (or fails to resolve) cocoonstack-sandbox from PyPI. python -m pip install -q ./sdk/python - python -m pip install -q "${{ steps.pkg.outputs.dir }}" pytest ruff + python -m pip install -q "${{ steps.pkg.outputs.dir }}" ruff==0.15.20 pytest==8.4.2 (cd "${{ steps.pkg.outputs.dir }}" && ruff check . && python -m pytest -q) - name: Build sdist + wheel diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 6ee70b0c..4effbbe4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,6 +2,7 @@ name: python on: push: + branches: [main] paths: - "sdk/python/**" - "sdk/openai/**" @@ -20,6 +21,8 @@ on: - "e2e/**/*.py" - "ruff.toml" - "protocol/**" + - ".github/workflows/python.yml" + workflow_dispatch: jobs: test: diff --git a/.github/workflows/silkd.yml b/.github/workflows/silkd.yml index 4ed2ecc8..849cff73 100644 --- a/.github/workflows/silkd.yml +++ b/.github/workflows/silkd.yml @@ -9,6 +9,7 @@ on: paths: - "silkd/**" - "protocol/**" + - ".github/workflows/silkd.yml" pull_request: paths: - "silkd/**" @@ -29,5 +30,5 @@ jobs: working-directory: silkd run: | cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test + cargo clippy --locked --all-targets -- -D warnings + cargo test --locked diff --git a/Makefile b/Makefile index d55553c3..c4dabce9 100644 --- a/Makefile +++ b/Makefile @@ -91,6 +91,7 @@ base: silkd-image ## base VM image against the local boot + silkd images docker build -t sandbox-base:dev \ --build-arg BOOT_IMAGE=$(BOOT_IMAGE) \ --build-arg SILKD_IMAGE=$(SILKD_IMAGE) \ + --build-arg INPUTS_HASH=$$(find os-image/base -type f ! -name Dockerfile -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1) \ --secret id=sandbox_install_agent,src=os-image/base/install-agent.sh \ -f os-image/base/24.04/Dockerfile os-image/base @@ -99,4 +100,4 @@ python: base ## python flavor image on top of base --build-arg BASE_IMAGE=sandbox-base:dev \ -f os-image/python/3.12/Dockerfile os-image/python -images: base python ## all VM images +images: base python ## the local base and python images (CI builds every flavor) diff --git a/README.md b/README.md index 79deb737..b9385c17 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ performance) — source in cocoon+guest) plus bare-metal acceptance drivers under `cmd/`: `demo`, `smoke`, `meshsmoke`, `crossnode`, `coldproof`, `egresssmoke`, `interceptsmoke`, `sockssmoke`, `volumesmoke`, `lifecycle` (idle→hibernate→archive), - `androidsmoke`, `browsersmoke`, `desktopsmoke`, and the `pullbench`/`pushbench`/`rpcbench`/`qaab` - perf drivers + `androidsmoke`, `browsersmoke`, `desktopsmoke`, `ringsmoke` (output ring cap, exec as a user), + and the `pullbench`/`pushbench`/`rpcbench`/`qaab` perf drivers - `boot/kernel/` — kernel version pin (`VERSION` + matching tarball `SHA256`, bump both together) + config fragment (amd64: over `x86_64_defconfig` + `kvm_guest.config`; arm64: over `defconfig`, then `sandbox-arm64.config`) @@ -159,6 +159,11 @@ Boot contract (cmdline keys consumed by sandbox-init): | `sandbox.debug=1` | fatal errors drop to `/bin/sh` (debug initramfs) instead of poweroff | | `sandbox.trace=1` | emit one pre-handoff line with per-phase µs timings | +The pinned kernel builds no 8250 UART, so on cocoon's Firecracker lane +(`console=ttyS0`) the trace line, the debug shell and the fatal-error +message all go nowhere; they are visible on the Cloud Hypervisor lane's +virtio console. + `boot=cocoon-overlay` is ignored. Everything cocoon passes today keeps working — images built here boot with an unmodified cocoon. diff --git a/boot/Dockerfile b/boot/Dockerfile index 1720e9c3..c24c27c1 100644 --- a/boot/Dockerfile +++ b/boot/Dockerfile @@ -46,7 +46,7 @@ RUN set -e; \ case "$TARGETARCH" in \ amd64) make x86_64_defconfig kvm_guest.config; \ ./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config; \ - arch_asserts="PVH VIRTIO_CONSOLE" ;; \ + arch_asserts="PVH" ;; \ arm64) make defconfig; \ ./scripts/kconfig/merge_config.sh -m .config /tmp/sandbox.config /tmp/sandbox-arm64.config; \ arch_asserts="RTC_DRV_PL031" ;; \ diff --git a/docs/sdk-python.md b/docs/sdk-python.md index ec92e265..121cdf0b 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -498,9 +498,11 @@ zero. - `APIError(verb, status, message)` — control plane (HTTP status) - `SilkdError(kind, message)` — typed guest failure; `kind` is `bad_request` (including a spawn the guest cannot start: missing binary, a - cwd that is not a directory, no exec bit) / `not_found` / `unimplemented` / - `internal` + cwd that is not a directory, no exec bit, a `user` the guest cannot + resolve) / `not_found` / `unimplemented` / `internal` - `ExitError(code, stderr, stdout)` — non-zero exit from `exec` +- `SandboxTimeout` — a caller's timeout or deadline expired before the call + finished; also a `TimeoutError` - `ProtocolError` — a failed relay dial or TLS handshake, or a broken stream (EOF, oversized or undecodable frame) diff --git a/docs/sdk.md b/docs/sdk.md index 1b5e5242..95cb8e44 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -629,7 +629,8 @@ claims; the fields are those of [`GET /v1/sandboxes`](sandboxd-api.md#get-v1sand - `*sandbox.ExitError` — non-zero exit from `Exec` (`Code`, `Stderr`) - `*wire.ErrorResp` — a typed guest-side failure; `Kind` is one of `wire.KindBadRequest` (including a spawn the guest cannot start: missing - binary, a cwd that is not a directory, no exec bit), `KindNotFound`, + binary, a cwd that is not a directory, no exec bit, a `User` the guest + cannot resolve), `KindNotFound`, `KindUnimplemented`, `KindInternal` (import `github.com/cocoonstack/sandbox/protocol/wire`) diff --git a/docs/silkd.md b/docs/silkd.md index 145fbdec..bca3387e 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -61,7 +61,7 @@ A failed verb terminates with `error {kind, message}`: | kind | meaning | |---|---| -| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable) | +| `bad_request` | malformed frame, empty argv, invalid pattern, an exec argv or cwd the guest cannot spawn (missing, not a directory, not executable), an exec or pty `user` the guest cannot resolve (`unknown user`, or `look up user: ` when the name service itself fails) | | `not_found` | unknown pid/session/path, a `port_forward` port nothing listens on, a language with no LSP manifest | | `unimplemented` | verb unavailable on this lane — notably git clone/push/pull on the no-network lane, whose message points at `fs.push` | | `internal` | everything else (other spawn failures, git errors, I/O) | diff --git a/os-image/android/silkd.rc b/os-image/android/silkd.rc index 0028d1e1..e508fe56 100644 --- a/os-image/android/silkd.rc +++ b/os-image/android/silkd.rc @@ -10,6 +10,7 @@ service silkd /system/bin/silkd user root group root setenv SILKD_PORT 2048 + setenv SHELL /system/bin/sh # Mirrors the base:24.04 unit; forwarded into execs only on the no-NIC lane. setenv http_proxy http://127.0.0.1:3128 setenv https_proxy http://127.0.0.1:3128 diff --git a/os-image/desktop/24.04/Dockerfile b/os-image/desktop/24.04/Dockerfile index 5d4cc481..9b448fc0 100644 --- a/os-image/desktop/24.04/Dockerfile +++ b/os-image/desktop/24.04/Dockerfile @@ -44,7 +44,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && install -d -m 0755 /etc/apt/keyrings \ && curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \ | gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg \ - && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \ + && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \ > /etc/apt/sources.list.d/google-chrome.list \ && apt-get update && apt-get install -y --no-install-recommends google-chrome-stable \ && rm -rf /var/lib/apt/lists/* \ diff --git a/scripts/bench.sh b/scripts/bench.sh index 76a9a676..fb44f0c2 100755 --- a/scripts/bench.sh +++ b/scripts/bench.sh @@ -169,7 +169,7 @@ done virt=$(systemd-detect-virt 2>/dev/null || true); virt=${virt:-unknown} [[ $virt == none ]] && envlabel="bare metal" || envlabel="nested ($virt)" cpu=$(awk -F': ' '/model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null || sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown) -digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}') +digest=$(cocoon image list 2>/dev/null | awk -v t="$TEMPLATE" '$2 ~ t {print $3; exit}' || true) echo echo "== results (paste into docs/benchmarks.md)" diff --git a/sdk/python/cocoonsandbox/conn.py b/sdk/python/cocoonsandbox/conn.py index a8018a2d..0dc4a264 100644 --- a/sdk/python/cocoonsandbox/conn.py +++ b/sdk/python/cocoonsandbox/conn.py @@ -57,7 +57,7 @@ def recv(self) -> dict[str, Any]: """Reads one frame or raises a typed protocol or guest error.""" try: line = self._reader.readline(MAX_FRAME + 1) - except OSError as exc: + except (OSError, ValueError) as exc: raise ProtocolError(f"read failed: {exc}") from exc if not line: raise ProtocolError("connection closed mid-stream") @@ -159,6 +159,8 @@ def dial_agent( ssl_context: ssl.SSLContext | None = None, ) -> Conn: """Opens one TCP/HTTP Upgrade relay within timeout and the optional deadline.""" + budget = time.monotonic() + timeout + deadline = budget if deadline is None else min(deadline, budget) for name, value in (("sandbox id", sandbox_id), ("token", token)): if any(c in value for c in "\r\n\0"): raise APIError("agent upgrade", 0, f"{name} contains a control character") diff --git a/sdk/python/cocoonsandbox/frames.py b/sdk/python/cocoonsandbox/frames.py index 64dd4aa6..137bec13 100644 --- a/sdk/python/cocoonsandbox/frames.py +++ b/sdk/python/cocoonsandbox/frames.py @@ -5,6 +5,7 @@ import base64 import binascii import json +import sys from typing import Any PROTO_VERSION = 1 @@ -14,6 +15,7 @@ FS_CHUNK = 256 * 1024 # tar and port streams chunk at 1 MiB: fewer frames per byte, still under MAX_FRAME after base64 BULK_CHUNK = 1 << 20 +_FAST_DATA = sys.version_info >= (3, 11) def encode_request(op: str, **fields: object) -> bytes: @@ -34,7 +36,7 @@ def encode_request(op: str, **fields: object) -> bytes: def decode_response(line: bytes) -> dict[str, Any]: """Decodes one response with binary payloads under data.""" # base64 is JSON-escape-free, so an exactly-shaped data frame slices without json.loads. - if line.startswith(b'{"type":"'): + if _FAST_DATA and line.startswith(b'{"type":"'): te = line.find(b'"', 9) if te > 0 and line[9:te] in (b"stdout", b"stderr", b"data") and line.startswith(b'","data":"', te): de = line.find(b'"', te + 10) diff --git a/sdk/python/tests/test_hardening.py b/sdk/python/tests/test_hardening.py index c2a97b2f..c0275a35 100644 --- a/sdk/python/tests/test_hardening.py +++ b/sdk/python/tests/test_hardening.py @@ -9,7 +9,18 @@ import pytest from conftest import sandbox_at -from cocoonsandbox import APIError, Client, Lsp, ProtocolError, Pty, SandboxError, Session, SilkdError, Watcher +from cocoonsandbox import ( + APIError, + Client, + Lsp, + ProtocolError, + Pty, + SandboxError, + SandboxTimeout, + Session, + SilkdError, + Watcher, +) from cocoonsandbox.conn import Conn, dial_agent, remaining_timeout from cocoonsandbox.frames import FS_CHUNK @@ -151,3 +162,39 @@ def test_sandbox_timeout_is_both_hierarchies(): remaining_timeout(1.0, time.monotonic() - 1, "probe") with pytest.raises(TimeoutError): remaining_timeout(1.0, time.monotonic() - 1, "probe") + + +def test_recv_after_close_is_a_protocol_error() -> None: + a, b = socket.socketpair() + conn = Conn(a, a.makefile("rb")) + conn.close() + b.close() + with pytest.raises(ProtocolError): + conn.recv() + + +def test_dial_timeout_bounds_the_whole_upgrade() -> None: + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + + def slow_headers() -> None: + peer, _ = server.accept() + peer.sendall(b"HTTP/1.1 101 Switching Protocols\r\n") + for n in range(8): + time.sleep(0.15) + try: + peer.sendall(f"X-Slow-{n}: 1\r\n".encode()) + except OSError: + break + peer.close() + + threading.Thread(target=slow_headers, daemon=True).start() + addr = f"127.0.0.1:{server.getsockname()[1]}" + started = time.monotonic() + with pytest.raises(SandboxTimeout): + dial_agent(addr, "sb_x", "tok", timeout=0.2) + assert time.monotonic() - started < 0.6, ( + "each header arrived under the per-read timeout; only a whole-dial budget stops this" + ) + server.close()