diff --git a/.changeset/container-instance-backend.md b/.changeset/container-instance-backend.md new file mode 100644 index 00000000..e952abb8 --- /dev/null +++ b/.changeset/container-instance-backend.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add a container backend for durable-object-scheduled containers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87115310..25b57935 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,9 @@ jobs: - name: assets workspace: "@example/computer-assets" path: examples/assets + - name: container + workspace: "@example/computer-container" + path: examples/container - name: container-legacy workspace: "@example/computer-container-legacy" path: examples/container-legacy diff --git a/README.md b/README.md index 859709c7..8e1ea38e 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,14 @@ for setup, build, and test instructions. The [`examples/`](examples) directory holds runnable consumers of the public surface. Each is a Worker workspace with its own README. -- [`examples/container-legacy`](examples/container-legacy) — runs `computerd` inside a - container, mounts a workspace, and talks to a Durable Object over - capnweb. A `write` / `read` / `exec` HTTP surface. +- [`examples/container`](examples/container) — runs `computerd` inside a + container the Durable Object schedules itself, mounts a workspace, and + talks to the object over capnweb. A `write` / `read` / `exec` HTTP + surface. The launch names the image and the instance size, because + `scheduling_policy: "durable_object"` moves both out of the config. +- [`examples/container-legacy`](examples/container-legacy) — the same + surface against a container the platform schedules and sizes from the + `containers` block. - [`examples/worker-shell`](examples/worker-shell) — same HTTP surface as the container example, but the shell runs [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker loaded through `env.LOADER`. No container. diff --git a/docs/01_vfs.md b/docs/01_vfs.md index b75059ab..7ad56989 100644 --- a/docs/01_vfs.md +++ b/docs/01_vfs.md @@ -16,17 +16,29 @@ container, not on `WorkspaceOptions`). ```ts import { Workspace } from "@cloudflare/computer"; -import { LegacyContainerBackend } from "@cloudflare/computer/backends/container-legacy"; - -new Workspace({ - storage: ctx.storage, - backends: [ - new LegacyContainerBackend({ - container: () => this, - workspace: { binding: "ContainerExample", id: ctx.id.toString() }, - }), - ], -}); +import { + ContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; +import { DurableObject } from "cloudflare:workers"; + +class WorkspaceHost extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "WorkspaceHost", id: this.ctx.id.toString() }, + name: "app", + instance: "standard-2", + }); + + readonly workspace = new Workspace({ + storage: this.ctx.storage, + backends: [this.backend], + }); + + override fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } +} ``` `backends` is optional. Omit it to construct a filesystem-only @@ -126,7 +138,7 @@ by the in-image `FUSE_MOUNT` env var (`auto` by default; see doc 07). On Cloudflare Containers `/dev/fuse` is exposed and the real kernel FUSE backend mounts; under `wrangler dev` it isn't, and `auto` falls back to the userspace shim. Either way the in-container view is a -live mirror of the DO-side VFS. Earlier revisions of `LegacyContainerBackend` +live mirror of the DO-side VFS. Earlier revisions of the container backend pinned `DISABLE_FUSE=1`, which produced a degraded mode where: - The in-container filesystem at `/workspace` is the container's own diff --git a/docs/07_injected_service.md b/docs/07_injected_service.md index cb021f26..a863262e 100644 --- a/docs/07_injected_service.md +++ b/docs/07_injected_service.md @@ -21,8 +21,8 @@ npm run build:bin --workspace @cloudflare/computerd # → artifacts/computerd/computerd-macos-x64 ``` -`examples/container-legacy/Dockerfile` is the canonical recipe for -staging the binary into a container image. +`examples/container/Dockerfile` is the canonical recipe for staging the +binary into a container image. ## Responsibilities @@ -65,7 +65,7 @@ The capnweb bootstrap interface is **`WorkspaceRPC`** (defined in ## Installing into your sandbox image -The canonical recipe is `examples/container-legacy/Dockerfile`: +The canonical recipe is `examples/container/Dockerfile`: ```dockerfile FROM --platform=linux/amd64 debian:stable-slim @@ -118,46 +118,43 @@ Provider-agnostic shape — three steps, in order: ### Cloudflare Containers specifics -`LegacyContainerBackend` (`packages/computer/src/backends/container-legacy/cloudflare-container.ts`) -wires it like this: - -1. **Start.** `LegacyWorkspaceContainerAPI.start({ env, enableInternet })`, - which reaches the Cloudflare Containers API — not the - `@cloudflare/sandbox` SDK. There is no process-name registry, no - `startProcess`/`getProcess`, and no `node /app/...` command (the - container's `ENTRYPOINT` runs `computerd` directly). `containerEnv` - pins `PORT=8080` and lets the image's own `FUSE_MOUNT` value - (typically `auto`) win, and the API adds `RPC_CLIENT_SECRET`. - - Neither the environment nor the internet flag can be changed on a - running container, so the launch records both and a container found - already running is only adopted when it matches. Otherwise it is - relaunched, which is what keeps a warm pool from handing a workspace - a container configured for something else. A container started - outside this API has no record and is relaunched too. +Cloudflare Computer has one backend for each container scheduling policy. + +`ContainerBackend` is the default. It works with containers that the durable +object schedules, configured with `scheduling_policy: "durable_object"` and +an `images` map. Each launch selects an image from +`ctx.container.images`. The backend can also request an `instance` size and +pass options such as `entrypoint`, `labels`, and snapshot settings to +`container.start()`. + +`LegacyContainerBackend` works with containers that the platform schedules. +The containers block chooses the image and instance size, so this backend +passes only the environment and internet setting when it starts the +container. + +Both backends use the same connection flow: + +1. **Start the container.** The image's `ENTRYPOINT` runs `computerd` + directly. The backend sets `PORT=8080`, preserves the image's + `FUSE_MOUNT` setting, and adds `RPC_CLIENT_SECRET`. It records the launch + settings so it can reject or replace a running container with the wrong + configuration. 2. **Wire egress.** `container.interceptOutboundHttp(egressHost, egress)` - routes outbound HTTP from the container at `egressHost` back to a - Worker `Fetcher` the DO controls. -3. **Probe.** `container.getTcpPort(containerPort).fetch("/health", { method: "HEAD" })`, - repeated until it returns `200`. -4. **Invert the WebSocket.** The DO arms an upgrade slot - (`#armUpgrade`) and then `POST`s to `/connect` on the container - (`#postConnect`). The request names the egress base and both paths, - so `computerd` polls `base + health` and then dials `base + api`; - the daemon assembles no paths of its own. Because the egress is - intercepted, - that outbound dial loops back to the DO's `handleFetch()`, which - accepts the upgrade and resolves the in-flight `#pendingUpgrade`. - The capnweb session then runs over that socket. **The WebSocket - carrier is inverted** versus a naive "host dials into container" - model. - -Sharp edges actually present in `cloudflare-container.ts`: - -- `#armUpgrade` must be set up *before* `#postConnect`, because `computerd` - can dial back before the `POST /connect` response returns. -- The container host records each monitored generation's exit reason. The dead container closes its WebSocket, and `fetchPort()` also short-circuits later requests with a transport error; either path invalidates the matching Workspace handle. -- **Reconnect replaces the whole session.** If the WebSocket dies, `Workspace` invalidates and closes the matching backend handle, then calls `LegacyContainerBackend.connect()` again. The replacement runs the complete start, egress-interception, health, `/connect`, and reverse-WebSocket sequence; the backend never splices a new carrier into the dead capnweb session. Replay-safe sync and process lifecycle operations get one retry. Command spawn is retried only when no request was dispatched. + routes outbound HTTP from the container back to a Worker `Fetcher` owned + by the durable object. +3. **Probe health.** The backend sends `HEAD /health` through the + container's TCP port until `computerd` responds. +4. **Open the WebSocket.** The backend prepares an upgrade slot before it + posts to `/connect`. `computerd` then dials the intercepted egress URL, + which routes the upgrade back to `handleFetch()`. The capnweb session + runs over that WebSocket. + +A reconnect creates a complete new session. If the WebSocket closes, +`Workspace` drops the old backend handle and calls the selected backend's +`connect()` method again. The replacement starts or adopts the container, +checks health, and repeats the `/connect` handshake. Sync and process +lifecycle operations get one retry when replay is safe. A command is only +retried when the request was not dispatched. ## Environment variables diff --git a/docs/11_lifecycle.md b/docs/11_lifecycle.md index c33b01d1..a0fc3f62 100644 --- a/docs/11_lifecycle.md +++ b/docs/11_lifecycle.md @@ -125,7 +125,10 @@ lifetime policy. From the DO's perspective: `computerd` is a long-lived process. It outlives DO restarts — the `Container.monitor()` promise resolves only when the container itself exits, and the backend's `#monitoring` flag drops the cached handle at -that point so the next call rebuilds from scratch (see the container host and backend implementations under `packages/computer/src/backends/container-legacy/`). +that point so the next call rebuilds from scratch. The primary implementation +lives under `packages/computer/src/backends/container/`; the +platform-scheduled variant lives under +`packages/computer/src/backends/container-legacy/`. When `computerd` runs with its default in-memory store, the two sides differ: the **container's VFS lasts only as long as the process**, @@ -168,11 +171,13 @@ the `close` callback, the session is gone. ### Where capnweb attaches in our code -On the DO side: `newWebSocketRpcSession(ws)` in -`LegacyContainerBackend.connect()` in `packages/computer/src/backends/container-legacy/cloudflare-container.ts`. -This installs `addEventListener("message", ...)` on the accepted -WebSocket, which means **the DO must be alive in memory to receive -frames**. There is no hibernation-aware variant today. +On the durable object side, `ContainerBackend.connect()` calls +`newWebSocketRpcSession(ws)` in +`packages/computer/src/backends/container/container-backend.ts`. +`LegacyContainerBackend` uses the same session setup. This installs +`addEventListener("message", ...)` on the accepted WebSocket, which means +**the durable object must be alive in memory to receive frames**. There is +no hibernation-aware variant today. On the container side, `acceptWebSocketSession(ws, rpc)` is attached by the inbound upgrade and outbound `/connect` paths in `packages/computerd/src/cli/computerd.ts`. Both attach to a `ws` package WebSocket and require the `computerd` process to be live. @@ -293,11 +298,10 @@ prove no unbounded growth under sustained workloads. > [!NOTE] > This section describes a target architecture, not shipped code. -> Today's `LegacyContainerBackend` uses `server.accept()`, which -> is **not** the hibernation API. The DO stays in memory for the -> lifetime of the WebSocket. Enabling hibernation requires changes -> across capnweb and the backend; the work is sketched here so the -> direction is clear. +> Today's container backends use `server.accept()`, which is **not** the +> hibernation API. The durable object stays in memory for the lifetime of +> the WebSocket. Enabling hibernation requires changes across capnweb and +> both backends; the work is sketched here so the direction is clear. ### What hibernation gives us diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index f55cb21d..1bba49b5 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -22,10 +22,12 @@ import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell"; ## When to reach for it -The container backend (`@cloudflare/computer/backends/container-legacy`) -gives you a real Linux environment with arbitrary binaries on -`$PATH`, optional network access, and a full POSIX filesystem. It costs a -container per session and a real roundtrip on every filesystem op. +The primary container backend (`@cloudflare/computer/backends/container`) +gives you a real Linux environment with arbitrary binaries on `$PATH`, +optional network access, and a full POSIX filesystem. It costs a container +per session and a real roundtrip on every filesystem operation. Use +`@cloudflare/computer/backends/container-legacy` instead when the platform +schedules and sizes the container. The worker backend trades the real environment for a Workers isolate that boots instantly, scales out cheaply, and has no diff --git a/docs/README.md b/docs/README.md index b928bd27..06ca0d7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,7 +43,8 @@ The package ships several entrypoints: | Entrypoint | Purpose | | --- | --- | | `@cloudflare/computer` | The Workspace wrapper, first-class `workspace.runtime`, stub types, the R2 mount, and proxy classes. | -| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. | +| `@cloudflare/computer/backends/container` | `ContainerBackend` and `withWorkspaceContainer`, for a container the durable object schedules (`scheduling_policy: "durable_object"`). Same sync plumbing; the launch names the image and the instance size. | +| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`, for a container the platform schedules and sizes from the containers block. | | `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash command runtime. | | `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. | | `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. | @@ -58,7 +59,7 @@ Wire types shared with the in-container service live in the sibling package `@cl ### Sandbox container image The container needs the `computerd` daemon alongside a FUSE runtime. The -simplest pattern, used by [`examples/container-legacy/Dockerfile`](../examples/container-legacy/Dockerfile), +simplest pattern, used by [`examples/container/Dockerfile`](../examples/container/Dockerfile), copies the prebuilt binary out of the public GHCR image and into a thin Debian base: @@ -87,33 +88,39 @@ To build the binary from source instead, run `npm run build:bin `artifacts/computerd/computerd-linux-x64`, then `COPY` that into the image. -`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what `examples/container-legacy/` uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence. +`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what [`examples/container`](../examples/container) uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence. ## Example ```ts import { Workspace } from "@cloudflare/computer"; import { - LegacyContainerBackend, - withLegacyWorkspaceContainer, -} from "@cloudflare/computer/backends/container-legacy"; + ContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; import { DurableObject } from "cloudflare:workers"; -export class Agent extends withLegacyWorkspaceContainer(class extends DurableObject {}) { +export class Agent extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "Agent", id: this.ctx.id.toString() }, + name: "app", + instance: "standard-2", + }); + readonly workspace = new Workspace({ - storage: this.ctx.storage, // DO storage → VFS lives here - backends: [ - new LegacyContainerBackend({ - container: () => this, - workspace: { binding: "Agent", id: this.ctx.id.toString() }, - }), - ], + storage: this.ctx.storage, // Durable Object storage → VFS lives here + backends: [this.backend], }); async initialize() { await this.workspace.ready(); await this.workspace.fs.mkdir("/workspace", { recursive: true }); } + + override fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } } ``` diff --git a/examples/container/.gitignore b/examples/container/.gitignore new file mode 100644 index 00000000..55845e82 --- /dev/null +++ b/examples/container/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +build/ +node_modules/ diff --git a/examples/container/Dockerfile b/examples/container/Dockerfile new file mode 100644 index 00000000..51fde903 --- /dev/null +++ b/examples/container/Dockerfile @@ -0,0 +1,43 @@ +# Container image for the container example. +# +# Pulls the computerd binary out of the public GHCR image. That image is +# a single layer over `scratch` whose only contents are the SEA +# binary at /usr/local/bin/computerd; we COPY it into a slim debian +# runtime below. The :VERSION tag is rewritten by the changesets +# Version Packages PR through .github/changeset-version.mjs. +# +# computerd mounts a FUSE filesystem at MOUNT_POINT so exec'd commands +# see the same VFS the RPC surface reads and writes. With +# FUSE_MOUNT=auto (below) the same image works in both directions: +# Cloudflare Containers expose /dev/fuse to the workload, so the +# real FUSE backend mounts; `wrangler dev` doesn't, so computerd falls +# back to the userspace shim transparently. + + +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.3.1 AS computerd + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl gnupg git \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd + +# computerd's defaults: HTTP+WS on :8080, FUSE mount on MOUNT_POINT. +# FUSE_MOUNT=auto picks real FUSE on Cloudflare Containers (where +# /dev/fuse is exposed) and the userspace shim under wrangler dev. +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/computerd"] diff --git a/examples/container/README.md b/examples/container/README.md new file mode 100644 index 00000000..ed4b3952 --- /dev/null +++ b/examples/container/README.md @@ -0,0 +1,103 @@ +# container example + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. +> +> Suitable for experiments, exploration and prototypes. It is NOT suitable +> for production use at this time. + +A Cloudflare Worker + Durable Object that boots a Container running the +`computerd` daemon and exposes a minimal `write` / `read` / `exec` HTTP +surface. + +## Container configuration + +Declare the available images in `wrangler.jsonc`. Wrangler prepares each +image and exposes it through `ctx.container.images`: + +```jsonc +"containers": [ + { + "class_name": "ContainerExample", + "scheduling_policy": "durable_object", + "images": { "app": { "dockerfile": "./Dockerfile" } } + } +] +``` + +Select the image when you create the backend: + +```ts +new ContainerBackend({ + container: () => this, + workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, + name: "app", // the images key above; "app" is the default +}); +``` + +Set the instance size on the backend: + +```ts +new ContainerBackend({ + // ... + instance: "standard-2", +}); +``` + +The `containers` entry accepts these fields: + +| Field | Purpose | +| --- | --- | +| `name` | Names the container application. | +| `class_name` | Names the Durable Object class that owns the container. | +| `scheduling_policy` | Set to `"durable_object"` so each Durable Object manages its container. | +| `images` | Declares the named images available through `ctx.container.images`. | +| `observability` | Configures logging for the container application. | +| `unsafe` | Holds restricted experimental settings. | + +## Architecture + +``` +client ─► Worker /c//{file,exec} + │ (DO RPC calls) + ▼ + DO (ContainerExample) ──► Container ──► computerd (:8080) + ▲ │ + │ ws://computer.internal/api │ + └────────── capnweb session ◄──────┘ +``` + +1. The DO constructs a `ContainerBackend` and hands it to a `Workspace`. + The backend owns the computerd lifecycle: it starts the container with + the image and size above, wires egress, probes `/health`, and asks + computerd to dial back. +2. computerd's outbound `/api` upgrade is intercepted by the egress and + lands on the DO's `fetch`, which forwards it to `backend.handleFetch`. +3. The resulting capnweb session carries filesystem sync and `exec`. + +If a container fails its startup health check, the replacement uses the +same image and instance size. + +## Running it + +```bash +npm run dev --workspace @example/computer-container +``` + +```bash +# write a file into the workspace +curl -X PUT --data 'hello' \ + http://localhost:8787/c/demo/file/workspace/hello.txt + +# read it back +curl http://localhost:8787/c/demo/file/workspace/hello.txt + +# run a command against it +curl -X POST -H 'content-type: application/json' \ + -d '{"command":"cat /workspace/hello.txt"}' \ + http://localhost:8787/c/demo/exec +``` + +Local development needs Docker. `wrangler dev` builds the image and runs it +against the local daemon. diff --git a/examples/container/package.json b/examples/container/package.json new file mode 100644 index 00000000..8ce18ffa --- /dev/null +++ b/examples/container/package.json @@ -0,0 +1,20 @@ +{ + "name": "@example/computer-container", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object that uses @cloudflare/computer inside a container the Durable Object schedules itself.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "build:types": "wrangler types" + }, + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } +} diff --git a/examples/container/script/run b/examples/container/script/run new file mode 100755 index 00000000..2e8242e5 --- /dev/null +++ b/examples/container/script/run @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Four-step smoke test against a running `wrangler dev` of this +# example. Demonstrates the round-trip between the file HTTP +# surface (which writes through the DO into the workspace VFS) and +# the exec HTTP surface (which runs commands inside the container, +# where computerd's FUSE / shim mount exposes the same tree at +# /workspace). +# +# Run `npm run dev` in another terminal first, then: +# ./script/run # against http://127.0.0.1:8787 +# ./script/run https://my-deploy.example.workers.dev +# NAME=foo ./script/run # use DO instance 'foo' instead of 'demo' + +set -euo pipefail + +BASE_URL="${1:-http://127.0.0.1:8787}" +BASE_URL="${BASE_URL%/}" +NAME="${NAME:-demo}" +PATH_VIA_API="api-wrote.txt" +PATH_VIA_EXEC="exec-wrote.txt" + +step() { printf '\n=== %s ===\n' "$*"; } +fail() { printf '\nFAIL: %s\n' "$*" >&2; exit 1; } + +step "1. write via API: PUT /c/${NAME}/file/workspace/${PATH_VIA_API}" +printf 'hello-from-api' | curl -fsS -X PUT --data-binary @- \ + "${BASE_URL}/c/${NAME}/file/workspace/${PATH_VIA_API}" \ + -w 'HTTP %{http_code}\n' + +step "2. read via exec: cat /workspace/${PATH_VIA_API} inside the container" +read_via_exec=$(curl -fsS -X POST "${BASE_URL}/c/${NAME}/exec" \ + -H 'content-type: application/json' \ + -d "{\"command\":\"cat /workspace/${PATH_VIA_API}\",\"encoding\":\"utf8\"}") +printf '%s\n' "$read_via_exec" +echo "$read_via_exec" | grep -q 'hello-from-api' \ + || fail "exec did not see the file the API just wrote" + +step "3. write via exec: echo … > /workspace/${PATH_VIA_EXEC}" +curl -fsS -X POST "${BASE_URL}/c/${NAME}/exec" \ + -H 'content-type: application/json' \ + -d "{\"command\":\"echo hello-from-exec > /workspace/${PATH_VIA_EXEC} && cat /workspace/${PATH_VIA_EXEC}\",\"encoding\":\"utf8\"}" +printf '\n' +# No settle wait needed: computerd's `beforeFetch` hook runs the shim's +# disk→VFS reconcile synchronously inside `fetchChanges`, so the +# GET below is guaranteed to see whatever the exec just wrote. + +step "4. read via API: GET /c/${NAME}/file/workspace/${PATH_VIA_EXEC}" +read_via_api=$(curl -fsS "${BASE_URL}/c/${NAME}/file/workspace/${PATH_VIA_EXEC}") +printf '%s\n' "$read_via_api" +[[ "$read_via_api" == "hello-from-exec" ]] \ + || fail "API did not see the file exec just wrote (got: $read_via_api)" + +printf '\nOK — both directions round-tripped through the VFS.\n' diff --git a/examples/container/src/index.ts b/examples/container/src/index.ts new file mode 100644 index 00000000..02a025dc --- /dev/null +++ b/examples/container/src/index.ts @@ -0,0 +1,265 @@ +// Example Worker + container-enabled Durable Object that runs a +// Workspace inside a container the Durable Object schedules itself. +// +// The DO is a thin shell over ContainerBackend: it picks the container +// (this.ctx.container) and the egress fetcher +// (ctx.exports.WorkspaceProxy bound to this DO instance), forwards +// container-bound /api upgrades back through the backend, and +// otherwise just calls into a single Workspace instance. +// +// What distinguishes this example from examples/container-legacy is +// the scheduling policy. The wrangler containers block here sets +// `scheduling_policy: "durable_object"`, which hands the container +// lifecycle to this object and takes image selection and sizing with +// it: the block rejects `instance_type`, and it prepares an `images` +// map for the object to choose from. So the backend below names both, +// and every start() it issues carries them. +// +// Wire shape: +// +// client ─► Worker /c//{file,exec} +// │ (DO RPC) +// ▼ +// ContainerExample DO ──► Container ──► computerd (:8080) +// ▲ │ +// │ ws://computer.internal/api │ +// └─── capnweb session ◀─────────────┘ + +import { DurableObject, tracing } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + getWorkspace, + type WorkspaceOptions, + WorkspaceProxy, + withWorkspace, +} from "@cloudflare/computer"; +import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container"; +import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare"; + +// Re-export so the runtime can build a loopback binding for the +// container egress (ctx.exports.WorkspaceProxy below). The class +// itself lives in @cloudflare/computer; the re-export is what +// puts it in the worker's top-level module graph. +export { WorkspaceProxy }; + +// Env is generated by `wrangler types` into worker-configuration.d.ts. + +// --------------------------------------------------------------- +// Durable Object: owns one Workspace backed by one container. +// --------------------------------------------------------------- +// The container half of the DO. The backend lives here rather than on +// ContainerExample because withWorkspace's options callback needs it +// while constructing the Workspace: base-class fields are initialized +// by the time the callback runs, subclass fields are not. +class ContainerBase extends withWorkspaceContainer(class extends DurableObject {}) { + readonly backend = new ContainerBackend({ + container: () => this, + workspace: { binding: "ContainerExample", id: this.ctx.id.toString() }, + egress: { mode: "direct" }, + // Key into `ctx.container.images`, which wrangler populates from + // the `images` map in wrangler.jsonc. "app" is the default, so + // this line is redundant here and is kept to show where the name + // comes from: it is the images key, not the container application + // name that sits beside it in the same block. + name: "app", + // Requested at launch because the containers block cannot declare + // it under this policy. Omitting it lets the platform choose, + // which is rarely right for a workload that compiles or installs + // packages -- the named tiers top out at 4 vCPU / 12 GiB, and a + // custom size is the only way past that. + instance: "standard-2", + }); +} + +// Named, with an explicit return type: an inline callback would make +// the class's own base expression part of its inferred type. +// DurableObject keeps ctx and env protected, so read them through a +// cast the way the mixin's own docs do. +function workspaceOptions(self: InstanceType): WorkspaceOptions { + const { ctx } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + // ctx.storage.sql.exec returns a narrower row type than + // DurableObjectStorageLike declares; the runtime shape + // matches. Cast through unknown to bypass invariance. + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [self.backend], + // Route every workspace operation through the Cloudflare + // runtime's user-tracing surface. The runtime owns the span + // lifecycle; the observer is a thin wrapper that forwards seed + // attributes and a `setAttribute` callback. With + // `observability.traces.enabled = true` in wrangler.jsonc, the + // spans show up in the Workers Observability dashboard + // alongside the runtime's automatic fetch and binding spans. + observer: createCloudflareObserver({ tracing }), + }; +} + +// withWorkspace owns the Workspace and installs the prototype +// accessor `getWorkspace` dispatches to. Methods on the client it +// hands back round-trip into this DO; the actual SyncRPC + ShellRPC +// traffic stays on the computerd ↔ DO capnweb wire. +export class ContainerExample extends withWorkspace(ContainerBase, workspaceOptions) { + // ---- WebSocket: computerd's outbound /api upgrade -------------------- + + override async fetch(request: Request): Promise { + return this.backend.handleFetch(request); + } +} + +// --------------------------------------------------------------- +// Worker HTTP surface (unchanged) +// --------------------------------------------------------------- + +interface ExecRequest { + command?: string; + argv?: string[]; + cwd?: string; + encoding?: "utf8"; +} + +// computerd mounts the VFS at /workspace inside the container. The file +// handler enforces that every path it touches sits under that +// root; callers pass the absolute VFS path verbatim in the URL +// (e.g. PUT /c//file/workspace/hello.txt writes +// /workspace/hello.txt, exactly as computerd sees it). Anything outside +// /workspace is rejected up front — the example only wants to +// expose the mounted tree, not the container's full filesystem. +const MOUNT_ROOT = "/workspace"; + +function resolveMountPath(rest: string): string | null { + // URL was matched as /c//file/; treat as an + // absolute path with the leading `/` stripped. + const candidate = `/${rest}`; + if (candidate !== MOUNT_ROOT && !candidate.startsWith(`${MOUNT_ROOT}/`)) { + return null; + } + // Reject .. segments so a caller can't escape the mount through + // path traversal once we've validated the prefix. + if (candidate.split("/").includes("..")) { + return null; + } + return candidate; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + const fileMatch = url.pathname.match(/^\/c\/([^/]+)\/file\/(.+)$/); + if (fileMatch) { + const resolved = resolveMountPath(fileMatch[2]); + if (resolved === null) { + return errorJSON(new Error(`path must sit under ${MOUNT_ROOT}; got /${fileMatch[2]}`), 400); + } + return handleFile(request, env, fileMatch[1], resolved); + } + + const execMatch = url.pathname.match(/^\/c\/([^/]+)\/exec\/?$/); + if (execMatch) return handleExec(request, env, execMatch[1]); + + if (url.pathname === "/" || url.pathname === "") { + return new Response( + [ + "container example", + "", + ` PUT /c//file/workspace/ write file at ${MOUNT_ROOT}/`, + ` GET /c//file/workspace/ read file at ${MOUNT_ROOT}/`, + " POST /c//exec run a command (JSON result)", + + "", + ].join("\n"), + { headers: { "content-type": "text/plain" } }, + ); + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +async function handleFile( + request: Request, + env: Env, + name: string, + path: string, +): Promise { + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + // `wrangler types` doesn't surface the accessor the withWorkspace + // mixin installs, so cast at the boundary. + const ws = await getWorkspace(stub as unknown as Parameters[0]); + + if (request.method === "PUT") { + const body = new Uint8Array(await request.arrayBuffer()); + try { + await ws.fs.mkdir(MOUNT_ROOT, { recursive: true }); + await ws.fs.writeFile(path, body); + return new Response(null, { status: 204 }); + } catch (error) { + return errorJSON(error, 500); + } + } + + if (request.method === "GET") { + try { + const stream = await ws.fs.readFile(path, {}); + return new Response(stream, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === "ENOENT") return errorJSON(error, 404); + return errorJSON(error, 500); + } + } + + return new Response("method not allowed", { status: 405, headers: { allow: "GET, PUT" } }); +} + +async function handleExec(request: Request, env: Env, name: string): Promise { + if (request.method !== "POST") { + return new Response("method not allowed", { status: 405, headers: { allow: "POST" } }); + } + let body: ExecRequest; + try { + body = (await request.json()) as ExecRequest; + } catch { + return errorJSON(new Error("invalid JSON body"), 400); + } + + let command: string; + if (typeof body.command === "string" && body.command.length > 0) { + command = body.command; + } else if (Array.isArray(body.argv) && body.argv.length > 0) { + command = body.argv.map(shellQuote).join(" "); + } else { + return errorJSON(new Error("must provide command or argv"), 400); + } + + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + const ws = await getWorkspace(stub as unknown as Parameters[0]); + try { + const handle = await ws.runtime.exec(command, { cwd: body.cwd, encoding: "utf8" }); + const result = await handle.result(); + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return errorJSON(error, 500); + } +} + +function errorJSON(error: unknown, status: number): Response { + const message = error instanceof Error ? error.message : String(error); + const code = (error as { code?: string }).code; + return new Response(JSON.stringify({ error: message, code }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function shellQuote(arg: string): string { + if (/^[A-Za-z0-9_\-+=:,./@%]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/examples/container/tsconfig.json b/examples/container/tsconfig.json new file mode 100644 index 00000000..5a6253fb --- /dev/null +++ b/examples/container/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/container/wrangler.jsonc b/examples/container/wrangler.jsonc new file mode 100644 index 00000000..5129654d --- /dev/null +++ b/examples/container/wrangler.jsonc @@ -0,0 +1,62 @@ +{ + // Example: Worker + Durable Object hosting a Cloudflare Container that + // the Durable Object schedules itself, rather than one the platform + // places and sizes. + // + // The containers block below is the whole difference from + // examples/container-legacy. Under `scheduling_policy: "durable_object"` + // the object owns the container lifecycle, so: + // + // - `images` replaces `image`. Wrangler prepares each entry and + // exposes it as `ctx.container.images.`; the backend's `name` + // option names the key to boot. + // - `instance_type` and `max_instances` are rejected. Sizing belongs + // to the object now, so it is requested at launch through the + // backend's `instance` option (see src/index.ts). + // + // Wrangler accepts only `name`, `class_name`, `scheduling_policy`, + // `images`, `observability`, and `unsafe.configuration.experimental_flags` + // under this policy. Anything else fails the deploy. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-container-instance-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + + "containers": [ + { + "class_name": "ContainerExample", + "scheduling_policy": "durable_object", + "images": { + // Built from the local Dockerfile, which COPYs the computerd + // binary staged into ./build/ by the predev / predeploy hook. + "app": { "dockerfile": "./Dockerfile" } + }, + "observability": { + "enabled": true + } + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "ContainerExample", + "class_name": "ContainerExample" + } + ] + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ContainerExample"] + } + ], + + "observability": { + "traces": { + "enabled": true + } + } +} diff --git a/package-lock.json b/package-lock.json index 2f468c77..21d76d96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2561,18 +2561,860 @@ } } }, + "examples/container": { + "name": "@example/computer-container", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, "examples/container-legacy": { "name": "@example/computer-container-legacy", "version": "0.0.0", "dependencies": { - "@cloudflare/computer": "*" + "@cloudflare/computer": "*" + }, + "devDependencies": { + "typescript": "^6.0.3", + "wrangler": "^4.137.0" + } + }, + "examples/container-legacy/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260921.1.tgz", + "integrity": "sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260921.1.tgz", + "integrity": "sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260921.1.tgz", + "integrity": "sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260921.1.tgz", + "integrity": "sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260921.1.tgz", + "integrity": "sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "examples/container-legacy/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "examples/container-legacy/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "examples/container-legacy/node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "examples/container-legacy/node_modules/miniflare": { + "version": "5.20260921.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260921.0-alpha.tgz", + "integrity": "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260921.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, - "devDependencies": { - "typescript": "^6.0.3", - "wrangler": "^4.137.0" + "engines": { + "node": ">=22.0.0" } }, - "examples/container-legacy/node_modules/@cloudflare/unenv-preset": { + "examples/container-legacy/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "examples/container-legacy/node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/workerd": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260921.1.tgz", + "integrity": "sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260921.1", + "@cloudflare/workerd-darwin-arm64": "1.20260921.1", + "@cloudflare/workerd-linux-64": "1.20260921.1", + "@cloudflare/workerd-linux-arm64": "1.20260921.1", + "@cloudflare/workerd-windows-64": "1.20260921.1" + } + }, + "examples/container-legacy/node_modules/wrangler": { + "version": "4.137.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", + "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "examples/container-legacy/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "examples/container/node_modules/@cloudflare/unenv-preset": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", @@ -2588,7 +3430,7 @@ } } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-64": { + "examples/container/node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260921.1.tgz", "integrity": "sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==", @@ -2605,7 +3447,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-darwin-arm64": { + "examples/container/node_modules/@cloudflare/workerd-darwin-arm64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260921.1.tgz", "integrity": "sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==", @@ -2622,7 +3464,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-linux-64": { + "examples/container/node_modules/@cloudflare/workerd-linux-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260921.1.tgz", "integrity": "sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==", @@ -2639,7 +3481,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-linux-arm64": { + "examples/container/node_modules/@cloudflare/workerd-linux-arm64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260921.1.tgz", "integrity": "sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==", @@ -2656,7 +3498,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@cloudflare/workerd-windows-64": { + "examples/container/node_modules/@cloudflare/workerd-windows-64": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260921.1.tgz", "integrity": "sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==", @@ -2673,7 +3515,7 @@ "node": ">=16" } }, - "examples/container-legacy/node_modules/@emnapi/runtime": { + "examples/container/node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", @@ -2684,7 +3526,7 @@ "tslib": "^2.4.0" } }, - "examples/container-legacy/node_modules/@img/sharp-darwin-arm64": { + "examples/container/node_modules/@img/sharp-darwin-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", @@ -2707,7 +3549,7 @@ "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-darwin-x64": { + "examples/container/node_modules/@img/sharp-darwin-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", @@ -2730,7 +3572,7 @@ "@img/sharp-libvips-darwin-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-freebsd-wasm32": { + "examples/container/node_modules/@img/sharp-freebsd-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", @@ -2750,7 +3592,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-arm64": { + "examples/container/node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", @@ -2767,7 +3609,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-darwin-x64": { + "examples/container/node_modules/@img/sharp-libvips-darwin-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", @@ -2784,7 +3626,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm": { + "examples/container/node_modules/@img/sharp-libvips-linux-arm": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", @@ -2804,7 +3646,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-arm64": { + "examples/container/node_modules/@img/sharp-libvips-linux-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", @@ -2824,7 +3666,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-ppc64": { + "examples/container/node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", @@ -2844,7 +3686,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-riscv64": { + "examples/container/node_modules/@img/sharp-libvips-linux-riscv64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", @@ -2864,7 +3706,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-s390x": { + "examples/container/node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", @@ -2884,7 +3726,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linux-x64": { + "examples/container/node_modules/@img/sharp-libvips-linux-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", @@ -2904,7 +3746,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "examples/container/node_modules/@img/sharp-libvips-linuxmusl-arm64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", @@ -2924,7 +3766,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "examples/container/node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", @@ -2944,7 +3786,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-arm": { + "examples/container/node_modules/@img/sharp-linux-arm": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", @@ -2970,7 +3812,7 @@ "@img/sharp-libvips-linux-arm": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-arm64": { + "examples/container/node_modules/@img/sharp-linux-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", @@ -2996,7 +3838,7 @@ "@img/sharp-libvips-linux-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-ppc64": { + "examples/container/node_modules/@img/sharp-linux-ppc64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", @@ -3022,7 +3864,7 @@ "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-riscv64": { + "examples/container/node_modules/@img/sharp-linux-riscv64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", @@ -3048,7 +3890,7 @@ "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-s390x": { + "examples/container/node_modules/@img/sharp-linux-s390x": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", @@ -3074,7 +3916,7 @@ "@img/sharp-libvips-linux-s390x": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linux-x64": { + "examples/container/node_modules/@img/sharp-linux-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", @@ -3100,7 +3942,7 @@ "@img/sharp-libvips-linux-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linuxmusl-arm64": { + "examples/container/node_modules/@img/sharp-linuxmusl-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", @@ -3126,7 +3968,7 @@ "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-linuxmusl-x64": { + "examples/container/node_modules/@img/sharp-linuxmusl-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", @@ -3152,7 +3994,7 @@ "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, - "examples/container-legacy/node_modules/@img/sharp-wasm32": { + "examples/container/node_modules/@img/sharp-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", @@ -3169,7 +4011,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-webcontainers-wasm32": { + "examples/container/node_modules/@img/sharp-webcontainers-wasm32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", @@ -3189,7 +4031,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-arm64": { + "examples/container/node_modules/@img/sharp-win32-arm64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", @@ -3209,7 +4051,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-ia32": { + "examples/container/node_modules/@img/sharp-win32-ia32": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", @@ -3229,7 +4071,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/@img/sharp-win32-x64": { + "examples/container/node_modules/@img/sharp-win32-x64": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", @@ -3249,7 +4091,7 @@ "url": "https://opencollective.com/libvips" } }, - "examples/container-legacy/node_modules/miniflare": { + "examples/container/node_modules/miniflare": { "version": "5.20260921.0-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260921.0-alpha.tgz", "integrity": "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ==", @@ -3267,14 +4109,14 @@ "node": ">=22.0.0" } }, - "examples/container-legacy/node_modules/path-to-regexp": { + "examples/container/node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, - "examples/container-legacy/node_modules/sharp": { + "examples/container/node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", @@ -3324,7 +4166,7 @@ } } }, - "examples/container-legacy/node_modules/workerd": { + "examples/container/node_modules/workerd": { "version": "1.20260921.1", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260921.1.tgz", "integrity": "sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==", @@ -3345,7 +4187,7 @@ "@cloudflare/workerd-windows-64": "1.20260921.1" } }, - "examples/container-legacy/node_modules/wrangler": { + "examples/container/node_modules/wrangler": { "version": "4.137.0", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.137.0.tgz", "integrity": "sha512-vq2JmxkvwOjnsMUejQwd89/EK6u1d20OMlGUVVmb55gVi2zSBKp2rvmxUiPqrSEntK8NwzpTF3DQ/S2I9/TLsg==", @@ -3381,7 +4223,7 @@ } } }, - "examples/container-legacy/node_modules/ws": { + "examples/container/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", @@ -12502,6 +13344,10 @@ "resolved": "examples/celld", "link": true }, + "node_modules/@example/computer-container": { + "resolved": "examples/container", + "link": true + }, "node_modules/@example/computer-container-legacy": { "resolved": "examples/container-legacy", "link": true diff --git a/packages/computer/README.md b/packages/computer/README.md index 473fd2eb..dc9b81b4 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -238,7 +238,8 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and | Backend | Import | Runs | Needs | | --- | --- | --- | --- | -| **Container** | `@cloudflare/computer/backends/container-legacy` | Shell commands in full Linux userland (real binaries, `npm`, `node`, network) | A Cloudflare Container running `computerd` | +| **Container** | `@cloudflare/computer/backends/container` | Shell commands in full Linux userland (real binaries, `npm`, `node`, network) | A Cloudflare Container running `computerd`, scheduled by the durable object (`scheduling_policy: "durable_object"`) | +| **Container (legacy)** | `@cloudflare/computer/backends/container-legacy` | The same | A Cloudflare Container the platform schedules and sizes from the `containers` block | | **Worker shell** | `@cloudflare/computer/backends/worker-shell` | Shell commands via [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker | A Worker Loader binding; `experimental` flag | | **Worker JavaScript** | `@cloudflare/computer/backends/worker-javascript` | ECMAScript modules in a fresh Dynamic Worker | A Worker Loader binding; `experimental` flag | diff --git a/packages/computer/package.json b/packages/computer/package.json index 653d585d..516e38d4 100644 --- a/packages/computer/package.json +++ b/packages/computer/package.json @@ -39,6 +39,10 @@ "types": "./dist/backends/container-legacy/index.d.ts", "import": "./dist/backends/container-legacy/index.js" }, + "./backends/container": { + "types": "./dist/backends/container/index.d.ts", + "import": "./dist/backends/container/index.js" + }, "./backends/worker-javascript": { "types": "./dist/backends/worker-javascript/index.d.ts", "import": "./dist/backends/worker-javascript/index.js" diff --git a/packages/computer/rolldown.config.ts b/packages/computer/rolldown.config.ts index 9e2680ad..0a78e602 100644 --- a/packages/computer/rolldown.config.ts +++ b/packages/computer/rolldown.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "assets/index": "src/assets/index.ts", "tools/index": "src/tools/index.ts", "backends/container-legacy/index": "src/backends/container-legacy/index.ts", + "backends/container/index": "src/backends/container/index.ts", "backends/worker-javascript/index": "src/backends/worker-javascript/index.ts", "backends/worker-shell/index": "src/backends/worker-shell/index.ts", // The shell-module groups build-bundle.mjs emits. Each is its diff --git a/packages/computer/src/backends/container/container-backend-launch.test.ts b/packages/computer/src/backends/container/container-backend-launch.test.ts new file mode 100644 index 00000000..8d198b87 --- /dev/null +++ b/packages/computer/src/backends/container/container-backend-launch.test.ts @@ -0,0 +1,108 @@ +// The image and the size have to survive a restart. +// +// connect() and the readiness loop reach the platform through +// different host methods, so a spec assembled separately in each place +// drifts without anything failing: the restart drops what the initial +// start was given, and the replacement container comes up smaller, or +// on a different image, than the one it replaced. Nothing observes +// that until a build runs out of memory. +// +// These drive the backend far enough to capture both calls and compare +// them. The connect() attempt is expected to fail — there is no +// container to dial — which is fine, because the assertion is about +// what the host was asked for, not about reaching a session. +import { describe, expect, test } from "vitest"; + +import { ContainerBackend } from "./container-backend.js"; +import type { ContainerRuntimeInfo, IWorkspaceContainerAPI } from "./container-host.js"; +import type { ContainerLaunchSpec } from "./container-launch-record.js"; + +function recordingHost() { + const specs: { method: "start" | "restart"; spec: ContainerLaunchSpec }[] = []; + const info: ContainerRuntimeInfo = { + runtimeId: "runtime-1", + clientSecret: "00112233445566778899aabbccddeeff", + outcome: "launched", + }; + const host: IWorkspaceContainerAPI = { + async start(spec) { + specs.push({ method: "start", spec }); + return info; + }, + async restart(spec) { + specs.push({ method: "restart", spec }); + return info; + }, + async interceptOutboundHttp() {}, + async interceptAllOutboundHttp() {}, + // Never healthy, so the readiness loop exhausts its attempts and + // takes the restart path. That is the path under test. + async fetchPort() { + return new Response(null, { status: 503 }); + }, + port() { + throw new Error("not used"); + }, + async setInactivityTimeout() {}, + async status() { + return { running: true, exit: null }; + }, + async exitInfo() { + return null; + }, + }; + return { host, specs }; +} + +function backendWith(options: { name?: string; instance?: ContainerInstanceSize } = {}) { + const { host, specs } = recordingHost(); + const backend = new ContainerBackend({ + container: () => ({ getWorkspaceContainer: () => host }), + workspace: { binding: "SESSIONS", id: "session-1" }, + // One restart, then give up. Enough to capture both launch paths. + restartAttempts: 1, + connectTimeoutMs: 1_000, + healthProbeTimeoutMs: 50, + healthRetryInitialDelayMs: 10, + healthRetryMaxDelayMs: 20, + heartbeatIntervalMs: 0, + ...options, + }); + return { backend, specs }; +} + +describe("both launch paths request the same container", () => { + test("the restart carries the image the initial start carried", async () => { + const { backend, specs } = backendWith({ name: "toolchain" }); + + await backend.connect().catch(() => undefined); + + const start = specs.find((call) => call.method === "start"); + const restart = specs.find((call) => call.method === "restart"); + expect(start?.spec.name).toBe("toolchain"); + expect(restart?.spec.name).toBe("toolchain"); + }); + + test("the restart carries the instance size the initial start carried", async () => { + const instance = { vcpu: 16, memoryMib: 32768, diskMb: 64000 }; + const { backend, specs } = backendWith({ instance }); + + await backend.connect().catch(() => undefined); + + const start = specs.find((call) => call.method === "start"); + const restart = specs.find((call) => call.method === "restart"); + expect(start?.spec.instance).toEqual(instance); + expect(restart?.spec.instance).toEqual(instance); + }); + + test("every launch names an image even when the caller does not", async () => { + const { backend, specs } = backendWith(); + + await backend.connect().catch(() => undefined); + + expect(specs.length).toBeGreaterThan(1); + for (const call of specs) { + expect(call.spec.name).toBe("app"); + } + }); +}); diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts new file mode 100644 index 00000000..2994f79b --- /dev/null +++ b/packages/computer/src/backends/container/container-backend.ts @@ -0,0 +1,749 @@ +// ContainerBackend — backs Workspace with a computerd instance +// running inside a Cloudflare Container. +// +// The backend drives container lifecycle through an IWorkspaceContainerAPI +// abstraction. Same-DO and cross-DO callers look identical from +// here; whether ctx.container is reached directly or through an +// RPC stub is a concern of the IWorkspaceContainerAPI implementation. +// +// Same-DO shape (one DO owns both the container and the Workspace): +// +// class ComputerdContainer extends DurableObject { +// #backend = new ContainerBackend({ +// container: () => this.ws, +// workspace: { binding: "ComputerdContainer", id: this.ctx.id.toString() }, +// }); +// #workspace = new Workspace({ backends: [this.#backend] }); +// +// override fetch(req: Request): Promise { +// return this.#backend.handleFetch(req); +// } +// } +// +// Cross-DO shape (Agent DO holds the Workspace, a pool member DO +// owns the container): +// +// class AgentDO extends DurableObject { +// #backend = new ContainerBackend({ +// container: async () => { +// const memberId = await pickPoolMember(this.env, this.ctx.id); +// return this.env.ComputerdHost.get(this.env.ComputerdHost.idFromString(memberId)); +// }, +// workspace: { binding: "AgentDO", id: this.ctx.id.toString() }, +// }); +// } +// +// The factory runs once per connect(), so a redial after a session +// drop re-picks the pool member; mid-session container churn is the +// pool's problem, not the backend's. +// +// Failure model: connect() does the bootstrap once and throws on +// any failure. The Workspace's ready() retries by re-entering +// connect() on the next call. On a mid-session WebSocket drop the +// backend resolves `BackendHandle.closed`, which the Workspace +// listens for and uses to drop its cached handle so the next call +// rebuilds against a fresh session. + +import type { WorkspaceRPC } from "@cloudflare/computer-rpc"; +import { newWebSocketRpcSession, type RpcStub } from "capnweb"; + +import type { BackendHandle, WorkspaceBackend } from "../../backend.js"; +import { startHeartbeat } from "../../heartbeat.js"; +import { + WORKSPACE_EGRESS_TOKEN_HEADER, + WORKSPACE_EGRESS_URL_HEADER, + type WorkspaceEgressPolicy, +} from "../../runtime/egress.js"; +import { WorkspaceTransportError } from "../../transport-failure.js"; +import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js"; +import type { ContainerInstanceSize, ContainerLaunchSpec } from "./container-launch-record.js"; +import { probeComputerdHealth } from "./health-probe.js"; + +// What the backend's `container` factory returns: anything with +// a getWorkspaceContainer() method — the shape withWorkspaceContainer +// installs. Same-DO callers pass `this`; cross-DO callers pass a +// DO stub whose target was extended with withWorkspaceContainer +// (Workers RPC exposes the method as a pipelined callable). +export interface ContainerHostHolder { + getWorkspaceContainer(): IWorkspaceContainerAPI | Promise; +} + +export interface ContainerBackendOptions { + // Resolves the container host to drive on each connect(). Called + // anew per dial so a pool-backed factory can re-pick. Returning + // a Promise is supported for pickers that consult external state + // (KV, a coordinator DO, etc.). + // + // The returned value exposes getWorkspaceContainer() — the same + // shape withWorkspaceContainer installs. Pass `this` (same-DO) + // or a DO stub (cross-DO); the backend calls the method itself. + container: () => ContainerHostHolder | Promise; + + // Identifies the Workspace-owning DO. Fixed for the lifetime of + // the backend: the backend lives inside this DO and the /api + // upgrade always lands here. Plain {binding, id} data so it + // survives the Workers RPC hop to a cross-DO container host. + workspace: WorkspaceRef; + + // Hostname computerd will dial back. Defaults to "computer.internal". + // Override for tests or to avoid collisions with other backends + // sharing the same container host. + egressHost?: string; + + egress?: WorkspaceEgressPolicy; + + // TCP port computerd listens on inside the container. Default 8080, + // matching the Dockerfile shipped with examples/container. + containerPort?: number; + + // Environment variables passed to container.start(). Merged onto + // the defaults (PORT, MOUNT_POINT). Caller-supplied values win. + containerEnv?: Record; + + // Total time the backend waits for: container port to open, + // /connect POST to return, /api upgrade to arrive. Default 30s. + connectTimeoutMs?: number; + + // Period for the application-level heartbeat — a watermarks() + // RPC on a timer. Two jobs: detect a silently-dead peer faster + // than waiting for the next real RPC, and keep middlebox idle + // timers warm. Default 20_000ms. Set 0 to disable. + heartbeatIntervalMs?: number; + + // Number of forced restart attempts after startup readiness + // fails. The first attempt runs host.start() then probes computerd; + // each restart attempt runs host.restart() then probes computerd + // again. Defaults to 1 (one restart after the initial start). + // Set 0 to disable restart on failed readiness. + restartAttempts?: number; + + // Per-probe timeout for the startup health probe. Defaults to + // 2 seconds. The shared probeComputerdHealth helper aborts the request + // when it elapses; the next probe in the loop carries the + // remaining readiness budget. + healthProbeTimeoutMs?: number; + + // First retry delay after a failed startup probe. Defaults to + // 250ms. Subsequent failures double the prior delay, capped at + // healthRetryMaxDelayMs. + healthRetryInitialDelayMs?: number; + + // Maximum delay between failed startup probes. Defaults to 2s. + healthRetryMaxDelayMs?: number; + + // Selector this backend is registered under in Workspace. + // Defaults to "container-shell"; override when the + // workspace hosts more than one instance of the same backend + // kind (e.g. two containers pinned to different pool members). + id?: string; + + // Which prepared image to boot, as a key into + // `ctx.container.images` — the names declared in the `images` block + // of the wrangler containers config. Not the container application + // name, which is a different field in that same block. Defaults to + // "app", matching wrangler's convention. + name?: string; + + // Instance size requested at launch. Under this scheduling policy + // the wrangler containers block rejects `instance_type`, because + // sizing belongs to the object once it owns the lifecycle, so this + // is the only place to ask for one. Omitted lets the platform + // choose, which is rarely what a workload that builds or compiles + // wants. + instance?: ContainerInstanceSize; + + // Remaining startup options, forwarded verbatim to + // `ctx.container.start()`. `entrypoint`, `labels`, `hardTimeout`, + // and the snapshot restore fields all live here. The fields this + // backend owns are excluded: `env` is assembled from containerEnv, + // `enableInternet` follows the egress policy, and `name` and + // `instance` are named options above. + launch?: Omit; +} + +const DEFAULT_EGRESS_HOST = "computer.internal"; +// Image key assumed when a caller names none. Kept in step with the +// same default in container-host.ts, which resolves it. +const DEFAULT_IMAGE_NAME = "app"; +// Paths the egress proxy serves. The container assembles no paths of +// its own, so these travel in the /connect request and both ends stay +// in step from one place. +const EGRESS_HEALTH_PATH = "/health"; +const EGRESS_API_PATH = "/api"; +const DEFAULT_CONTAINER_PORT = 8080; +const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000; +const DEFAULT_RESTART_ATTEMPTS = 1; +const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 2_000; +const DEFAULT_HEALTH_RETRY_INITIAL_DELAY_MS = 250; +const DEFAULT_HEALTH_RETRY_MAX_DELAY_MS = 2_000; + +// Bearer check for the dial-back. The scheme token is case-insensitive +// and may be followed by more than one space, matching what the daemon +// accepts on its own surface. +// +// The comparison walks every byte rather than stopping at the first +// difference, so it does not leak how much of the secret was correct. +// crypto.subtle.timingSafeEqual would be the primitive to reach for, but +// the SubtleCrypto this package compiles against does not declare it. +// +// An absent expected secret refuses everything rather than allowing it. +// This only runs once connect() has recorded the secret it launched the +// container with, so an absent one means the upgrade arrived without that +// having happened. +function bearerMatches(header: string | null, expected: string | undefined): boolean { + if (expected === undefined || header === null) return false; + const separator = header.indexOf(" "); + if (separator === -1) return false; + if (header.slice(0, separator).toLowerCase() !== "bearer") return false; + const presented = header.slice(separator + 1).trim(); + if (presented.length !== expected.length) return false; + let differences = 0; + for (let i = 0; i < expected.length; i += 1) { + differences |= presented.charCodeAt(i) ^ expected.charCodeAt(i); + } + return differences === 0; +} + +export class ContainerBackend implements WorkspaceBackend { + readonly type = "cloudflare-container"; + readonly id: string; + + readonly #options: Required< + Omit< + ContainerBackendOptions, + "container" | "workspace" | "containerEnv" | "egress" | "id" | "name" | "instance" | "launch" + > + > & + Pick< + ContainerBackendOptions, + "container" | "workspace" | "containerEnv" | "name" | "instance" | "launch" + >; + readonly #egress: WorkspaceEgressPolicy; + readonly #egressToken: string | undefined; + // Set once start() reports it, before the upgrade slot is armed, so + // handleFetch can check the dial-back against it. + #clientSecret: string | undefined; + + // State for the in-flight /api upgrade. handleFetch() resolves + // #pendingUpgrade; connect() awaits it. + #pendingUpgrade: Promise | undefined; + #resolveUpgrade: ((ws: WebSocket) => void) | undefined; + #rejectUpgrade: ((err: unknown) => void) | undefined; + + // Cached after the first successful connect(). Cleared on close() + // or when the underlying WebSocket reports `close` / `error`. + #handle: BackendHandle | undefined; + + constructor(options: ContainerBackendOptions) { + this.id = options.id ?? "container-shell"; + this.#egress = options.egress ?? { mode: "none" }; + this.#egressToken = this.#egress.mode === "http-gateway" ? crypto.randomUUID() : undefined; + this.#options = { + container: options.container, + workspace: options.workspace, + containerEnv: options.containerEnv, + egressHost: options.egressHost ?? DEFAULT_EGRESS_HOST, + containerPort: options.containerPort ?? DEFAULT_CONTAINER_PORT, + connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, + heartbeatIntervalMs: options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS, + restartAttempts: options.restartAttempts ?? DEFAULT_RESTART_ATTEMPTS, + healthProbeTimeoutMs: options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS, + healthRetryInitialDelayMs: + options.healthRetryInitialDelayMs ?? DEFAULT_HEALTH_RETRY_INITIAL_DELAY_MS, + healthRetryMaxDelayMs: options.healthRetryMaxDelayMs ?? DEFAULT_HEALTH_RETRY_MAX_DELAY_MS, + name: options.name, + instance: options.instance, + launch: options.launch, + }; + } + + async connect(): Promise { + if (this.#handle) return this.#handle; + + const deadline = Date.now() + this.#options.connectTimeoutMs; + const holder = await this.#options.container(); + const host = await holder.getWorkspaceContainer(); + + // Pre-flight: surface any prior container exit so a readiness + // failure can attribute it back to the crash. host.start() + // clears this once the new generation is up, so a successful + // dial against a previously-dead container loses the + // attribution — which is the right semantics; the prior exit + // is only interesting if the new attempt fails too. + const priorExit = await host.exitInfo().catch(() => null); + + const env = { + PORT: String(this.#options.containerPort), + MOUNT_POINT: "/workspace", + ...this.#options.containerEnv, + }; + let runtimeId: string; + // The container requires this on its HTTP surface. It is durable, so + // the value is the same across incarnations and across a replaced + // container. + let clientSecret: string; + try { + ({ runtimeId, clientSecret } = await host.start(this.#launchSpec(env))); + } catch (error) { + throw new WorkspaceTransportError( + this.#formatStageError("start", { + attempt: 1, + maxAttempts: this.#options.restartAttempts + 1, + restarts: 0, + lastError: error, + priorExit, + }), + { cause: error }, + ); + } + try { + await host.interceptOutboundHttp(this.#options.egressHost, this.#options.workspace); + if (this.#egress.mode === "http-gateway" && this.#egressToken !== undefined) { + await host.interceptAllOutboundHttp(this.#options.workspace, this.#egressToken); + } + } catch (error) { + throw new WorkspaceTransportError( + this.#formatStageError("egress", { + attempt: 1, + maxAttempts: this.#options.restartAttempts + 1, + restarts: 0, + lastError: error, + priorExit, + }), + { cause: error }, + ); + } + + this.#clientSecret = clientSecret; + + // Arm the upgrade promise before posting /connect — computerd + // dials back as soon as /health on the egress answers, so + // the upgrade can arrive before the POST resolves. + this.#armUpgrade(); + + runtimeId = await this.#readyWithRestarts(host, env, deadline, priorExit, runtimeId); + await this.#requireAuthEnforced(host, deadline); + await this.#postConnect(host, deadline, clientSecret); + const ws = await this.#waitForUpgrade(deadline); + + const stub = newWebSocketRpcSession( + ws as unknown as globalThis.WebSocket, + ) as RpcStub; + + // `closed` resolves on the first 'close' event from the underlying + // WebSocket. The Workspace listens for it and drops its cached + // handle so the next ready() call rebuilds against a fresh + // session. + let stopHeartbeat: (() => void) | undefined; + const closed = new Promise((resolve) => { + let fired = false; + const onClose = () => { + if (fired) return; + fired = true; + stopHeartbeat?.(); + resolve(); + this.#handle = undefined; + }; + ws.addEventListener("close", onClose, { once: true }); + // Some runtimes fire 'error' without a follow-up 'close' on + // abrupt teardown; treat error as close too. + ws.addEventListener("error", onClose, { once: true }); + // capnweb's RPC layer can notice the session is broken (an + // abort frame, a malformed message) before the underlying + // WebSocket fires close. onRpcBroken closes that gap so the + // next ready() rebuilds against a fresh transport instead of + // waiting on a heartbeat or the next real RPC to discover + // the wedged session. + (stub as unknown as { onRpcBroken: (cb: (err: unknown) => void) => void }).onRpcBroken( + onClose, + ); + }); + + if (this.#options.heartbeatIntervalMs > 0) { + stopHeartbeat = startHeartbeat({ + intervalMs: this.#options.heartbeatIntervalMs, + ping: () => (stub as unknown as WorkspaceRPC).sync.watermarks(), + onFailure: () => { + try { + ws.close(); + } catch { + // already closed; idempotent + } + }, + }); + } + + const handle: BackendHandle = { + rpc: stub as unknown as WorkspaceRPC, + runtimeId, + closed, + close: async () => { + stopHeartbeat?.(); + // Dispose the root stub first. Per capnweb's docs, this is + // the documented way to shut a session down — it lets the + // RPC layer send a clean abort frame to the peer before + // the socket dies. Falling through to ws.close() is + // belt-and-braces for runtimes where the dispose path + // doesn't (yet) close the transport. + try { + (stub as unknown as Disposable)[Symbol.dispose]?.(); + } catch { + // already disposed; idempotent + } + try { + ws.close(); + } catch { + // already closed; idempotent + } + this.#handle = undefined; + }, + }; + this.#handle = handle; + return handle; + } + + // Routes an /api upgrade Request into the in-flight connect(). + // Returns the 101 response that the WorkspaceProxy fetch handler + // forwards back to the container. + async handleFetch(req: Request): Promise { + if ( + this.#egress.mode === "http-gateway" && + this.#egressToken !== undefined && + req.headers.get(WORKSPACE_EGRESS_TOKEN_HEADER) === this.#egressToken + ) { + const headers = new Headers(req.headers); + const originalUrl = headers.get(WORKSPACE_EGRESS_URL_HEADER); + let parsedUrl: URL; + try { + parsedUrl = new URL(originalUrl ?? ""); + } catch { + return new Response("invalid egress URL", { status: 400 }); + } + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return new Response("invalid egress URL", { status: 400 }); + } + headers.delete(WORKSPACE_EGRESS_TOKEN_HEADER); + headers.delete(WORKSPACE_EGRESS_URL_HEADER); + const sanitized = new Request(req, { headers }); + return this.#egress.gateway.fetch(new Request(parsedUrl, sanitized)); + } + const url = new URL(req.url); + if (url.pathname !== EGRESS_API_PATH) { + return new Response("not found", { status: 404 }); + } + // A request with no Upgrade header is a malformed handshake, which + // is a 400. 426 belongs to the narrower case of a version this end + // does not speak, and is what the daemon answers for it. + if (req.headers.get("upgrade") !== "websocket") { + return new Response(`${EGRESS_API_PATH} requires a websocket upgrade`, { status: 400 }); + } + // The slot armed by connect() hands its session to whoever arrives + // first, and this endpoint is reachable from inside the container + // through the egress interceptor. Without a token, any command the + // workspace runs could take the daemon's place and become the peer + // this durable object pushes its files to and takes its exec output + // from. The daemon presents the secret it was launched with. + if (!bearerMatches(req.headers.get("authorization"), this.#clientSecret)) { + return new Response("unauthorized", { + status: 401, + headers: { "www-authenticate": "Bearer" }, + }); + } + + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + server.accept(); + + if (this.#resolveUpgrade) { + this.#resolveUpgrade(server); + } else { + // No connect() in flight — close the socket immediately. + // The remote will redial on its next attempt; we don't + // hold orphaned sockets that nothing will reap. + server.close(1011, "no pending connect"); + return new Response("no pending connect", { status: 409 }); + } + + return new Response(null, { status: 101, webSocket: client }); + } + + // --- internals -------------------------------------------------- + + /** + * Assemble the launch spec both launch paths use. + * + * connect() and #readyWithRestarts each reach the platform through a + * different host method, and a spec built separately in each place + * drifts: the restart silently drops whatever the initial start was + * given, and the replacement container comes up with a different + * image or size than the one it replaced. One construction site is + * what keeps them honest. + */ + #launchSpec(env: Record): ContainerLaunchSpec { + return { + ...this.#options.launch, + env, + enableInternet: this.#egress.mode === "direct", + name: this.#options.name ?? DEFAULT_IMAGE_NAME, + ...(this.#options.instance === undefined ? {} : { instance: this.#options.instance }), + }; + } + + #armUpgrade(): void { + this.#pendingUpgrade = new Promise((resolve, reject) => { + this.#resolveUpgrade = resolve; + this.#rejectUpgrade = reject; + }); + // Swallow unhandled-rejection noise if connect() throws + // before anyone awaits the promise. + this.#pendingUpgrade.catch(() => {}); + } + + #clearUpgrade(): void { + this.#pendingUpgrade = undefined; + this.#resolveUpgrade = undefined; + this.#rejectUpgrade = undefined; + } + + // Drive startup readiness with bounded restart attempts. Each + // attempt runs the shared probe in a backoff loop until either + // computerd answers, the per-attempt budget elapses, or the overall + // connect deadline elapses. On a failed attempt with restarts + // remaining, run host.restart(env) and try again. + async #readyWithRestarts( + host: IWorkspaceContainerAPI, + env: Record, + deadline: number, + priorExit: { exitedAt: number; reason: string } | null, + initialRuntimeId: string, + ): Promise { + const maxAttempts = this.#options.restartAttempts + 1; + // Split the remaining time across attempts so a failing + // first attempt doesn't starve the restart-retry. Floor at + // 250ms so a near-deadline last attempt still has room to + // dispatch a probe rather than collapsing to ~1ms. + const totalBudget = Math.max(0, deadline - Date.now()); + const perAttemptBudget = Math.max(250, Math.floor(totalBudget / maxAttempts)); + let attempt = 0; + let restarts = 0; + let lastError: unknown; + let runtimeId = initialRuntimeId; + + while (attempt < maxAttempts) { + attempt++; + const attemptDeadline = Math.min(deadline, Date.now() + perAttemptBudget); + const ok = await this.#probeUntilHealthy(host, attemptDeadline).then( + () => true, + (error) => { + lastError = error; + return false; + }, + ); + if (ok) return runtimeId; + + if (attempt < maxAttempts) { + try { + ({ runtimeId } = await host.restart(this.#launchSpec(env))); + restarts++; + } catch (error) { + this.#rejectUpgrade?.(error); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + this.#formatStageError("restart", { + attempt, + maxAttempts, + restarts, + lastError: error, + }), + { cause: error }, + ); + } + } + } + + this.#rejectUpgrade?.(new Error("computerd never became healthy")); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + this.#formatStageError("health", { + attempt, + maxAttempts, + restarts, + lastError, + priorExit, + }), + lastError instanceof Error ? { cause: lastError } : undefined, + ); + } + + async #probeUntilHealthy(host: IWorkspaceContainerAPI, deadline: number): Promise { + let delay = this.#options.healthRetryInitialDelayMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await probeComputerdHealth(host, { + port: this.#options.containerPort, + path: "/health", + timeoutMs: Math.min( + this.#options.healthProbeTimeoutMs, + Math.max(50, deadline - Date.now()), + ), + }); + return; + } catch (error) { + lastError = error; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(delay, remaining)); + delay = Math.min(delay * 2, this.#options.healthRetryMaxDelayMs); + } + } + throw lastError ?? new Error("computerd health probe timed out"); + } + + #formatStageError( + stage: "start" | "egress" | "health" | "restart" | "connect" | "ws", + info: { + attempt: number; + maxAttempts: number; + restarts: number; + lastError: unknown; + priorExit?: { exitedAt: number; reason: string } | null; + }, + ): string { + const priorExit = info.priorExit ? ` priorExit=${JSON.stringify(info.priorExit.reason)}` : ""; + return ( + `ContainerBackend(${this.id}): connect failed at ` + + `stage=${stage} port=${this.#options.containerPort} ` + + `attempt=${info.attempt}/${info.maxAttempts} restarts=${info.restarts} ` + + `timeoutMs=${this.#options.connectTimeoutMs}${priorExit} ` + + `lastError=${describeError(info.lastError)}` + ); + } + + // Confirm the container refuses an unauthorized request before handing + // it a session. Launching with the secret is arranged elsewhere, but an + // image built before the daemon understood RPC_CLIENT_SECRET ignores it + // and serves everything, and that is invisible from the host: the + // bearer token goes out and the container is content either way. + // + // /api is the probe target because it exists on every daemon that has a + // capnweb endpoint at all, so this check does not depend on which + // diagnostic routes a given build happens to expose. An enforcing + // daemon answers 401 before it looks at the route; one that is not + // enforcing answers whatever the route says for an unauthenticated + // GET. + // + // A definite answer other than 401 fails the connect. Recycling a + // container that predates the secret is the cost of the upgrade, and it + // is preferable to a workspace that believes it is authorized and is + // not. A probe that cannot complete is not evidence either way and is + // allowed through: the readiness loop above is what decides whether the + // container is alive. + async #requireAuthEnforced(host: IWorkspaceContainerAPI, deadline: number): Promise { + let status: number; + try { + const res = await host.fetchPort(this.#options.containerPort, "http://container/api", { + // Bounded like every other request this file makes to the + // container. Unbounded, a container that accepts the connection + // and then stops serving would hang the connect, and a slow one + // would eat the budget #waitForUpgrade needs, surfacing as an + // upgrade that never arrived. + signal: AbortSignal.timeout( + Math.min(this.#options.healthProbeTimeoutMs, Math.max(50, deadline - Date.now())), + ), + }); + status = res.status; + // Release the body; nothing here reads it. + await res.text().catch(() => ""); + } catch { + return; + } + if (status === 401) return; + this.#rejectUpgrade?.(new Error("container is not enforcing RPC_CLIENT_SECRET")); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=auth]: container served an unauthenticated ` + + `request to /api with ${status}, so this workspace would run without authorization. ` + + `A container or image predating RPC_CLIENT_SECRET has to be recycled.`, + ); + } + + async #postConnect( + host: IWorkspaceContainerAPI, + deadline: number, + clientSecret: string, + ): Promise { + const remaining = Math.max(0, deadline - Date.now()); + let res: Response; + try { + res = await host.fetchPort(this.#options.containerPort, "http://container/connect", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${clientSecret}`, + }, + body: JSON.stringify({ + base: `http://${this.#options.egressHost}`, + health: EGRESS_HEALTH_PATH, + api: EGRESS_API_PATH, + healthTimeoutMs: remaining, + }), + }); + } catch (error) { + this.#rejectUpgrade?.(error); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=connect]: POST /connect failed: ${describeError(error)}`, + { cause: error }, + ); + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + const cause = new Error(`/connect ${res.status}`); + this.#rejectUpgrade?.(cause); + this.#clearUpgrade(); + throw new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=connect]: POST /connect returned ${res.status}: ${body}`, + { cause }, + ); + } + } + + async #waitForUpgrade(deadline: number): Promise { + const upgrade = this.#pendingUpgrade; + if (!upgrade) throw new Error("ContainerBackend: upgrade promise missing"); + + const remaining = Math.max(0, deadline - Date.now()); + let timer: ReturnType | undefined; + try { + const ws = await Promise.race([ + upgrade, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new WorkspaceTransportError( + `ContainerBackend(${this.id}) [stage=ws]: /api upgrade did not arrive within ${this.#options.connectTimeoutMs}ms`, + ), + ), + remaining, + ); + }), + ]); + return ws; + } finally { + if (timer) clearTimeout(timer); + this.#clearUpgrade(); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/packages/computer/src/backends/container/container-client-secret.test.ts b/packages/computer/src/backends/container/container-client-secret.test.ts new file mode 100644 index 00000000..f04c832a --- /dev/null +++ b/packages/computer/src/backends/container/container-client-secret.test.ts @@ -0,0 +1,73 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, test } from "vitest"; + +import { ContainerClientSecret } from "./container-client-secret.js"; + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + let writes = 0; + return { + writes: () => writes, + values, + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + writes++; + values.set(key, value); + }, + }; +} + +describe("ContainerClientSecret", () => { + test("generates 32 hex characters on first use", async () => { + const storage = fakeStorage(); + const secret = await new ContainerClientSecret(storage).ensure(); + + expect(secret).toMatch(/^[0-9a-f]{32}$/); + expect(storage.writes()).toBe(1); + }); + + test("returns the stored value on later calls without rewriting it", async () => { + const storage = fakeStorage(); + const store = new ContainerClientSecret(storage); + + const first = await store.ensure(); + const second = await store.ensure(); + + expect(second).toBe(first); + expect(storage.writes()).toBe(1); + }); + + test("a reconstructed instance reuses the persisted value", async () => { + // This is the case that matters: a durable object rebuilt against a + // container that is still running has to present the secret that + // container was launched with. + const storage = fakeStorage(); + const before = await new ContainerClientSecret(storage).ensure(); + + const after = await new ContainerClientSecret(storage).ensure(); + + expect(after).toBe(before); + }); + + test("two workspaces do not share a secret", async () => { + const one = await new ContainerClientSecret(fakeStorage()).ensure(); + const two = await new ContainerClientSecret(fakeStorage()).ensure(); + + expect(one).not.toBe(two); + }); + + test("replaces a stored value that is empty", async () => { + const storage = fakeStorage({ "computer:container-client-secret": "" }); + + const secret = await new ContainerClientSecret(storage).ensure(); + + expect(secret).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/packages/computer/src/backends/container/container-client-secret.ts b/packages/computer/src/backends/container/container-client-secret.ts new file mode 100644 index 00000000..e663bb50 --- /dev/null +++ b/packages/computer/src/backends/container/container-client-secret.ts @@ -0,0 +1,48 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Shared secret authorizing the host's requests to the container's HTTP +// surface. +// +// It has to be durable rather than generated per call. A durable object +// can be reconstructed while its container keeps running, and in that +// case start() does not relaunch, so the environment the container holds +// is the one from the original launch. A fresh secret each incarnation +// would not match it and every request would be refused, breaking the +// reconnect that POST /connect exists to serve. +// +// Persisting it also covers the other direction: when a replacement +// container is launched, it receives the value already stored, so both +// ends stay in step without the host having to read anything back. + +interface ClientSecretStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; +} + +const STORAGE_KEY = "computer:container-client-secret"; + +// 16 random bytes, hex encoded: 32 characters carrying 128 bits. +const SECRET_BYTES = 16; + +function generate(): string { + const bytes = crypto.getRandomValues(new Uint8Array(SECRET_BYTES)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export class ContainerClientSecret { + constructor(private readonly storage: ClientSecretStorage) {} + + // Returns the stored secret, generating and persisting one the first + // time. Callers may invoke this on every start; only the first writes. + async ensure(): Promise { + const existing = await this.storage.get(STORAGE_KEY); + if (typeof existing === "string" && existing.length > 0) return existing; + const secret = generate(); + await this.storage.put(STORAGE_KEY, secret); + return secret; + } +} diff --git a/packages/computer/src/backends/container/container-host-launch.test.ts b/packages/computer/src/backends/container/container-host-launch.test.ts new file mode 100644 index 00000000..e84473e8 --- /dev/null +++ b/packages/computer/src/backends/container/container-host-launch.test.ts @@ -0,0 +1,165 @@ +// What reaches ctx.container.start(), and what happens when the image +// cannot be resolved. +// +// Under this scheduling policy the durable object owns the container +// lifecycle, so start() has to name the image and may name a size. +// Both come from the launch spec, and both have to survive the trip +// through the host: a dropped field is not an error anywhere, it is a +// container that boots with the wrong filesystem or the wrong +// resources. +import { describe, expect, test } from "vitest"; + +import { WorkspaceContainerAPI } from "./container-host.js"; +import type { ContainerLaunchSpec } from "./container-launch-record.js"; + +type StartOptions = ContainerStartupOptions; + +function fakeCtx(options: { images?: Record; running?: boolean } = {}) { + const values = new Map(); + const starts: StartOptions[] = []; + let exited: (() => void) | undefined; + const container = { + running: options.running ?? false, + images: options.images ?? { app: "registry.example/app@sha256:abc" }, + start(spec: StartOptions) { + starts.push(spec); + container.running = true; + }, + async destroy() { + container.running = false; + exited?.(); + exited = undefined; + }, + async setInactivityTimeout() {}, + monitor: () => + new Promise((resolve) => { + exited = resolve; + }), + getTcpPort: () => ({}) as Fetcher, + }; + const ctx = { + container, + storage: { + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + values.set(key, value); + }, + async delete(key: string): Promise { + return values.delete(key); + }, + }, + blockConcurrencyWhile: async (fn: () => Promise) => fn(), + } as unknown as DurableObjectState; + return { ctx, container, starts }; +} + +function spec(overrides: Partial = {}): ContainerLaunchSpec { + return { + env: { PORT: "8080", MOUNT_POINT: "/workspace" }, + enableInternet: false, + ...overrides, + }; +} + +describe("launch options reach the platform", () => { + test("resolves the default image when the spec names none", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start(spec()); + + expect(starts[0]).toMatchObject({ image: "registry.example/app@sha256:abc" }); + }); + + test("resolves the named image", async () => { + const { ctx, starts } = fakeCtx({ + images: { app: "img-app", toolchain: "img-toolchain" }, + }); + + await new WorkspaceContainerAPI(ctx).start(spec({ name: "toolchain" })); + + expect(starts[0]).toMatchObject({ image: "img-toolchain" }); + }); + + test("forwards the instance size", async () => { + const { ctx, starts } = fakeCtx(); + const instance = { vcpu: 16, memoryMib: 32768, diskMb: 64000 }; + + await new WorkspaceContainerAPI(ctx).start(spec({ instance })); + + expect(starts[0]?.instance).toEqual(instance); + }); + + test("forwards options the host does not itself interpret", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start( + spec({ entrypoint: ["/bin/sh", "-c", "computerd"], labels: { tier: "gold" } }), + ); + + expect(starts[0]?.entrypoint).toEqual(["/bin/sh", "-c", "computerd"]); + expect(starts[0]?.labels).toEqual({ tier: "gold" }); + }); + + test("injects the client secret over a caller-supplied value", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start( + spec({ env: { PORT: "8080", RPC_CLIENT_SECRET: "attacker-chosen" } }), + ); + + expect(starts[0]?.env?.RPC_CLIENT_SECRET).toMatch(/^[0-9a-f]{32}$/); + expect(starts[0]?.env?.RPC_CLIENT_SECRET).not.toBe("attacker-chosen"); + }); + + test("boots from a snapshot instead of an image when one is given", async () => { + const { ctx, starts } = fakeCtx(); + + await new WorkspaceContainerAPI(ctx).start(spec({ containerSnapshot: { id: "snap-1" } })); + + expect(starts[0]).toMatchObject({ containerSnapshot: { id: "snap-1" } }); + expect(starts[0]).not.toHaveProperty("image"); + }); +}); + +describe("image resolution failures", () => { + // The likeliest first-run mistake: pointing this backend at a + // container the platform schedules. No image name could resolve, so + // the message has to name the backend that serves that deployment + // rather than just reporting an empty map. + test("an empty images map names the backend that serves that deployment", async () => { + const { ctx } = fakeCtx({ images: {} }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec())).rejects.toThrow( + /LegacyContainerBackend/, + ); + }); + + test("an empty images map explains the wrangler configuration it expects", async () => { + const { ctx } = fakeCtx({ images: {} }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec())).rejects.toThrow(/scheduling_policy/); + }); + + test("an unknown image name lists the prepared images", async () => { + const { ctx } = fakeCtx({ images: { app: "img-app", toolchain: "img-toolchain" } }); + + await expect(new WorkspaceContainerAPI(ctx).start(spec({ name: "missing" }))).rejects.toThrow( + /prepared images: app, toolchain/, + ); + }); + + // A failed resolve must not leave a runtime identity claiming a + // process that was never started, or a later call addresses a + // container that does not exist. + test("a failed launch clears the runtime identity", async () => { + const { ctx, starts } = fakeCtx({ images: {} }); + const api = new WorkspaceContainerAPI(ctx); + + await expect(api.start(spec())).rejects.toThrow(); + + expect(starts).toHaveLength(0); + expect(await api.status()).toMatchObject({ running: false }); + }); +}); diff --git a/packages/computer/src/backends/container/container-host.ts b/packages/computer/src/backends/container/container-host.ts new file mode 100644 index 00000000..b13dcc85 --- /dev/null +++ b/packages/computer/src/backends/container/container-host.ts @@ -0,0 +1,421 @@ +// IWorkspaceContainerAPI — the seam ContainerBackend +// drives instead of talking to a Container binding directly. Two +// reasons it exists: +// +// 1. Same-DO vs cross-DO. A container-pool deployment wants the +// DO that owns the Workspace (e.g. an Agent DO) to be separate +// from the DO that owns the Container binding (a pool member +// that can be re-leased between sessions). The pool member's +// ctx.container isn't reachable from the Agent's isolate, but +// an RpcTarget stub satisfying IWorkspaceContainerAPI is. +// +// 2. Testability. The interface is narrower than `Container` — +// three methods — and fakes don't need to mimic the full +// runtime surface. +// +// Consumers don't implement this interface directly. They mix +// `withWorkspaceContainer(Base)` into their DO class, which adds a +// single `ws` accessor returning a `WorkspaceContainerAPI` — +// an RpcTarget so it works the same in-isolate and across RPC. + +import { RpcTarget } from "cloudflare:workers"; +import { WorkspaceTransportError } from "../../transport-failure.js"; +import { ContainerClientSecret } from "./container-client-secret.js"; +import { + type ContainerLaunchRecord, + type ContainerLaunchSpec, + CurrentContainerLaunchRecord, + launchRecordFor, + sameLaunch, +} from "./container-launch-record.js"; +import { + type ContainerExitInfo, + containerExitInfo, + destroyContainerExpectingExit, + installContainerMonitor, +} from "./container-lifecycle.js"; +import { CurrentContainerRuntimeIdentity } from "./container-runtime-identity.js"; + +export type { ContainerExitInfo } from "./container-lifecycle.js"; + +// Image key assumed when a caller names none. Matches the convention +// wrangler documents for the `images` block. +const DEFAULT_IMAGE_NAME = "app"; + +// Identifies the Durable Object that owns the Workspace and answers +// the /api upgrade. Plain data so it can travel over Workers RPC. +export interface WorkspaceRef { + // Binding name in the host Worker's env that resolves to the + // DurableObjectNamespace for the Workspace-owning DO class. + binding: string; + // Stringified DurableObjectId of the specific Workspace owner. + id: string; +} + +// Driver surface ContainerBackend talks to. Implemented +// by WorkspaceContainerAPI below; exposed on consumer DOs through +// the `ws` accessor that withWorkspaceContainer installs. +export interface ContainerRuntimeInfo { + runtimeId: string; + // Shared secret the container requires on its HTTP surface. Durable, + // so a reconstructed durable object presents the value the running + // container was launched with. + clientSecret: string; + // What the call actually did. `adopted` means a container was already + // running with the requested launch spec and was reused. `relaunched` + // means one was running with a different spec, or with none recorded, + // and had to be replaced: the environment and the internet flag can + // only be set at launch, so adopting it would have quietly ignored + // what the caller asked for. + outcome: "launched" | "adopted" | "relaunched"; +} + +export interface IWorkspaceContainerAPI { + // Idempotent start. Returns the durable identity of the running + // container process once the runtime has accepted the start command; + // readiness is verified by the backend through probeComputerdHealth. + start(spec: ContainerLaunchSpec): Promise; + + // Wire `host` → workspace inside the container's egress table. + // Called once per backend connect(). The implementation + // constructs the loopback Fetcher locally from {binding, id}, + // because Fetchers can't survive a Workers RPC hop. + interceptOutboundHttp(host: string, workspace: WorkspaceRef): Promise; + interceptAllOutboundHttp(workspace: WorkspaceRef, token: string): Promise; + + // Fetch against a named TCP port inside the container. The fetch + // runs in the container-owning Durable Object, so callers across + // Workers RPC do not need to receive and reuse a Fetcher stub. + fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise; + + // Return a Fetcher bound to the named TCP port inside the + // container for same-isolate callers and advanced integrations. + port(port: number): Fetcher; + + // Force a fresh container generation when startup readiness + // never opens or a lease-time health check has declared the + // current generation dead. Implementation: destroy() the + // container, then start({ env }). Callers bound the number of + // restart attempts — this method does no looping of its own. + restart(spec: ContainerLaunchSpec): Promise; + + // Set the platform's idle timeout for the attached container. Exposed + // so a caller that pre-starts containers, such as a warm pool, never + // needs to reach past this API to ctx.container. + setInactivityTimeout(durationMs: number): Promise; + + // Coarse diagnostic state. The `running` flag reports whether + // the platform still has a container instance attached; it does + // not prove that computerd is listening or responsive. Use + // probeComputerdHealth for readiness; use status() only for logs and + // tracing. `exit` is populated from the in-memory monitor() + // signal when the most recent container generation exited; it + // resets on the next successful start(). + status(): Promise<{ running: boolean; exit: ContainerExitInfo | null }>; + + // Snapshot of the last container exit reason observed through + // monitor(). Null while the current generation is alive (or + // before any container has been started). Used by the backend + // pre-flight check in connect() to attribute readiness failures + // to the prior generation's exit when one is available. + exitInfo(): Promise; +} + +// Concrete implementation. Extends RpcTarget so it travels intact +// across a Workers RPC boundary; in-isolate callers see plain +// method calls. Constructed by withWorkspaceContainer's `ws` +// getter — consumers don't instantiate this directly. +export class WorkspaceContainerAPI extends RpcTarget implements IWorkspaceContainerAPI { + readonly #container: NonNullable; + readonly #ctx: DurableObjectState; + readonly #runtimeIdentity: CurrentContainerRuntimeIdentity; + readonly #clientSecret: ContainerClientSecret; + readonly #launchRecord: CurrentContainerLaunchRecord; + + constructor(ctx: DurableObjectState) { + super(); + if (!ctx.container) { + throw new Error("WorkspaceContainerAPI: DO is not container-enabled (check wrangler.jsonc)"); + } + this.#container = ctx.container; + this.#ctx = ctx; + this.#runtimeIdentity = new CurrentContainerRuntimeIdentity(ctx.storage); + this.#clientSecret = new ContainerClientSecret(ctx.storage); + this.#launchRecord = new CurrentContainerLaunchRecord(ctx.storage); + } + + async start(spec: ContainerLaunchSpec): Promise { + // If a prior generation has died, commit to a fresh one: the + // destroy clears any platform-side carcass, and the start that + // follows is unconditional. We cannot rely on + // this.#container.running to flip to false synchronously after + // destroy resolves, so guarding the start against it would let + // a stale-running flag skip the re-launch entirely. + const priorExit = containerExitInfo(this.#ctx); + // Resolved before any launch so the value written into the + // container's environment is the same one later incarnations read + // back and present on their requests. + const clientSecret = await this.#clientSecret.ensure(); + const requested = await launchRecordFor(spec); + + if (priorExit !== null) { + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // best-effort — the next start() will surface any real + // platform-side failure. + } + return this.#launchAs(spec, clientSecret, requested, "launched"); + } + if (!this.#container.running) { + return this.#launchAs(spec, clientSecret, requested, "launched"); + } + + // A container is already running. Adopting it is only correct if it + // was launched with the spec being asked for now: the environment + // and the internet flag cannot be changed on a live container, so + // adopting a mismatched one would silently drop what this caller + // wants. An absent record means something started the container + // without going through here, which is the same problem. + const actual = await this.#launchRecord.get(); + if (actual === null || !sameLaunch(actual, requested)) { + console.warn({ + message: + actual === null + ? "container was started outside WorkspaceContainerAPI; relaunching so the requested environment applies" + : "running container was launched with a different spec; relaunching", + component: "workspace-container", + requested, + actual, + }); + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // best-effort, as above. + } + return this.#launchAs(spec, clientSecret, requested, "relaunched"); + } + + // A Durable Object incarnation can be reconstructed while its + // container stays alive. Reuse the durable runtime id rather + // than treating the new WebSocket as a new process. + const runtime = + (await this.#runtimeIdentity.get()) ?? (await this.#runtimeIdentity.markStarted()); + installContainerMonitor(this.#ctx, this.#container, () => this.#runtimeIdentity.clear(runtime)); + return { runtimeId: runtime.id, clientSecret, outcome: "adopted" }; + } + + async restart(spec: ContainerLaunchSpec): Promise { + // destroy() resolves once the platform has torn down the + // attached container. A subsequent start() launches a fresh + // generation — ports re-bind, the computerd daemon comes up clean. + // destroyContainerExpectingExit flips the lifecycle flag so + // the monitor handler logs the exit as intentional. + try { + await destroyContainerExpectingExit(this.#ctx, this.#container); + } catch { + // tolerate a flaky destroy — start() below will either + // succeed against a fresh generation or surface its own + // failure. + } + const clientSecret = await this.#clientSecret.ensure(); + return this.#launchAs(spec, clientSecret, await launchRecordFor(spec), "launched"); + } + + async setInactivityTimeout(durationMs: number): Promise { + await this.#container.setInactivityTimeout(durationMs); + } + + async #launchAs( + spec: ContainerLaunchSpec, + clientSecret: string, + record: ContainerLaunchRecord, + outcome: "launched" | "relaunched", + ): Promise { + const runtime = await this.#runtimeIdentity.markStarted(); + const { env, name, containerSnapshot, ...passthrough } = spec; + // The platform types image and containerSnapshot as mutually + // exclusive arms of a union, so each is built as its own literal. + // Spreading an optional `image` onto a shared object does not + // typecheck against either arm: the snapshot arm requires the key + // to be absent, and `string | undefined` is not `undefined`. + const common = { + ...passthrough, + enableInternet: spec.enableInternet, + // Assigned after the spread so the secret wins over a + // caller-supplied value. The container refuses requests that do + // not present it, so a caller overriding it would lock the host + // out of its own container. + env: { ...env, RPC_CLIENT_SECRET: clientSecret }, + }; + try { + this.#container.start( + containerSnapshot === undefined + ? { ...common, image: this.#resolveImage(name) } + : { ...common, containerSnapshot }, + ); + } catch (error) { + await this.#runtimeIdentity.clear(runtime); + throw error; + } + // Written after the start is accepted, so a failed launch does not + // leave a record claiming the container holds this spec. + await this.#launchRecord.set(record); + installContainerMonitor(this.#ctx, this.#container, () => this.#runtimeIdentity.clear(runtime)); + return { runtimeId: runtime.id, clientSecret, outcome }; + } + + /** + * Resolve the image the container boots from. + * + * Under this scheduling policy the durable object owns the container + * lifecycle, so the platform supplies no image of its own and + * `start()` has to name one. Wrangler prepares the images declared + * in the `images` block and exposes them as `ctx.container.images`, + * keyed by the name used there. + * + * Only reached when no snapshot was supplied. A snapshot already + * carries the filesystem an image would provide, and the platform + * types the two as mutually exclusive, so that case never needs an + * image resolved. + */ + #resolveImage(name: string | undefined): string { + const images = this.#container.images; + const available = Object.keys(images ?? {}); + if (available.length === 0) { + // Empty `images` means the platform schedules this container and + // picks the image from the wrangler containers block. That is a + // valid deployment, just not one this backend serves, and it + // cannot be rescued here: no image name would resolve. Naming + // the other backend is the whole value of this message, because + // the symptom otherwise is a container that never becomes + // healthy for no visible reason. + throw new Error( + "container has no prepared images. This backend drives containers the " + + 'durable object schedules, which requires `scheduling_policy: "durable_object"` ' + + "and an `images` map in the wrangler containers block. A " + + "platform-scheduled container is served by LegacyContainerBackend " + + "from @cloudflare/computer/backends/container-legacy.", + ); + } + + const wanted = name ?? DEFAULT_IMAGE_NAME; + const image = images?.[wanted]; + if (image === undefined) { + // Almost always an images key that does not match what the code + // asks for, so list what the deployment actually prepared. + throw new Error( + `container image "${wanted}" is not available; prepared images: ${available.join(", ")}`, + ); + } + return image; + } + + async status() { + return { running: this.#container.running, exit: containerExitInfo(this.#ctx) }; + } + + async exitInfo(): Promise { + return containerExitInfo(this.#ctx); + } + + async interceptAllOutboundHttp(ref: WorkspaceRef, token: string) { + const exports = (this.#ctx as unknown as { exports: Record }).exports as { + WorkspaceProxy: (opts: { props: WorkspaceRef & { egressToken: string } }) => Fetcher; + }; + const proxy = exports.WorkspaceProxy({ props: { ...ref, egressToken: token } }); + await Promise.all([ + this.#container.interceptAllOutboundHttp(proxy), + this.#container.interceptOutboundHttps("*", proxy), + ]); + } + + async interceptOutboundHttp(host: string, ref: WorkspaceRef) { + // ctx.exports.WorkspaceProxy is bound by name in the + // consumer's Worker (they re-export WorkspaceProxy from this + // package). The cast keeps us independent of the consumer's + // worker-configuration.d.ts. + // ctx.exports is present at runtime but not on the public + // DurableObjectState type; cast through unknown to reach it. + const exports = (this.#ctx as unknown as { exports: Record }).exports as { + WorkspaceProxy: (opts: { props: WorkspaceRef }) => Fetcher; + }; + await this.#container.interceptOutboundHttp(host, exports.WorkspaceProxy({ props: ref })); + } + + fetchPort(port: number, input: RequestInfo | URL, init?: RequestInit): Promise { + // Short-circuit if the container is known to have exited. + // WorkspaceTransportError is classified by + // isWorkspaceTransportFailure, so the Workspace cache drops + // the stale handle and the next operation reconnects against + // a fresh generation. + const exit = containerExitInfo(this.#ctx); + if (exit !== null) { + throw new WorkspaceTransportError(`container exited: ${exit.reason}`); + } + return this.#container.getTcpPort(port).fetch(input, init); + } + + port(port: number) { + return this.#container.getTcpPort(port); + } +} + +// TS requires a mixin class's constructor to take a single rest +// parameter, so we widen here. DurableObject's runtime signature +// is still (ctx, env); the rest tuple just makes TS happy. We +// don't constrain the instance shape because DurableObject's +// `ctx` is protected — visible inside the mixin's class body via +// `extends Base`, but not as a public structural property. +// biome-ignore lint/suspicious/noExplicitAny: mixin constructor shape requires any[] +type DOCtor = new (...args: any[]) => object; + +// Mixin: add a single `getWorkspaceContainer()` method to a DO +// class. Returns a fresh WorkspaceContainerAPI bound to this DO's +// ctx. One name added to the consumer's class — nothing to +// forward to super, nothing else to override. A method (not a +// getter) so it crosses Workers RPC as a callable, and the +// long-form name keeps it from colliding with anything the +// consumer's base class might already expose. +// +// Same-DO usage (Agent owns the container): +// +// export class Agent extends withWorkspaceContainer( +// class extends DurableObject {}, +// ) { +// #backend = new ContainerBackend({ +// container: () => this, +// workspace: { binding: "Agent", id: this.ctx.id.toString() }, +// }); +// } +// +// Cross-DO usage (pool member owns the container): +// +// export class ComputerdHost extends withWorkspaceContainer( +// class extends DurableObject {}, +// ) {} +// +// #backend = new ContainerBackend({ +// container: () => this.env.ComputerdHost.get(memberId), +// workspace: { binding: "Agent", id: this.ctx.id.toString() }, +// }); +// +// Constructor type the mixin returns. Written explicitly so +// rolldown-plugin-dts can emit a stable .d.ts (anonymous returned +// classes with method declarations trip its TS transformer). +export type WithWorkspaceContainerCtor = TBase & + (new ( + // biome-ignore lint/suspicious/noExplicitAny: mirror mixin constructor shape + ...args: any[] + ) => InstanceType & { getWorkspaceContainer(): WorkspaceContainerAPI }); + +export function withWorkspaceContainer( + Base: TBase, +): WithWorkspaceContainerCtor { + class WithWorkspaceContainer extends Base { + getWorkspaceContainer(): WorkspaceContainerAPI { + return new WorkspaceContainerAPI((this as unknown as { ctx: DurableObjectState }).ctx); + } + } + return WithWorkspaceContainer as WithWorkspaceContainerCtor; +} diff --git a/packages/computer/src/backends/container/container-launch-record.test.ts b/packages/computer/src/backends/container/container-launch-record.test.ts new file mode 100644 index 00000000..7f72426d --- /dev/null +++ b/packages/computer/src/backends/container/container-launch-record.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "vitest"; + +import { + type ContainerLaunchSpec, + CurrentContainerLaunchRecord, + launchRecordFor, + sameLaunch, +} from "./container-launch-record.js"; + +function spec(overrides: Partial = {}): ContainerLaunchSpec { + return { + env: { PORT: "8080", MOUNT_POINT: "/workspace" }, + enableInternet: false, + ...overrides, + }; +} + +function fakeStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + values, + async get(key: string): Promise { + return values.get(key) as T | undefined; + }, + async put(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; +} + +describe("launchRecordFor", () => { + test("is stable regardless of the order keys were written in", async () => { + const one = await launchRecordFor(spec({ env: { A: "1", B: "2" } })); + const two = await launchRecordFor(spec({ env: { B: "2", A: "1" } })); + + expect(one.envDigest).toBe(two.envDigest); + expect(sameLaunch(one, two)).toBe(true); + }); + + test("changes when an environment value changes", async () => { + const one = await launchRecordFor(spec({ env: { A: "1" } })); + const two = await launchRecordFor(spec({ env: { A: "2" } })); + + expect(sameLaunch(one, two)).toBe(false); + }); + + test("changes when the internet flag changes", async () => { + const one = await launchRecordFor(spec({ enableInternet: false })); + const two = await launchRecordFor(spec({ enableInternet: true })); + + expect(sameLaunch(one, two)).toBe(false); + }); +}); + +// The fields below cannot be changed on a running container, so a +// container launched with one value cannot be adopted by a caller that +// asks for another. Each one has to reach the digest, or the adoption +// check silently hands back a container configured for someone else. +describe("launch-time-only options reach the digest", () => { + test("a different instance size is not the same launch", async () => { + const one = await launchRecordFor(spec({ instance: "standard-2" })); + const two = await launchRecordFor(spec({ instance: "standard-4" })); + + expect(sameLaunch(one, two)).toBe(false); + }); + + test("a named tier and a custom size are not the same launch", async () => { + const named = await launchRecordFor(spec({ instance: "standard-2" })); + const custom = await launchRecordFor( + spec({ instance: { vcpu: 1, memoryMib: 6144, diskMb: 12000 } }), + ); + + expect(sameLaunch(named, custom)).toBe(false); + }); + + test("a custom size digests by value, not by key order", async () => { + const one = await launchRecordFor( + spec({ instance: { vcpu: 16, memoryMib: 32768, diskMb: 64000 } }), + ); + const two = await launchRecordFor( + spec({ instance: { diskMb: 64000, vcpu: 16, memoryMib: 32768 } }), + ); + + expect(sameLaunch(one, two)).toBe(true); + }); + + test("a different image name is not the same launch", async () => { + const one = await launchRecordFor(spec({ name: "app" })); + const two = await launchRecordFor(spec({ name: "toolchain" })); + + expect(sameLaunch(one, two)).toBe(false); + }); +}); + +// A snapshot describes how a container was created, not a property of +// the running container: one restored from a snapshot is +// indistinguishable from one that was not. Digesting it would destroy +// and relaunch a healthy container every time the stored handle moved +// on, which is a relaunch nobody asked for. +describe("creation-time options stay out of the digest", () => { + test("a different container snapshot is still the same launch", async () => { + const one = await launchRecordFor(spec({ containerSnapshot: { id: "snap-1" } })); + const two = await launchRecordFor(spec({ containerSnapshot: { id: "snap-2" } })); + + expect(sameLaunch(one, two)).toBe(true); + }); + + test("a different directory snapshot is still the same launch", async () => { + const one = await launchRecordFor(spec({ directorySnapshots: [{ mountPoint: "/cache" }] })); + const two = await launchRecordFor(spec({ directorySnapshots: [{ mountPoint: "/other" }] })); + + expect(sameLaunch(one, two)).toBe(true); + }); +}); + +describe("CurrentContainerLaunchRecord", () => { + test("returns null before anything is stored", async () => { + const record = new CurrentContainerLaunchRecord(fakeStorage()); + + expect(await record.get()).toBeNull(); + }); + + test("round-trips a stored record", async () => { + const record = new CurrentContainerLaunchRecord(fakeStorage()); + const written = await launchRecordFor(spec({ instance: "standard-2", name: "app" })); + + await record.set(written); + + expect(await record.get()).toEqual(written); + }); +}); diff --git a/packages/computer/src/backends/container/container-launch-record.ts b/packages/computer/src/backends/container/container-launch-record.ts new file mode 100644 index 00000000..f283335c --- /dev/null +++ b/packages/computer/src/backends/container/container-launch-record.ts @@ -0,0 +1,108 @@ +// What the running container process was launched with. +// +// Some startup options cannot be changed once a container is running. +// A durable object that finds one already running therefore cannot +// apply them, and until it can tell what the container was launched +// with it has no way to know whether adopting it is safe. A warm pool +// that pre-starts containers is the case in point: the workspace that +// later adopts one may want a different environment, a different size, +// or no internet access at all. +// +// So each launch records what it used, and adoption compares. A +// container launched outside this API leaves no record at all, which +// reads as a mismatch and gets it relaunched rather than trusted. + +/** Instance size accepted by `start()`, as the platform declares it. */ +export type ContainerInstanceSize = NonNullable; + +// Everything the platform accepts at startup, minus the three fields +// this package owns. `env` and `enableInternet` are required rather +// than optional because every launch sets them. `image` is excluded +// deliberately: the platform makes it mutually exclusive with +// `containerSnapshot`, and the caller names an image through `name` +// instead, which the host resolves against ctx.container.images. +export interface ContainerLaunchSpec + extends Omit { + env: Record; + enableInternet: boolean; + /** Key into `ctx.container.images`, resolved by the container host. */ + name?: string; +} + +export interface ContainerLaunchRecord { + enableInternet: boolean; + // A digest rather than the environment itself: containerEnv is + // consumer-supplied and may carry their own secrets, and this record + // only ever needs to answer "the same or not". + envDigest: string; + // Canonicalised rather than stored raw, because a named tier and an + // equivalent custom size are different shapes, and two custom sizes + // written in a different key order are the same launch. + instanceDigest?: string; + name?: string; + // A startup option that cannot change on a running container belongs + // here too. hardTimeout is the next candidate, but the workers-types + // this package compiles against does not declare it, so there is + // nothing to digest yet; add it with the field rather than ahead of + // it. +} + +interface LaunchRecordStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; +} + +const STORAGE_KEY = "computer:container-launch-record"; + +export async function launchRecordFor(spec: ContainerLaunchSpec): Promise { + // The field list is explicit rather than "digest the whole spec" so + // that a startup option added upstream is a deliberate decision here + // rather than an accidental relaunch trigger. The test file records + // which side of the line each field falls on and why. + return { + enableInternet: spec.enableInternet, + envDigest: await digestEnv(spec.env), + ...(spec.instance === undefined ? {} : { instanceDigest: canonicalInstance(spec.instance) }), + ...(spec.name === undefined ? {} : { name: spec.name }), + }; +} + +export function sameLaunch(a: ContainerLaunchRecord, b: ContainerLaunchRecord): boolean { + return ( + a.enableInternet === b.enableInternet && + a.envDigest === b.envDigest && + a.instanceDigest === b.instanceDigest && + a.name === b.name + ); +} + +// A named tier is its own name; a custom size is its three numbers in a +// fixed order, tagged so no tier name could ever collide with one. +function canonicalInstance(instance: ContainerInstanceSize): string { + if (typeof instance === "string") return `tier:${instance}`; + return `size:${instance.vcpu}/${instance.memoryMib}/${instance.diskMb}`; +} + +// Sorted so two callers building the same environment in a different +// order agree, and length-prefixed so no combination of names and +// values can be rearranged into the same input. +async function digestEnv(env: Record): Promise { + const canonical = Object.keys(env) + .sort() + .map((name) => `${name.length}:${name}=${env[name]?.length ?? 0}:${env[name] ?? ""}`) + .join(";"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export class CurrentContainerLaunchRecord { + constructor(private readonly storage: LaunchRecordStorage) {} + + async get(): Promise { + return (await this.storage.get(STORAGE_KEY)) ?? null; + } + + async set(record: ContainerLaunchRecord): Promise { + await this.storage.put(STORAGE_KEY, record); + } +} diff --git a/packages/computer/src/backends/container/container-lifecycle.test.ts b/packages/computer/src/backends/container/container-lifecycle.test.ts new file mode 100644 index 00000000..5e184055 --- /dev/null +++ b/packages/computer/src/backends/container/container-lifecycle.test.ts @@ -0,0 +1,430 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + containerExitInfo, + destroyContainerExpectingExit, + formatExitReason, + getContainerLifecycle, + installContainerMonitor, + resetContainerLifecycleForTests, +} from "./container-lifecycle.js"; + +// Minimal Container stand-in. The lifecycle module only touches +// .destroy(), .start(), .running, and .monitor(). A controllable +// per-generation monitor() promise lets the tests drive the exit +// signal deterministically and aim it at a specific generation. +// +// `current` exposes the live generation's controls; `generations` +// preserves the per-generation tuples so a test can fire the +// first generation's reject AFTER the second generation has been +// armed, simulating the platform's behavior when an old monitor's +// settle frame arrives late. +interface MonitorControls { + resolve: () => void; + reject: (error: unknown) => void; + promise: Promise; +} + +function makeContainer(): { + container: NonNullable; + starts: number; + destroys: number; + monitorCalls: number; + current: MonitorControls; + generations: MonitorControls[]; +} { + let starts = 0; + let destroys = 0; + let monitorCalls = 0; + let running = false; + const generations: MonitorControls[] = []; + + function armPromise(): MonitorControls { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Swallow unhandled rejection noise when the test path leaves + // the promise pending or rejects without awaiting it. + promise.catch(() => {}); + const controls: MonitorControls = { resolve, reject, promise }; + generations.push(controls); + return controls; + } + let current = armPromise(); + + const container = { + get running() { + return running; + }, + start(_options?: unknown) { + starts++; + running = true; + // Each start() arms a fresh monitor() that the next monitor() + // call returns. + current = armPromise(); + }, + async destroy() { + destroys++; + running = false; + // The real container.monitor() rejects on destroy() (SIGKILL + // surfaces as a non-zero exit). The fake mirrors that + // contract so the lifecycle's expected-exit handler is + // tested against the platform's actual settle direction. + current.reject(new Error("container destroyed")); + }, + monitor() { + monitorCalls++; + return current.promise; + }, + } as unknown as NonNullable; + + return { + container, + get starts() { + return starts; + }, + get destroys() { + return destroys; + }, + get monitorCalls() { + return monitorCalls; + }, + get current() { + return current; + }, + generations, + } as ReturnType; +} + +// Lifecycle state is keyed by ctx — a tiny opaque object suffices. +function makeContext(container: NonNullable): DurableObjectState { + return { container } as unknown as DurableObjectState; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("formatExitReason", () => { + test("returns 'exited normally' for resolve case (no error)", () => { + expect(formatExitReason(undefined)).toBe("exited normally"); + }); + + test("returns the Error message for an Error", () => { + expect(formatExitReason(new Error("OOM killed"))).toBe("OOM killed"); + }); + + test("falls back to String() for non-Error rejections", () => { + expect(formatExitReason(42)).toBe("42"); + }); +}); + +describe("installContainerMonitor", () => { + test("records exit info when the monitor resolves (clean exit)", async () => { + // container.monitor() resolves only on a clean code-0 exit on + // the real platform. The lifecycle treats that as 'exited + // normally' — useful when the workload exits on its own + // rather than being SIGKILL'd by the runtime. + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + expect(containerExitInfo(ctx)).toBeNull(); + fake.current.resolve(); + // Microtask drain. + await Promise.resolve(); + await Promise.resolve(); + + const exit = containerExitInfo(ctx); + expect(exit).not.toBeNull(); + expect(exit?.reason).toBe("exited normally"); + expect(exit?.exitedAt).toBe(Date.now()); + }); + + test("records the rejection reason and runs current-generation cleanup", async () => { + const fake = makeContainer(); + const ctx = makeContext(fake.container); + const cleanup = vi.fn(async () => {}); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container, cleanup); + + fake.current.reject(new Error("container crashed")); + await Promise.resolve(); + await Promise.resolve(); + + const exit = containerExitInfo(ctx); + expect(exit?.reason).toBe("container crashed"); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + test("logs at warn level on an unexpected exit", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + fake.current.reject(new Error("OOM killed")); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).toHaveBeenCalledTimes(1); + const [arg] = warn.mock.calls[0] ?? []; + expect(arg).toMatchObject({ + message: "workspace.container.exited", + reason: "OOM killed", + expected: false, + }); + }); + + test("logs at info level when the exit was expected (after destroyContainerExpectingExit)", async () => { + // The platform monitor() rejects on destroy. The lifecycle + // snapshots expectingExit at arm time, so even though the + // destroy's finally clears the flag synchronously, the + // monitor handler that fires later still sees expected:true. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + await destroyContainerExpectingExit(ctx, fake.container); + // Drain the monitor's then-chain. + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledTimes(1); + const [arg] = info.mock.calls[0] ?? []; + expect(arg).toMatchObject({ + message: "workspace.container.exited", + expected: true, + reason: "container destroyed", + }); + }); + + test("a late-rejecting stale monitor does not poison a new generation", async () => { + // First generation arms its monitor; we leave it pending. + // Second generation arms a new monitor (incrementing the + // generation counter). The stale handler firing after the + // new generation has armed must NOT overwrite the new + // generation's clean exit state. + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + + const staleCleanup = vi.fn(async () => {}); + fake.container.start(); + const firstGeneration = fake.current; + installContainerMonitor(ctx, fake.container, staleCleanup); + + // Second generation — a new monitor promise is armed in the + // fake's start(); installContainerMonitor bumps the lifecycle's + // generation counter and attaches a fresh handler against the + // new promise. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + const secondGeneration = fake.current; + + expect(containerExitInfo(ctx)).toBeNull(); + // Settle the stale monitor with an error — it must be + // ignored because its generation is no longer current. + firstGeneration.reject(new Error("old generation died long ago")); + await Promise.resolve(); + await Promise.resolve(); + expect(containerExitInfo(ctx)).toBeNull(); + expect(staleCleanup).not.toHaveBeenCalled(); + + // The current generation's monitor still records normally. + secondGeneration.reject(new Error("current generation died")); + await Promise.resolve(); + await Promise.resolve(); + expect(containerExitInfo(ctx)?.reason).toBe("current generation died"); + }); +}); + +describe("destroyContainerExpectingExit", () => { + test("resets expectingExit so the next generation's crash logs as a crash", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + await destroyContainerExpectingExit(ctx, fake.container); + await Promise.resolve(); + await Promise.resolve(); + // Arm a fresh monitor for a new container generation. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + fake.current.reject(new Error("real crash")); + await Promise.resolve(); + await Promise.resolve(); + + // The second exit was unexpected; it must log as a crash. + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("expected-exit log fires for restart even when monitor settles after destroy resolves", async () => { + // Production timing: the platform settles container.monitor() + // asynchronously relative to container.destroy(). If the + // expected-exit log is gated on the monitor handler running + // *before* the next generation is installed, the log is + // silently dropped. destroyContainerExpectingExit must await + // the destroyed generation's monitor handler before + // returning so the caller can install the next generation + // without superseding the pending log. + // + // Real timers here — we need setTimeout to actually fire so + // the deferred reject straddles a task boundary the way + // production does. Restore fake timers at the end so the + // surrounding beforeEach/afterEach contract holds. + vi.useRealTimers(); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Custom container where destroy() does NOT settle the + // monitor synchronously; instead it schedules the rejection + // for a later microtask, mirroring the platform's behavior. + let monitorReject: ((error: unknown) => void) | null = null; + let monitorPromise = new Promise((_, reject) => { + monitorReject = reject; + }); + monitorPromise.catch(() => {}); + const container = { + get running() { + return true; + }, + start() { + // New generation arms a fresh monitor promise. + monitorPromise = new Promise((_, reject) => { + monitorReject = reject; + }); + monitorPromise.catch(() => {}); + }, + async destroy() { + // Capture the current rejector; settle it on a macrotask + // so the destroy() resolution and the monitor rejection + // straddle a task boundary. This mirrors production + // timing — the platform's destroy can return before its + // monitor() promise settles, and microtask-only ordering + // (queueMicrotask, Promise.resolve) would mask the race. + const reject = monitorReject; + setTimeout(() => { + reject?.(new Error("deferred destroy reject")); + }, 0); + }, + monitor() { + return monitorPromise; + }, + } as unknown as NonNullable; + const ctx = makeContext(container); + resetContainerLifecycleForTests(ctx); + + container.start(); + installContainerMonitor(ctx, container); + + await destroyContainerExpectingExit(ctx, container); + // Immediately install the next generation, as restart() does. + container.start(); + installContainerMonitor(ctx, container); + + // Drain any pending tasks (including the macrotask the fake + // queued from destroy). + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + + // The destroyed generation's exit must have logged as + // expected (info), not as a crash (warn). + expect(info).toHaveBeenCalledTimes(1); + expect(info.mock.calls[0]?.[0]).toMatchObject({ + message: "workspace.container.exited", + expected: true, + }); + expect(warn).not.toHaveBeenCalled(); + + vi.useFakeTimers(); + }); + + test("a later real crash on a fresh generation logs as unexpected after a failed destroy", async () => { + // destroy() rejects against generation 1. The expected-exit + // mark sticks against generation 1. A subsequent start() + // arms generation 2; a crash on generation 2 must be logged + // as unexpected because the mark targets a generation that + // no longer matches the live one. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + fake.container.start(); + installContainerMonitor(ctx, fake.container); + + const broken = { + destroy: async () => { + throw new Error("destroy rejected"); + }, + } as unknown as NonNullable; + await expect(destroyContainerExpectingExit(ctx, broken)).rejects.toThrow(/destroy rejected/); + + // Fresh generation, fresh monitor. + fake.container.start(); + installContainerMonitor(ctx, fake.container); + fake.current.reject(new Error("real crash")); + await Promise.resolve(); + await Promise.resolve(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatchObject({ expected: false }); + }); +}); + +describe("getContainerLifecycle", () => { + test("returns null exit info before any monitor has fired", () => { + const fake = makeContainer(); + const ctx = makeContext(fake.container); + resetContainerLifecycleForTests(ctx); + expect(containerExitInfo(ctx)).toBeNull(); + expect(getContainerLifecycle(ctx).exit).toBeNull(); + }); + + test("isolates state per ctx via the WeakMap", async () => { + const a = makeContainer(); + const b = makeContainer(); + const ctxA = makeContext(a.container); + const ctxB = makeContext(b.container); + resetContainerLifecycleForTests(ctxA); + resetContainerLifecycleForTests(ctxB); + a.container.start(); + installContainerMonitor(ctxA, a.container); + b.container.start(); + installContainerMonitor(ctxB, b.container); + + a.current.reject(new Error("a crashed")); + await Promise.resolve(); + await Promise.resolve(); + + expect(containerExitInfo(ctxA)?.reason).toBe("a crashed"); + expect(containerExitInfo(ctxB)).toBeNull(); + }); +}); diff --git a/packages/computer/src/backends/container/container-lifecycle.ts b/packages/computer/src/backends/container/container-lifecycle.ts new file mode 100644 index 00000000..39139a30 --- /dev/null +++ b/packages/computer/src/backends/container/container-lifecycle.ts @@ -0,0 +1,205 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Container lifecycle helpers. +// +// `WorkspaceContainerAPI` is constructed fresh on every +// getWorkspaceContainer() call, so instance fields can't track +// monitor state across calls. The state lives in a module-level +// WeakMap keyed by the owning DO's ctx; each DO gets one slot, +// garbage-collected when the DO is reclaimed. +// +// The lifecycle helpers here are independent of any +// cloudflare:workers imports so they can be unit-tested under the +// node-based vitest runner. + +type ContainerHandle = NonNullable; + +export interface ContainerExitInfo { + exitedAt: number; + reason: string; +} + +interface ContainerLifecycleState { + // Populated when the in-flight monitor() resolves or rejects. + // Only the monitor whose generation matches `currentGeneration` + // is allowed to write here — a stale handler from a previous + // generation must not poison the fresh one's exit state. + exit: ContainerExitInfo | null; + // Monotonically incremented every time a new monitor is armed. + // The installContainerMonitor closure captures the value at arm + // time so a late-resolving old monitor can detect that it has + // been superseded. + currentGeneration: number; + // The generation whose monitor is expected to terminate — set + // by destroyContainerExpectingExit just before the destroy call. + // The monitor handler reads this and, if it matches its own + // generation, logs the exit as intentional. Storing the + // generation (not a boolean) means a later spurious clear can + // never affect the current generation, and a flag set against + // generation N cannot leak into generation N+1. + expectedExitGeneration: number | null; + // Resolves once the *current* generation's monitor handler has + // run to completion. destroyContainerExpectingExit awaits this + // so the expected-exit log is emitted before any subsequent + // installContainerMonitor bumps the generation out from under + // the in-flight handler. Null between generations and before + // the first install. + currentMonitorSettled: Promise | null; +} + +const LIFECYCLE = new WeakMap(); + +export function getContainerLifecycle(ctx: DurableObjectState): ContainerLifecycleState { + let state = LIFECYCLE.get(ctx); + if (state === undefined) { + state = { + exit: null, + currentGeneration: 0, + expectedExitGeneration: null, + currentMonitorSettled: null, + }; + LIFECYCLE.set(ctx, state); + } + return state; +} + +export function containerExitInfo(ctx: DurableObjectState): ContainerExitInfo | null { + return getContainerLifecycle(ctx).exit; +} + +export function formatExitReason(error: unknown): string { + if (error === undefined) return "exited normally"; + if (error instanceof Error) return error.message; + return String(error); +} + +// Arm a monitor() for the currently-attached container generation. +// Every call bumps the generation counter and attaches a fresh +// .then handler. The handler captures its generation at arm time +// so a stale monitor that resolves late cannot overwrite a newer +// generation's exit state. +// +// The expected-or-not classification is taken from the live +// `expectedExitGeneration` slot when the handler runs, which is +// what destroyContainerExpectingExit writes — reading the slot +// (not a closure snapshot) lets a destroy() called *after* arm +// still mark its own generation's exit as intentional. +// +// Callers (start, restart) sequence arming after a successful +// container.start(); the platform contract is "one monitor per +// generation". If a caller arms twice for the same generation, +// the worst that happens is two handlers race to write the same +// exit state — same value, same generation. +export function installContainerMonitor( + ctx: DurableObjectState, + container: ContainerHandle, + onExit?: () => void | Promise, +): void { + const state = getContainerLifecycle(ctx); + state.currentGeneration += 1; + const generation = state.currentGeneration; + // Clear any prior exit info — a fresh generation has started. + state.exit = null; + + const monitorPromise = container.monitor(); + // currentMonitorSettled tracks the wrapper that runs recordExit, + // not the raw monitor promise, so awaiting it guarantees the + // exit has been recorded (and logged) before the awaiter + // continues. Both branches feed into recordExit which never + // throws, so the wrapper itself resolves rather than rejecting. + state.currentMonitorSettled = monitorPromise.then( + () => recordExit(state, generation, undefined, onExit), + (error) => recordExit(state, generation, error, onExit), + ); +} + +// Tear down the current container generation. Marks the current +// generation as the one expected to terminate so the monitor +// handler logs the resulting exit as intentional. +// +// The mark is keyed by generation, not by a global boolean, so a +// destroy() that fires while a different generation is in flight +// (e.g. a stale destroy attempt against a generation that has +// already been replaced) cannot mark the wrong generation's exit +// as intentional. The mark sticks until the next arm; if destroy +// throws and a fresh generation never gets armed, the mark sits +// against a generation that no longer matches `currentGeneration` +// and any later real crash on the new generation logs as a crash. +export async function destroyContainerExpectingExit( + ctx: DurableObjectState, + container: ContainerHandle, +): Promise { + const state = getContainerLifecycle(ctx); + state.expectedExitGeneration = state.currentGeneration; + // Capture the current generation's monitor wrapper BEFORE the + // destroy. A subsequent installContainerMonitor reassigns + // currentMonitorSettled, but the capture here pins us to the + // one we're waiting on. + const settled = state.currentMonitorSettled; + await container.destroy(); + // Wait for the destroyed generation's monitor handler to run so + // its expected-exit log fires before the caller (e.g. + // WorkspaceContainerAPI.restart) installs the next generation's + // monitor and bumps currentGeneration out from under it. The + // platform settles monitor() asynchronously relative to + // destroy(); without this await the next install supersedes the + // pending handler and recordExit drops the write as stale. + if (settled) await settled; +} + +// Reset state for a ctx. Test-only escape hatch; the production +// path relies on WeakMap GC. +export function resetContainerLifecycleForTests(ctx: DurableObjectState): void { + LIFECYCLE.delete(ctx); +} + +async function recordExit( + state: ContainerLifecycleState, + generation: number, + error: unknown, + onExit?: () => void | Promise, +): Promise { + // Drop late writes from superseded monitors. The state object is + // shared across generations; only the current one is allowed to + // mutate `exit`. Log nothing on a stale exit — the operator + // already saw the live generation's exit when it happened. + if (generation !== state.currentGeneration) return; + const expected = state.expectedExitGeneration === generation; + // Consume the expected mark so a later spurious monitor + // resolution (e.g. an idempotent arm against the same + // generation) does not double-claim the intentional log. + if (expected) state.expectedExitGeneration = null; + const reason = formatExitReason(error); + const exitedAt = Date.now(); + state.exit = { reason, exitedAt }; + // Log a single line per exit. Single-object form so Cloudflare + // Logs picks up the structured fields alongside the message. + if (expected) { + console.info({ + message: "workspace.container.exited", + reason, + exitedAt, + expected: true, + }); + } else { + console.warn({ + message: "workspace.container.exited", + reason, + exitedAt, + expected: false, + }); + } + try { + await onExit?.(); + } catch (cleanupError) { + console.error({ + message: "workspace.container.exit_cleanup_failed", + reason: formatExitReason(cleanupError), + exitedAt: Date.now(), + }); + } +} diff --git a/packages/computer/src/backends/container/container-runtime-identity.test.ts b/packages/computer/src/backends/container/container-runtime-identity.test.ts new file mode 100644 index 00000000..f9c2ddeb --- /dev/null +++ b/packages/computer/src/backends/container/container-runtime-identity.test.ts @@ -0,0 +1,61 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, it, vi } from "vitest"; + +import { CurrentContainerRuntimeIdentity } from "./container-runtime-identity.js"; + +function storage(initial = new Map()) { + return { + get: vi.fn(async (key: string) => initial.get(key)), + put: vi.fn(async (key: string, value: unknown) => { + initial.set(key, value); + }), + delete: vi.fn(async (key: string) => initial.delete(key)), + }; +} + +describe("CurrentContainerRuntimeIdentity", () => { + it("persists a UUID for a newly started container runtime", async () => { + const backing = new Map(); + const current = new CurrentContainerRuntimeIdentity(storage(backing)); + + const runtime = await current.markStarted(); + + expect(runtime.id).toMatch(/^[0-9a-f-]{36}$/); + await expect(current.get()).resolves.toEqual(runtime); + }); + + it("returns the same stored identity across helper instances", async () => { + const backing = new Map(); + const first = new CurrentContainerRuntimeIdentity(storage(backing)); + const started = await first.markStarted(); + const recreated = new CurrentContainerRuntimeIdentity(storage(backing)); + + await expect(recreated.get()).resolves.toEqual(started); + }); + + it("creates a new UUID for each container runtime", async () => { + const current = new CurrentContainerRuntimeIdentity(storage()); + + const first = await current.markStarted(); + const second = await current.markStarted(); + + expect(second.id).not.toBe(first.id); + }); + + it("clears only the runtime identity that stopped", async () => { + const current = new CurrentContainerRuntimeIdentity(storage()); + const stale = await current.markStarted(); + const active = await current.markStarted(); + + await current.clear(stale); + await expect(current.get()).resolves.toEqual(active); + + await current.clear(active); + await expect(current.get()).resolves.toBeNull(); + }); +}); diff --git a/packages/computer/src/backends/container/container-runtime-identity.ts b/packages/computer/src/backends/container/container-runtime-identity.ts new file mode 100644 index 00000000..5bf2e0c1 --- /dev/null +++ b/packages/computer/src/backends/container/container-runtime-identity.ts @@ -0,0 +1,42 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Durable identity for the currently-running container process. +// +// A WebSocket reconnect keeps this id. Starting a replacement process +// writes a new UUID. Execution-scoped operations use it to distinguish +// reconnecting to the same computerd from reaching an empty replacement. + +export interface ContainerRuntimeIdentity { + id: string; +} + +interface RuntimeIdentityStorage { + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + +const STORAGE_KEY = "computer:container-runtime-identity"; + +export class CurrentContainerRuntimeIdentity { + constructor(private readonly storage: RuntimeIdentityStorage) {} + + async get(): Promise { + return (await this.storage.get(STORAGE_KEY)) ?? null; + } + + async markStarted(): Promise { + const runtime = { id: crypto.randomUUID() }; + await this.storage.put(STORAGE_KEY, runtime); + return runtime; + } + + async clear(runtime: ContainerRuntimeIdentity): Promise { + const current = await this.get(); + if (current?.id === runtime.id) await this.storage.delete(STORAGE_KEY); + } +} diff --git a/packages/computer/src/backends/container/health-probe.test.ts b/packages/computer/src/backends/container/health-probe.test.ts new file mode 100644 index 00000000..7f6a552e --- /dev/null +++ b/packages/computer/src/backends/container/health-probe.test.ts @@ -0,0 +1,79 @@ +// Duplicated verbatim from ../container-legacy/. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +import { describe, expect, test, vi } from "vitest"; + +import type { IWorkspaceContainerAPI } from "./container-host.js"; +import { probeComputerdHealth } from "./health-probe.js"; + +// probeComputerdHealth only consumes fetchPort. The helper is typed +// against the wider IWorkspaceContainerAPI to make ergonomic +// same-isolate calls cheap, but the test scope is narrower — use +// the minimal structural type so the fake doesn't have to stub +// methods it never reaches. +type HealthProbeHost = Pick; + +function fakeHost( + handler: (port: number, input: RequestInfo | URL, init?: RequestInit) => Promise, +): IWorkspaceContainerAPI { + const host: HealthProbeHost = { + fetchPort: vi.fn(handler), + }; + return host as IWorkspaceContainerAPI; +} + +describe("probeComputerdHealth", () => { + test("resolves on a 2xx response", async () => { + const host = fakeHost(async () => new Response(null, { status: 200 })); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).resolves.toBeUndefined(); + }); + + test("issues a HEAD request to the configured path", async () => { + const calls: { port: number; url: string; method?: string }[] = []; + const host = fakeHost(async (port, input, init) => { + const req = input instanceof Request ? input : new Request(input, init); + calls.push({ port, url: req.url, method: req.method }); + return new Response(null, { status: 200 }); + }); + await probeComputerdHealth(host, { port: 9090, path: "/__computerd/info", timeoutMs: 1_000 }); + expect(calls).toEqual([ + { port: 9090, url: "http://container/__computerd/info", method: "HEAD" }, + ]); + }); + + test("rejects on a non-2xx response with the status in the message", async () => { + const host = fakeHost(async () => new Response("bad", { status: 503 })); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).rejects.toThrow(/503/); + }); + + test("propagates host.fetchPort rejections", async () => { + const host = fakeHost(async () => { + throw new Error("connection refused"); + }); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 1_000 }), + ).rejects.toThrow(/connection refused/); + }); + + test("aborts the request after timeoutMs", async () => { + const host = fakeHost(async (_port, _input, init) => { + // Simulate a never-responding computerd: wait until the AbortSignal + // fires, then reject with an AbortError so the helper sees the + // timeout surface as a rejection. + await new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }); + return new Response(null, { status: 200 }); + }); + await expect( + probeComputerdHealth(host, { port: 8080, path: "/health", timeoutMs: 20 }), + ).rejects.toThrow(/aborted|timeout/i); + }); +}); diff --git a/packages/computer/src/backends/container/health-probe.ts b/packages/computer/src/backends/container/health-probe.ts new file mode 100644 index 00000000..7ede4877 --- /dev/null +++ b/packages/computer/src/backends/container/health-probe.ts @@ -0,0 +1,53 @@ +// Duplicated from ../container-legacy/, differing only in the name of +// the host interface it probes through. The two backends serve +// different container scheduling policies, but this file touches +// neither the launch spec nor the policy, so there is no divergence +// pressure on it and the copy is deliberate rather than overlooked. +// Fixes here apply to both copies. +// +// Shared computerd health probe. +// +// Used in two places that must agree on what "healthy" means: +// +// - LegacyContainerBackend.connect() startup readiness, in +// place of the previous private #waitForPort loop; +// - the keep-alive alarm's lease-time check. +// +// A single HEAD against the configured healthPath on the container +// port. ctx.container.running tells the runtime the container is +// attached, not that computerd is listening; this probe closes the gap. +// +// Probe failures bubble out as rejections; callers apply their own +// backoff / budget. No retries here — keeping the helper a single +// shot lets startup and lease alarms compose it differently. + +import type { IWorkspaceContainerAPI } from "./container-host.js"; + +export interface ComputerdHealthProbeOptions { + // TCP port computerd listens on inside the container. + port: number; + // Path to probe. Defaults to /health at the call site; required + // here so both callers thread the same value. + path: string; + // Per-probe timeout. The helper aborts the request when it + // elapses; the host's fetchPort surfaces that as a rejection. + timeoutMs: number; +} + +export async function probeComputerdHealth( + host: IWorkspaceContainerAPI, + options: ComputerdHealthProbeOptions, +): Promise { + const signal = AbortSignal.timeout(options.timeoutMs); + const res = await host.fetchPort(options.port, `http://container${options.path}`, { + method: "HEAD", + signal, + }); + // Drain the body so the underlying connection (if any) can be + // released. Some runtimes return a non-null body for HEAD even + // though it should be empty; cancel() is a no-op for null. + void res.body?.cancel(); + if (!res.ok) { + throw new Error(`computerd health returned ${res.status}`); + } +} diff --git a/packages/computer/src/backends/container/index.ts b/packages/computer/src/backends/container/index.ts new file mode 100644 index 00000000..6f242db3 --- /dev/null +++ b/packages/computer/src/backends/container/index.ts @@ -0,0 +1,42 @@ +// Public surface of @cloudflare/computer/backends/container. +// +// The container backend pairs a Workspace with a computerd daemon +// running inside a Cloudflare Container. computerd owns its own +// SQLite-backed VFS; the package syncs the two stores across a +// capnweb WebSocket. +// +// This backend serves containers the durable object schedules, which +// the wrangler containers block selects with +// `scheduling_policy: "durable_object"` and an `images` map. Under +// that policy the object owns the container lifecycle, so every +// start() has to name the image and may name an instance size; the +// block itself rejects `instance_type` and `max_instances`. +// +// A container the platform schedules is a different deployment shape +// and is served by LegacyContainerBackend from +// @cloudflare/computer/backends/container-legacy. +// +// Imported via: +// +// import { +// ContainerBackend, +// withWorkspaceContainer, +// } from "@cloudflare/computer/backends/container"; + +export type { WorkspaceEgressPolicy } from "../../runtime/egress.js"; +export { + ContainerBackend, + type ContainerBackendOptions, + type ContainerHostHolder, +} from "./container-backend.js"; +export { + type ContainerRuntimeInfo, + type IWorkspaceContainerAPI, + WorkspaceContainerAPI, + type WorkspaceRef, + withWorkspaceContainer, +} from "./container-host.js"; +export type { + ContainerInstanceSize, + ContainerLaunchSpec, +} from "./container-launch-record.js";