diff --git a/docs/designs/agent/compass-agent-config-delivery/design.md b/docs/designs/agent/compass-agent-config-delivery/design.md index d707bc421..26061a41e 100644 --- a/docs/designs/agent/compass-agent-config-delivery/design.md +++ b/docs/designs/agent/compass-agent-config-delivery/design.md @@ -418,7 +418,7 @@ survives in this record. ### A second env surface (container `Env` at create) -`ContainerSpec.Env` exists (`podman.go:85-86`) and setting it at `Create` +`WorkloadSpec.Env` exists (`podman.go:85-86`) and setting it at `Create` would be easy — and wrong: env fixed at create cannot rotate, values ride `-e KEY=VALUE` into host-visible podman argv (the exposure RIG-1327 T5 eliminates, `compass-agent-container-runtime.md:828-833`), and it forks diff --git a/docs/designs/agent/compass-agent-container-runtime.md b/docs/designs/agent/compass-agent-container-runtime.md index 78367f8f8..931aa4f9c 100644 --- a/docs/designs/agent/compass-agent-container-runtime.md +++ b/docs/designs/agent/compass-agent-container-runtime.md @@ -820,7 +820,7 @@ declared secrets (file under `$HOME/.compass/secrets/` or env, per aggregate env file at `$HOME/.compass/env` (a **sibling** of the `secrets/` dir, so no secret named `env` can collide with it), rejecting values with newline/NUL (the env-file line grammar). - - `type SecretMaterializer struct { runtime ContainerRuntime }`; + - `type SecretMaterializer struct { runtime WorkloadRuntime }`; `func (m *SecretMaterializer) Install(ctx context.Context, handle *AgentHandle, secrets []secrets.ResolvedSecret) error` — routes by `Kind`: provider → `ProviderSeedScript`; gh (`SecretGH`) → `GHCredentials.SetupScript` (using `ResolvedSecret.Host`); generic diff --git a/docs/designs/agent/compass-agent-spawn-despawn/design.md b/docs/designs/agent/compass-agent-spawn-despawn/design.md index d6ca9604d..5531001cb 100644 --- a/docs/designs/agent/compass-agent-spawn-despawn/design.md +++ b/docs/designs/agent/compass-agent-spawn-despawn/design.md @@ -606,7 +606,7 @@ Remove(ctx context.Context, containerName string) error Red-first: dispatch table test (`dispatch_test.go` pattern, `dispatch_test.go:160-177`) + host test: Remove tears down a launched -container (fake ContainerRuntime records Stop+Remove), retires the bound +container (fake WorkloadRuntime records Stop+Remove), retires the bound session, closes the socket; a second Remove is a no-op. ### T4 — runnerhub: `Remove` relay + `RelayLifecycleCall` resolution edge diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md index 4369ef128..3627bc0d5 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/design.md @@ -73,7 +73,7 @@ Compass makes infra cost first-order. Nothing in Dogfood blocks on this. Compass ships as two products over one shared core: - **OSS core (AGPL, `RigelBuild/compass` — this repo).** The agent runtime and - every seam this record touches — `AgentRuntime` plus the `ContainerRuntime` / + every seam this record touches — `AgentRuntime` plus the `WorkloadRuntime` / `VirtualFS` / `ComputeRuntime` seams, the volume lifecycle, and the microVM boundary. All of this record's `go/internal/*` and `agent-image/*` citations are @@ -191,13 +191,13 @@ The agent gets the **same working copy the customer's own humans get**: ### The three seams The package layering (`go/internal/runtime/podman.go:10-20`) already isolates -the engine behind the `ContainerRuntime` interface +the engine behind the `WorkloadRuntime` interface (`go/internal/runtime/podman.go:286-324`) — "everything above depends on the interface, so a libpod-REST backend can replace it without touching a caller." The hardening work reuses that discipline: -- **`ContainerRuntime` — existing verbs frozen, extended additively.** It - remains the engine seam (create/start/exec/stop against a `ContainerID`, +- **`WorkloadRuntime` — existing verbs frozen, extended additively.** It + remains the engine seam (create/start/exec/stop against a `WorkloadID`, `go/internal/runtime/podman.go:286-324`), including `ExecStreaming` (`go/internal/runtime/podman.go:299-307`) for the long-lived agent process. Resize-in-place adds one verb — `Resize(ctx, id, ResourceLimits)` (a @@ -221,15 +221,15 @@ The hardening work reuses that discipline: - **`ComputeRuntime` — the elastic-compute seam (the one genuinely new abstraction).** Named `ComputeRuntime` (over `ExecRuntime`) because what it abstracts is the compute *capacity* an exec runs against, not the exec - mechanics `ContainerRuntime` already owns. It routes a heavy op to a + mechanics `WorkloadRuntime` already owns. It routes a heavy op to a backend: **run-in-place** (the session's own environment, optionally resized) vs **burst** (a bigger transient environment sharing the session's volume). It is justified by a capability no existing seam carries: - `ContainerRuntime.Exec` (`go/internal/runtime/podman.go:286-324`) models an + `WorkloadRuntime.Exec` (`go/internal/runtime/podman.go:286-324`) models an exec against a fixed, already-sized container, while a heavy op needs an exec whose *sizing and placement* are chosen by policy at call time. `Exec` is completion-shaped; a **streaming variant is reserved in the seam - now** (live stdio + kill/wait handle, mirroring how `ContainerRuntime` + now** (live stdio + kill/wait handle, mirroring how `WorkloadRuntime` splits `Exec`/`ExecStreaming`, `go/internal/runtime/podman.go:293-307`) for RIG-1720's agent-launched dev servers, even if unimplemented, so freezing the seam does not force a breaking change later. @@ -422,7 +422,7 @@ inside a real environment, never the absence of one. backend, never define one. 2. **Go through the seams (hard rule).** Every working-tree materialization goes through `VirtualFS`; every heavy-op exec goes through - `ComputeRuntime`; the engine stays behind `ContainerRuntime`. No direct + `ComputeRuntime`; the engine stays behind `WorkloadRuntime`. No direct `exec` for a heavy op, no raw-disk path outside the session volume. Every bypass deletes the incremental-hardening migration path; a bypass is a design violation, not a shortcut. @@ -507,7 +507,7 @@ built: ### S1 — the seams, landed with their fused in-container configurations (lane: infra) Freeze the two new Go seams and land their trivial fused-model -configurations end to end, with `ContainerRuntime`'s existing verbs frozen: +configurations end to end, with `WorkloadRuntime`'s existing verbs frozen: - **`VirtualFS`** — the thin source-of-tree seam plus its checkout backend. At S1 the destination is **today's clone-dir workspace** (the genuinely @@ -550,8 +550,8 @@ configurations end to end, with `ContainerRuntime`'s existing verbs frozen: fully buffered stdout/stderr, an accepted limit for whole-suite output until the streaming variant lands. `SpecBuilder` (`go/internal/runner/host.go:46-48`) derives the `WorkspaceSource`. - `ContainerRuntime` also gains the additively-reserved - `Resize(ctx, id ContainerID, limits ResourceLimits) error` — frozen here, + `WorkloadRuntime` also gains the additively-reserved + `Resize(ctx, id WorkloadID, limits ResourceLimits) error` — frozen here, unimplemented until C3 — so I1's microVM backend and every fake carry the full surface from the start and C3 lands no interface change. `ResourceLimits{CPUShares int, MemoryBytes int64}` is the concrete @@ -573,7 +573,7 @@ and — since winning that customer depends on having it — it is built early, not deferred behind the customer. The descoping of the split and the content-addressed VFS is what frees the capacity to build it now. -- **Backend behind `ContainerRuntime`:** slot a microVM OCI runtime +- **Backend behind `WorkloadRuntime`:** slot a microVM OCI runtime (krun/libkrun or kata) via podman's `--runtime` selection, so the engine seam (`go/internal/runtime/podman.go:286-324`) is reused rather than replaced where possible. The real work is above the seam: a microVM-bootable @@ -587,7 +587,7 @@ content-addressed VFS is what frees the capacity to build it now. stable absolute path, preserving the no-copy invariant P2/C3 rely on. - **Interfaces:** produces the microVM runtime binding behind - `runtime.ContainerRuntime` (runtime selection + the rootfs image build + + `runtime.WorkloadRuntime` (runtime selection + the rootfs image build + guest egress arming); consumes `runtime.EgressPolicy.NftScript()` (`go/internal/runtime/egress.go:71-107`). No new caller-facing seam — the boundary is an engine/runtime configuration behind the existing interface. @@ -658,7 +658,7 @@ The two elastic backends behind `ComputeRuntime`, and the routing policy that picks one: - **Resize-in-place:** raise the session environment's CPU/memory limits for - the op's duration, then restore, via `ContainerRuntime.Resize`. Available + the op's duration, then restore, via `WorkloadRuntime.Resize`. Available where the runtime supports live limit changes (rootless podman on cgroups v2); under the microVM boundary (I1) live memory hotplug is limited, so resize covers CPU/headroom cases and otherwise falls back to burst. The @@ -685,7 +685,7 @@ picks one: `ResourceClass ∈ {ClassInner, ClassResized, ClassBurst}`; consumes the P2 volume attach (burst mount), `runtime.EgressPolicy.NftScript()` (`go/internal/runtime/egress.go:87`) to arm the burst environment, and - `runtime.ContainerRuntime` for the transient environment's lifecycle. + `runtime.WorkloadRuntime` for the transient environment's lifecycle. Produces the routing-policy table + its config surface + the startup reconciliation pass. - **Depends:** S1, P2, I1 (the burst environment is I1's microVM boundary); @@ -782,7 +782,7 @@ relaunch on activity. (incremental-build probe); cold-idle archive→restore round-trip reconstructs the tree + `target/` byte-for-byte from the object store and the incremental-build probe still hits warm; a suspend leaks no container - (engine reconcile via `ContainerRuntime.Exists`) and a cold idle leaves no + (engine reconcile via `WorkloadRuntime.Exists`) and a cold idle leaves no local disk footprint; warm-start and rehydration latency asserted against a budget this task produces — D4's own measurement round sets the envelope (there is no pre-existing number), and E5's managed-user behavior suite @@ -821,9 +821,9 @@ order): `compute.ComputeRuntime` elastic-compute seam (in-environment passthrough backend, reserved streaming variant, fail-closed routing-policy shell) + provision wiring + `WorkspaceSource` variant; - `ContainerRuntime` existing verbs frozen, `Resize` added additively. + `WorkloadRuntime` existing verbs frozen, `Resize` added additively. - [ ] **I1** [infra] — microVM inter-tenant boundary: microVM OCI runtime - (krun/libkrun or kata) behind `ContainerRuntime` via podman `--runtime`, + (krun/libkrun or kata) behind `WorkloadRuntime` via podman `--runtime`, microVM-bootable rootfs image, guest-netns egress arming, virtio-fs volume mount, KVM-absent degrade-to-container path (parallel with M0/S1; consumed by C3). @@ -876,7 +876,7 @@ Nothing pinned in the Approach is re-opened here. reserves a streaming variant (live stdio + kill/wait handle) for agent-launched dev servers; the port-exposure and lifecycle wiring are RIG-1720's scope. **Recommendation:** freeze the reserved signature in S1 - mirroring `ContainerRuntime.ExecStreaming` + mirroring `WorkloadRuntime.ExecStreaming` (`go/internal/runtime/podman.go:299-307`); implement nothing here. 5. **[resolved — decided, now task I1] Inter-tenant isolation boundary = microVM.** The session environment and its bursts run model-written, @@ -886,7 +886,7 @@ Nothing pinned in the Approach is re-opened here. customer *depends on* having the boundary, deferring it behind the customer is circular. **Decision:** commit to a microVM inter-tenant boundary as the end-state (session and burst), built early (task I1) while the descoping of - split/VFS frees the capacity. The boundary slots behind `ContainerRuntime` + split/VFS frees the capacity. The boundary slots behind `WorkloadRuntime` as a microVM OCI runtime (krun/libkrun or kata via podman `--runtime`), so the seam is expected to hold; the image/boot/egress plumbing above it is the real work I1 owns. Through Dogfood + trusted-tenant Beta the rootless diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md index 9706e7472..1397c8daa 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md @@ -33,7 +33,7 @@ Runner depends on: (`agent.go:304`) — is a post-start `Exec`. With no exec into the guest, none of this runs. 2. **The agent's AF_UNIX gateway socket does not cross a VM boundary.** The - `ContainerSpec.Mounts` doc: "the per-container agent gateway socket is + `WorkloadSpec.Mounts` doc: "the per-container agent gateway socket is mounted read-write (the agent must connect() to it)" (`go/internal/runtime/podman.go:100-102`). Package `go/internal/runner/gateway` is "the Runner side of the agent->Runner call @@ -51,7 +51,7 @@ speaking a protocol over virtio-vsock**, with the host Runner driving create/exec/stdio/signal *and* the gateway control plane through that vsock channel. That is real Runner control-plane work regardless of VMM choice, and it is the honest scope of this record: a **dedicated microVM Runner backend** -— a second implementation behind `runtime.ContainerRuntime` — rather than a +— a second implementation behind `runtime.WorkloadRuntime` — rather than a config swap. This record details *under* the parent's frozen decisions (Decision 5: the @@ -66,11 +66,11 @@ permanent second runtime. ## Approach -A microVM backend as a **sibling `ContainerRuntime` implementation** beside +A microVM backend as a **sibling `WorkloadRuntime` implementation** beside `PodmanCLI`, selected by Runner config. `podman.go`'s own layering note anticipated exactly this seam use: "Everything above depends on the interface, so a libpod-REST backend can replace it without touching a caller" -(`go/internal/runtime/podman.go:11-13`). The `ContainerRuntime` interface +(`go/internal/runtime/podman.go:11-13`). The `WorkloadRuntime` interface (`podman.go:303-352`: Create/Start/Exec/ExecStreaming/Stop/Remove/Exists/ MountLabel/Resize) is the contract; `AgentRuntime`, the gateway, and the session lifecycle above it stay untouched. @@ -337,7 +337,7 @@ demand via cloud-hypervisor hotplug rather than reserving peak RAM (D5). ([CH README](https://github.com/cloud-hypervisor/cloud-hypervisor#objectives)), Rust, security-focused, runs rootless as an ordinary process, proven as a Kata VMM. Hotplug directly serves the S1-reserved - `ContainerRuntime.Resize` (`podman.go:342-351`, D5). Runs on KVM/MSHV, not + `WorkloadRuntime.Resize` (`podman.go:342-351`, D5). Runs on KVM/MSHV, not macOS HVF — acceptable because native-macOS-embedded is dropped (D2). Cost: we build the guest supervisor ourselves (would have been shared with the libkrun option). @@ -416,7 +416,7 @@ availability) surface first with minimal code. ### V1 — backend seam + selection + startup gate -A `MicroVMRuntime` skeleton implementing `runtime.ContainerRuntime`, plus the +A `MicroVMRuntime` skeleton implementing `runtime.WorkloadRuntime`, plus the config-driven backend selection in Runner startup. Through the transitional period (D2) both backends exist and selection resolves to the configured one, defaulting to the container path while microVM is proven; once microVM is the @@ -424,13 +424,13 @@ sole runtime the selection collapses to microVM with `VerifyMicroVMSupport` (V5) as a hard startup gate (D3 — no container fallback to select). - **Interfaces:** produces `runtime.MicroVMRuntime` satisfying - `runtime.ContainerRuntime` (`Create(ctx, ContainerSpec) (ContainerID, - error)`, `Start`, `Exec(ctx, ContainerID, ExecSpec) (ExecOutput, error)`, - `ExecStreaming(ctx, ContainerID, StreamingExecSpec) (*StreamingExec, + `runtime.WorkloadRuntime` (`Create(ctx, WorkloadSpec) (WorkloadID, + error)`, `Start`, `Exec(ctx, WorkloadID, ExecSpec) (ExecOutput, error)`, + `ExecStreaming(ctx, WorkloadID, StreamingExecSpec) (*StreamingExec, error)`, `Stop`, `Remove`, `Exists`, `MountLabel`, `Resize` — `podman.go:303-352`), every method returning a typed `ErrMicroVMNotImplemented` until V2b/V3 fill them in; produces - `runtime.SelectBackend(cfg RunnerConfig) (ContainerRuntime, error)`. + `runtime.SelectBackend(cfg RunnerConfig) (WorkloadRuntime, error)`. Consumes `RunnerConfig` (new fields `Backend string`, `MicroVM struct{ VMMPath, VirtiofsdPath, KernelImage, RootfsImage string }`). - **Test cycle:** selection unit tests (transitional: configured backend @@ -483,7 +483,7 @@ non-zero-exit-is-not-an-error contract (`podman.go:310-314`) and the over vsock; produces the filled `MicroVMRuntime` methods. Consumes V2a's artifacts and `BootConfig`. - **Test cycle:** contract tests asserting `MicroVMRuntime` and `PodmanCLI` - behave identically through the `ContainerRuntime` surface (shared + behave identically through the `WorkloadRuntime` surface (shared table-driven contract suite, KVM-gated for the microVM rows): exec exit codes, stdin feeding (`WriteAgentFile`'s stdin-not-argv invariant, `agent.go:241-248`), streaming stdio, kill/wait, uid enforcement (uid-0 diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md index a5b8ee5a2..17fcea7ab 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md @@ -13,7 +13,7 @@ parent's schedule-critical milestone (microvm-runner.md:466-476): grow seed, `proto/compass/v1/guest_control.proto:20-22`), and implement `MicroVMRuntime`'s Create/Start/Exec/ExecStreaming/Stop/Remove against it so the microVM backend behaves identically to `PodmanCLI` through the -`ContainerRuntime` surface (`go/internal/runtime/podman.go:303-352`). Every +`WorkloadRuntime` surface (`go/internal/runtime/podman.go:303-352`). Every method today is a typed stub ("returned by every MicroVMRuntime method until the in-guest control plane lands", `go/internal/runtime/microvm.go:48-55`); V2b fills them. Egress arming stays V3's; the gateway transport stays V4's. @@ -169,8 +169,8 @@ the nft-script branch errors as unimplemented until V3: ```proto message ProvisionRequest { string nft_script = 1; // V3: EgressPolicy.NftScript(); empty in V2b - uint32 default_exec_uid = 2; // the session's agent uid (ContainerSpec.UID) - map base_env = 3; // ContainerSpec.Env, the base env every exec inherits + uint32 default_exec_uid = 2; // the session's agent uid (WorkloadSpec.UID) + map base_env = 3; // WorkloadSpec.Env, the base env every exec inherits } message ProvisionResponse {} ``` @@ -218,7 +218,7 @@ carries no spec, yet Start is where V2b buries the `Provision` call, so V3's podman the arm rides a post-Start root-capable `Exec` from `AgentRuntime.provision` (`agent.go:293-307`) — an exec path the microVM backend REFUSES (uid-0/caps). So V3 cannot reuse that seam: the intended data -path is `ContainerSpec` growing an egress field captured at Create and +path is `WorkloadSpec` growing an egress field captured at Create and delivered by Start's Provision call (podman.go:89-116 has no egress field today). V2b does not build that field, but it records the assumption here so V3's designer inherits it explicitly rather than discovering the gap; if the @@ -250,7 +250,7 @@ refuses exec specs requesting uid 0 or capabilities" (microvm-runner.md:358-360). guestd rejects any `Exec`/`StartExec` whose `uid` is 0 with a typed Connect error, before spawning anything. An *absent* uid resolves to the session's default exec uid delivered by `Provision` -(`default_exec_uid`, the `ContainerSpec.UID` — the baked agent uid, +(`default_exec_uid`, the `WorkloadSpec.UID` — the baked agent uid, `podman.go:109-113`) — mirroring podman's "Nil runs as the image's default user (for the compass-agent image that is uid 1000, not root)" (`podman.go:119-121`), with the default supplied per session instead of baked @@ -269,7 +269,7 @@ a non-host CID is closed immediately. This lands in V2b (not V8, which only *probes* it) because V2b is what turns the port from a Health responder into an exec surface worth escalating to. -**Env base.** `ContainerSpec.Env` on podman is set on the container and thus +**Env base.** `WorkloadSpec.Env` on podman is set on the container and thus visible to execs; on the microVM backend the same base env arrives via `Provision.base_env` and guestd merges it under each exec's own `env` map (exec-specific keys win). Host-side assembly stays deterministic exactly as @@ -278,17 +278,17 @@ the wire, so determinism matters only for logging/tests. ### (c) `MicroVMRuntime` methods against the vsock service -The nine frozen signatures (`microvm.go:71-116`, `var _ ContainerRuntime = +The nine frozen signatures (`microvm.go:71-116`, `var _ WorkloadRuntime = (*MicroVMRuntime)(nil)`) are filled by translating each verb onto V2a's harness + the (a) service. `MicroVMRuntime` grows a per-session state table -(`ContainerID → *session`), where a `session` holds the V2a `BootConfig` +(`WorkloadID → *session`), where a `session` holds the V2a `BootConfig` (`go/internal/runtime/microvm/config.go:21-35`), the running `*microvm.VM` handle, the `GuestControl` client, and the runtime dir. The `microvm` package "depends on nothing in go/internal/runtime, so importing it there introduces no cycle" (`config.go:5-7`) — V2b is the planned importer. -- **`Create(ctx, ContainerSpec) (ContainerID, error)`** allocates without - booting (mirroring `podman create`): mint a session id (the `ContainerID` — +- **`Create(ctx, WorkloadSpec) (WorkloadID, error)`** allocates without + booting (mirroring `podman create`): mint a session id (the `WorkloadID` — there is no engine to print one, so the backend generates a random hex id and derives the runtime dir from it), create the per-session runtime dir (`/microvm//` — the layout V7 formalizes with pidfiles, @@ -553,7 +553,7 @@ execution, stdio, networking, and observability, plus a `rustjail` embedded OCI runtime); V2b is one exec session per VM (~4 RPCs), so adopting the agent would mean importing an order of magnitude more surface than the design needs, against the "guest supervisor is a thin exec supervisor" non-goal. (4) *Host -interface* — the acceptance bar is our frozen `runtime.ContainerRuntime` +interface* — the acceptance bar is our frozen `runtime.WorkloadRuntime` (`microvm.go:71-116`); no external agent implements it, so the host-side translation layer §(c) is ours regardless. The prior art proves the shape and the correctness model; the code stays a reference. @@ -563,7 +563,7 @@ the correctness model; the code stays a reference. Every task below inherits these; they restate the parent's binding decisions in V2b-concrete form. -- **The `ContainerRuntime` contract is the acceptance bar.** Every filled +- **The `WorkloadRuntime` contract is the acceptance bar.** Every filled method behaves identically to `PodmanCLI` through the interface (`podman.go:303-352`), specifically: a non-zero exec exit is a successful call returning `ExecOutput.ExitCode`, never an error (`podman.go:310-313`); @@ -787,7 +787,7 @@ U3, graceful Stop, idempotent Remove, `Exists` from the session table, (the per-session dir root), `DefaultCPUs int`, `DefaultMemoryMB int` — flagged OQ-D. Consumes U2 (guest behavior), U3 (`GuestExec`), the V2a harness (`Launch`/`Shutdown`/`BootConfig`, `launch.go`, `config.go`), and - `ContainerSpec`/`ExecSpec`/`StreamingExecSpec` unchanged. + `WorkloadSpec`/`ExecSpec`/`StreamingExecSpec` unchanged. - **Test cycle:** hardware-independent: spec→BootConfig assembly (paths, CID/ port allocation, mount→FSSharedDir, refusal of inexpressible specs per OQ-C), spec→ExecRequest mapping incl. numeric-uid parsing and env merge, @@ -801,14 +801,14 @@ U3, graceful Stop, idempotent Remove, `Exists` from the session table, exits before the kill escalation — proving the graceful preamble is not dead weight that always burns the full timeout. -### U5 — the shared `ContainerRuntime` contract suite +### U5 — the shared `WorkloadRuntime` contract suite The parent's V2b acceptance (microvm-runner.md:485-490): one table-driven suite asserting `MicroVMRuntime` and `PodmanCLI` behave identically through the interface, run against both backends. - **Interfaces:** produces `go/internal/runtime/contract_test.go`-class - shared suite parameterized over a `ContainerRuntime` factory; the podman + shared suite parameterized over a `WorkloadRuntime` factory; the podman rows gate on rootless podman availability (the existing suite's pattern), the microVM rows on `microvmtest.Require` (`microvmtest.go:107-128`). Consumes U4 and the existing `PodmanCLI`. @@ -848,7 +848,7 @@ the interface, run against both backends. - [ ] U4 — `MicroVMRuntime` lifecycle: Create/Start/Exec/ExecStreaming/ Stop/Remove/Exists/MountLabel behind the frozen signatures (Exists + dup-name Create keyed on `spec.Name`) -- [ ] U5 — shared ContainerRuntime contract suite (podman + microVM rows; +- [ ] U5 — shared WorkloadRuntime contract suite (podman + microVM rows; microVM rows KVM-gated) + Q-budget numbers ## Open Questions @@ -889,9 +889,9 @@ The non-load-bearing OQ-E/OQ-F stand at their recommendations. sketch, forced by buf lint regardless, not a contradiction of a decision. - **OQ-C (load-bearing) — mount expressiveness in V2b, and who owns the real mount shapes.** podman accepts arbitrary bind mounts - (`ContainerSpec.Mounts`, `podman.go:100-103`); the microVM backend has + (`WorkloadSpec.Mounts`, `podman.go:100-103`); the microVM backend has exactly one virtio-fs share in the V2a harness (the `workspace` tag, - `config.go:29-31`). The current producer of `ContainerSpec.Mounts` is + `config.go:29-31`). The current producer of `WorkloadSpec.Mounts` is `agentHost.Provision`, which on EVERY launch unconditionally appends two mounts — the gateway socket (`host.go:177`) and the read-only agent-config tree (`host.go:193`) — plus any operator `SpecDefaults.Mounts` (`spec.go:30`). @@ -958,7 +958,7 @@ The non-load-bearing OQ-E/OQ-F stand at their recommendations. both backends' Wait errors satisfy it (podman by the fallback, microVM by constructing it in `waitFunc`). ~10 host-side lines, guarded by a podman-row regression test, owned by U3b. This widens one runner-side symbol above the - `ContainerRuntime` interface — sanctioned because the prototype holds no + `WorkloadRuntime` interface — sanctioned because the prototype holds no interface immutable; the alternative (leave `isDeliberateKill` alone and accept that microVM Stop cannot distinguish deliberate kill from crash) was rejected as it breaks the crash-vs-stop signal D4 depends on @@ -974,7 +974,7 @@ teardown, D4's supervisor split) and resolves them within the parent's decisions. Two resolutions are genuinely new cross-record calls: OQ-A's no-credential-plus-boot-nonce auth resolution of the proto header's flagged question, and OQ-G's shared deliberate-kill error taxonomy on the -`ContainerRuntime` surface. Both are candidates for DL rows if Matt wants them +`WorkloadRuntime` surface. Both are candidates for DL rows if Matt wants them citable outside this record's lineage; both bind surfaces this record and its parent own (the GuestControl transport; the runtime error contract), so the recommendation is to keep them here. The caller owns the ledger delta at PR diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md index 209fa26fd..e7ad19853 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v3-egress-in-guest.md @@ -36,22 +36,22 @@ The load-bearing arm-routing fork is (a)-(c); every resolution is also listed in `## Open Questions` for the pre-freeze batch, and the body designs against the recommended option. -### (a) How `NftScript()` reaches the backend: `ContainerSpec.Egress` +### (a) How `NftScript()` reaches the backend: `WorkloadSpec.Egress` Today the policy stops at the `AgentRuntime` layer: `AgentSpec.Egress` (`go/internal/runtime/agent.go:40-42`) is consumed only by `AgentRuntime.armEgress`, which execs the script into the running container -(`agent.go:303-309`). `ContainerSpec` — the only thing a `ContainerRuntime` +(`agent.go:303-309`). `WorkloadSpec` — the only thing a `WorkloadRuntime` backend ever sees (`go/internal/runtime/podman.go:88-114`) — carries no egress field. The V2b record already recorded this exact gap as V3's inheritance: -"the intended data path is `ContainerSpec` growing an egress field captured at +"the intended data path is `WorkloadSpec` growing an egress field captured at Create and delivered by Start's Provision call" (microvm-v2b-guest-supervisor-exec.md:214-226). -**Resolution: `ContainerSpec` grows `Egress EgressPolicy`.** +**Resolution: `WorkloadSpec` grows `Egress EgressPolicy`.** - `AgentRuntime.createAndStart` sets it from `spec.Egress` when assembling the - `ContainerSpec` (`agent.go:262-272`). + `WorkloadSpec` (`agent.go:262-272`). - `PodmanCLI` **ignores** the field entirely: `createArgs` is untouched, so the podman argv — and the whole podman path — stays byte-identical. Podman keeps arming via the post-start `armEgress` exec as before ((c)). @@ -86,7 +86,7 @@ Two candidate owners for issuing the arm: transactional step (`microvm_lifecycle.go:269-310`); V3 adds `NftScript: session.nftScript` to that same request. One RPC provisions *and* arms; the gate opens only when both succeed. - - Pro: the `ContainerRuntime` contract identity holds — on podman, + - Pro: the `WorkloadRuntime` contract identity holds — on podman, `Start` then `Exec` works with no intermediate call, and the V2b contract suite asserts exactly that identity on both backends (contract_microvm_test.go:5-9, microvm_lifecycle_test.go's @@ -119,7 +119,7 @@ would **fail** (nft as a capability-less uid), failing every microVM provision. It must not run on this backend. Three candidates: -- **Option A (rejected): grow `ContainerRuntime` with an +- **Option A (rejected): grow `WorkloadRuntime` with an `ArmEgress(ctx, id, EgressPolicy) error` verb** (podman impl = today's exec moved verbatim; microVM impl = no-op). Clean in the abstract, but it violates the interface's freeze discipline — the surface was deliberately @@ -138,7 +138,7 @@ must not run on this backend. Three candidates: - **Option C (recommended): a backend capability probe in `AgentRuntime.provision`.** `MicroVMRuntime` gains one exported marker method, `EgressArmedInGuest() bool` (returns true), NOT on the - `ContainerRuntime` interface. `AgentRuntime.provision` type-asserts an + `WorkloadRuntime` interface. `AgentRuntime.provision` type-asserts an unexported single-method interface and skips `armEgress` when the backend self-arms: @@ -146,7 +146,7 @@ must not run on this backend. Three candidates: // in agent.go type inGuestEgressArmer interface{ EgressArmedInGuest() bool } - func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { + func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentSpec) error { if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err @@ -228,8 +228,8 @@ is the full default-drop base ruleset with an empty allowlist (`egress.go:29-34,109-115`) — there is no "no policy" representation. `MicroVMRuntime.Start` therefore **always** sends `session.nftScript` (never empty for a session created through -`ContainerSpec`), and every microVM session boots default-deny even when a -direct `ContainerRuntime` caller never set `Egress`. That is a deliberate +`WorkloadSpec`), and every microVM session boots default-deny even when a +direct `WorkloadRuntime` caller never set `Egress`. That is a deliberate divergence from podman, where a caller that skips `armEgress` gets an unfirewalled container: on this backend a silent open-egress VM is structurally impossible, which is the stronger reading of the parent's @@ -290,14 +290,14 @@ Every task below inherits these. stays ignored on this backend (`microvm_lifecycle.go:140-143`). - **The podman path is byte-identical.** No change to `createArgs`, to `armEgress`'s exec (`agent.go:300-309`), or to any podman argv; `PodmanCLI` - ignores `ContainerSpec.Egress` and does not implement the (c) probe. The + ignores `WorkloadSpec.Egress` and does not implement the (c) probe. The existing podman suites run unchanged. - **`EgressPolicy`/`NftScript()` consumed unchanged** (`egress.go:71-107`) — same script on both backends, per the parent's V3 Interfaces (microvm-runner.md:498-502). - **No proto wire change.** Doc-comment updates only (§(f)); `buf lint` + `buf breaking` green; internal-go lane only. -- **Frozen `ContainerRuntime` interface untouched.** The (c) probe is a marker +- **Frozen `WorkloadRuntime` interface untouched.** The (c) probe is a marker method on `MicroVMRuntime` + an unexported assertion in `AgentRuntime`, never an interface verb (`podman.go:379-388` discipline). - **KVM-gated vs hermetic split** (V2b GC, microvm-v2b-guest-supervisor-exec.md: @@ -362,11 +362,11 @@ and the `Provision` handler comment (`supervisor.go:137-140`). ### W2 — host: thread `spec.Egress` to `ProvisionRequest.nft_script`; probe-and-skip `armEgress` -The §(a)+(c) host half: `ContainerSpec.Egress`, the session capture, the +The §(a)+(c) host half: `WorkloadSpec.Egress`, the session capture, the Start-intrinsic delivery, the `AgentRuntime` probe. - **Interfaces:** produces - - `ContainerSpec.Egress EgressPolicy` (new field, `podman.go:88-114`; + - `WorkloadSpec.Egress EgressPolicy` (new field, `podman.go:88-114`; doc-comment states podman ignores it — the podman arm rides `AgentRuntime.armEgress`); - `microvmSession.nftScript string` recorded in `MicroVMRuntime.Create` as @@ -419,19 +419,19 @@ Start-intrinsic delivery, the `AgentRuntime` probe. independent hermetic proof of the script-delivery + fail-Start contract, so the seams are preferred; - `func (m *MicroVMRuntime) EgressArmedInGuest() bool { return true }` - (marker, NOT on `ContainerRuntime`); + (marker, NOT on `WorkloadRuntime`); - the unexported probe in `agent.go`: `type inGuestEgressArmer interface{ EgressArmedInGuest() bool }`, checked at the top of `AgentRuntime.provision` (`agent.go:290-293`) to skip `armEgress` when satisfied; `armEgress` itself unchanged (`agent.go:300-309`); plus a one-line pointer comment beside the - `ContainerRuntime` freeze note (`podman.go:379-388`) naming - `inGuestEgressArmer`, so a future backend — or a `ContainerRuntime` + `WorkloadRuntime` freeze note (`podman.go:379-388`) naming + `inGuestEgressArmer`, so a future backend — or a `WorkloadRuntime` decorator, which would otherwise swallow the marker and silently re-enable `armEgress` on the microVM backend (a loud but hard-to-diagnose launch failure) — discovers the probe; - `AgentRuntime.createAndStart` setting `Egress: spec.Egress` in the - `ContainerSpec` literal (`agent.go:263-272`). + `WorkloadSpec` literal (`agent.go:263-272`). Consumes `EgressPolicy`/`NftScript()` unchanged. - **Test cycle (hermetic):** (1) a fake runtime WITHOUT the marker still receives the `armEgress` exec (existing @@ -464,7 +464,7 @@ opening with `microvmtest.Require(t)`. pattern) — no new production code. Produces the KVM-gated test files only. - **Test cycle (KVM-gated):** 1. **Allowlisted reachable / non-allowlisted blocked, both families:** boot - a session whose `ContainerSpec.Egress` allowlists one real host; in-guest + a session whose `WorkloadSpec.Egress` allowlists one real host; in-guest execs (agent uid) show the allowlisted host connects and a non-allowlisted raw IPv4 and IPv6 destination time out — mirroring the podman lifecycle proof (lifecycle_test.go:137-140) inside the guest @@ -494,7 +494,7 @@ opening with `microvmtest.Require(t)`. - [ ] W1 — guestd `Provision` arms `nft_script` as guest root (replaces `CodeUnimplemented`), fail-closed, gate stays closed on failure -- [ ] W2 — `ContainerSpec.Egress` threaded Create→Start→`ProvisionRequest`; +- [ ] W2 — `WorkloadSpec.Egress` threaded Create→Start→`ProvisionRequest`; `AgentRuntime.provision` probe-and-skips `armEgress` on self-arming backends (podman path byte-identical) - [ ] W3 — KVM-gated in-guest egress integration suite (allow/deny both @@ -519,14 +519,14 @@ recommendation. because the parent record is frozen and the literal routing differs. **Recommendation:** ratify Start-intrinsic arming as the correct reading. - **OQ-2 (load-bearing) — the (c) probe mechanism.** Marker-method probe - (recommended, §(c) Option C) vs growing the frozen `ContainerRuntime` + (recommended, §(c) Option C) vs growing the frozen `WorkloadRuntime` interface (Option A). The probe keeps the interface frozen and the blast radius at one call site; the interface verb is the more discoverable shape but contradicts the S1 no-interface-change discipline (`podman.go:379-388`) and touches every fake. **Recommendation:** Option C. - **OQ-3 (load-bearing) — always-arm on the microVM backend (§(e)).** Every microVM Start arms at least default-deny, including direct - `ContainerRuntime` callers (the KVM contract/e2e suites), a conceded + `WorkloadRuntime` callers (the KVM contract/e2e suites), a conceded divergence (7) from podman. Risk: an existing KVM row that needs external egress would start failing — believed none (exec traffic is loopback/vsock), verified on hardware by W3(4) before freeze is exercised. diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md index 1e009c493..5977bc506 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v4-gateway-over-vsock.md @@ -178,7 +178,7 @@ microvm-v3-egress-in-guest.md:138-163): - `MicroVMRuntime` gains one exported method, `AgentGatewayEndpoint(name string) (socketPath string, ok bool)`, returning `microvm.GatewaySocketPath(session.cfg.VsockSocket, agentGatewayVsockPort)` - for the named session — NOT on the frozen `ContainerRuntime` interface. + for the named session — NOT on the frozen `WorkloadRuntime` interface. - `agentHost` probes its engine via an unexported single-method interface, `type vsockGatewayEngine interface { AgentGatewayEndpoint(string) (string, bool) }`. - Probe **absent** (podman, every fake): today's path byte-identical — @@ -384,7 +384,7 @@ Every task below inherits these. mount list Provision builds on podman, `AGENT_SOCKET_PATH`, or any podman argv; fakes don't implement the (c) probe, so every existing hermetic runner suite runs unchanged. -- **Frozen `ContainerRuntime` interface untouched.** The (c) probe is a +- **Frozen `WorkloadRuntime` interface untouched.** The (c) probe is a marker/endpoint method on `MicroVMRuntime` + an unexported assertion in `agentHost`, never an interface verb — the V3-ratified discipline (microvm-v3-egress-in-guest.md:300-302). @@ -473,7 +473,7 @@ byte-identical. - `func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool)` — resolves the session by `name` (the `Exists` lookup shape) and returns `GatewaySocketPath(session.cfg.VsockSocket, agentGatewayVsockPort)`; - `(“”, false)` for an unknown name. NOT on `ContainerRuntime`; + `(“”, false)` for an unknown name. NOT on `WorkloadRuntime`; - in `go/internal/runner/host.go`: `type vsockGatewayEngine interface { AgentGatewayEndpoint(string) (string, bool) }`, asserted on `h.engine` at the top of `Provision` diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md index d27b70105..408595282 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v5-preflight-boot-canary.md @@ -86,20 +86,20 @@ broken engine isn't sent chasing tokens first): *per selected backend*; 4. everything else unchanged. -**How main picks the preflight.** The frozen `ContainerRuntime` interface +**How main picks the preflight.** The frozen `WorkloadRuntime` interface gains nothing (the V3/V4-ratified discipline: capability probes are unexported single-method interface assertions, never interface verbs — microvm-v4-gateway-over-vsock.md § Global Constraints, "Frozen -`ContainerRuntime` interface untouched"). `main.go` grows one extracted, +`WorkloadRuntime` interface untouched"). `main.go` grows one extracted, hermetically testable helper: ```go // verifyBackendPreflight runs the selected engine's startup preflight: the // podman userns-remap check iff the engine is the podman backend, the microVM // support check iff it is the microVM backend. Probed via unexported -// single-method interfaces so the frozen ContainerRuntime interface is +// single-method interfaces so the frozen WorkloadRuntime interface is // untouched and a test fake can present either capability. -func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error +func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error ``` with two unexported probe interfaces in `package main`: @@ -326,7 +326,7 @@ Two candidate shapes for the canary's boot path: - **Teardown is Remove's, not a second copy.** Start already tears down its own partial boot on any failure (`microvm_lifecycle.go:374-383`), and Remove is the idempotent teardown; the canary adds no new cleanup path. - Mechanics: a canary `ContainerSpec` with a reserved name + Mechanics: a canary `WorkloadSpec` with a reserved name (`compass-canary-<8-hex random>` — outside the agent-session prefix so it cannot collide with `runner.AgentContainerNamePrefix` sessions), a **single throwaway workspace mount** — a freshly `os.MkdirTemp`'d host dir mounted @@ -477,7 +477,7 @@ Every task below inherits these. today's `VerifyUsernsRemapSupport` call with today's semantics; no podman argv, check, or message changes. Existing hermetic runner suites run unchanged. -- **Frozen `ContainerRuntime` interface untouched.** Preflight and canary are +- **Frozen `WorkloadRuntime` interface untouched.** Preflight and canary are `*MicroVMRuntime` methods reached from `main` via unexported single-method probe interfaces — the V3/V4-ratified discipline (microvm-v4-gateway-over-vsock.md § Global Constraints). @@ -569,7 +569,7 @@ added by W3. - the reorder: `backends.selectEngine()` moved ahead of the preflight (currently `main.go:153` and `main.go:94-104` respectively); the legibility comment rewritten for the per-backend contract; - - `func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error` + - `func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error` in `package main`, with unexported probes `type microVMPreflighter interface { VerifyMicroVMSupport(context.Context) error }` and `type podmanPreflighter interface { VerifyUsernsRemapSupport(context.Context) error }`; diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md index bbb488cca..10e90c6d1 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v7-teardown-recovery-observability.md @@ -622,7 +622,7 @@ package's timeout onto `*runtime.TimeoutError` at this exact seam, // because the session's VM died mid-session — the VMM exited or virtiofsd // died (fatal, no remount-and-hope; microvm-runner.md:249-253). type SessionDeadError struct { - ID ContainerID + ID WorkloadID Cause string // "vmm" | "virtiofsd" } @@ -1089,7 +1089,7 @@ Every task below inherits these. message changes; the one shared touchpoint (the `backend`-labelled session counter in the Runner host) reads the backend name through an unexported probe and changes no podman behavior. -- **Frozen `ContainerRuntime` interface untouched.** `ReapOrphans` and the +- **Frozen `WorkloadRuntime` interface untouched.** `ReapOrphans` and the backend-name probe are `*MicroVMRuntime` methods / unexported interface assertions — the V3/V4-ratified single-method-probe discipline (`main.go:185-203`). @@ -1295,7 +1295,7 @@ assertion; see the Plan preamble). - `func (vm *VM) VMMExited() bool` — nil-safe delegation to the VMM child's `hasExited()` (PR #912 `launch.go:96-107`; a channel read, not a zombie-blind signal-0 probe), for `Exec`'s in-flight wrap (§(c)); - - `type SessionDeadError struct { ID ContainerID; Cause string }` with + - `type SessionDeadError struct { ID WorkloadID; Cause string }` with `Error()` in `go/internal/runtime` (untagged file, beside `CommandError`); - `microvmSession.deadCause` + `microvmSession.deadEpoch` + @@ -1475,13 +1475,13 @@ The §(d) main.go ordering fix and the `backend`-labelled session metric. ```go type startupHooks struct { setupOtel func(ctx context.Context) (func(), error) - selectEngine func() (runtime.ContainerRuntime, error) - preflight func(ctx context.Context, engine runtime.ContainerRuntime) error - lockRunRoot func(engine runtime.ContainerRuntime) (runtime.RunRootLock, func(), error) - reap func(ctx context.Context, engine runtime.ContainerRuntime, held runtime.RunRootLock) error + selectEngine func() (runtime.WorkloadRuntime, error) + preflight func(ctx context.Context, engine runtime.WorkloadRuntime) error + lockRunRoot func(engine runtime.WorkloadRuntime) (runtime.RunRootLock, func(), error) + reap func(ctx context.Context, engine runtime.WorkloadRuntime, held runtime.RunRootLock) error } - func startup(ctx context.Context, h startupHooks) (runtime.ContainerRuntime, func(), error) + func startup(ctx context.Context, h startupHooks) (runtime.WorkloadRuntime, func(), error) ``` which calls them in the order `setupOtel` → `selectEngine` → diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md index af739a144..bd205d5e5 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v8-acceptance-suite-benchmarks.md @@ -54,7 +54,7 @@ Three gaps V8 closes: guest). 2. **The contract and failure-mode proofs stop below the session lifecycle.** - The shared `ContainerRuntime` contract suite runs on the microVM backend + The shared `WorkloadRuntime` contract suite runs on the microVM backend (`contract_microvm_test.go:34-69`), but the S1-frozen seam contract the parent's design demands — "a session and a burst both boot on the microVM runtime and pass the S1/C3 contract tests unchanged" (design.md:596-597) @@ -165,7 +165,7 @@ assembled-backend layer; cycles marked *new* have no existing coverage. | --- | --- | --- | --- | | 1 | Inter-tenant probe (volume, vsock, host fs, host metadata/net) | *escalate + new* — PR #912 already boots two sessions for the volume surface (`microvm_isolation_microvm_test.go:391-395`, `TestMicroVMCrossSessionVolumeUnreachable`) and confines a single session's traversal (`:302-304`); net-new are the host-network legs and the vsock leg (OQ-8) | W1 | | 2 | Egress fail-closed inside the guest netns | *escalate* — `egress_inguest_microvm_test.go:37-42` already runs under the full backend and names itself V8 row (2) | W3 | -| 3 | S1 contract tests pass unchanged | *escalate* — `contract_microvm_test.go:34-69` covers `ContainerRuntime`; the `AgentRuntime.Launch` layer is podman-only (`lifecycle_test.go:1`) | W3 | +| 3 | S1 contract tests pass unchanged | *escalate* — `contract_microvm_test.go:34-69` covers `WorkloadRuntime`; the `AgentRuntime.Launch` layer is podman-only (`lifecycle_test.go:1`) | W3 | | 4 | Boot timeout killed + cleaned | *escalate + new* — `microvm_lifecycle_microvm_test.go:62-118` proves the corrupt-rootfs deadline is **fail-closed** (Start errors `:98-100`, no exec client `:108-110`, runtime dir removable `:111-116`); it asserts NOTHING about processes — "no orphan processes" is doc-comment text only (`:57-61`), read by no assertion. V8's delta is therefore the orphan-freedom assertion *itself*, pidfile-identity-verified, plus the caller-deadline cancel leg | W4 | | 5 | Mid-session VMM death under the session lifecycle | *new* at this layer — V7 (PR #931 §(c)) designs runtime-layer detection; gateway streams above it are unproven | W4 | | 6 | KVM-absent hard-fail (D3) | *escalate* — `microvm_preflight_test.go:82-88` unit-tests the axis; no acceptance-level assertion of the capability-naming error text | W5 | @@ -482,7 +482,7 @@ the CID-1 dial complete an HTTP exchange (exit 0) and the test MUST go red. mutations). W3 also adds the one missing leg: the same allow/deny probe pair run through `AgentRuntime.Launch`-provisioned sessions rather than direct `Create`/`Start` calls. -- **S1 contract (cycle 3).** The `ContainerRuntime` contract suite already +- **S1 contract (cycle 3).** The `WorkloadRuntime` contract suite already runs with every divergence cap ON (`contract_microvm_test.go:47-55`). The missing layer is `AgentRuntime`: the podman lifecycle e2e (`lifecycle_test.go:5-16` — create, checkout-dir ownership, uid-1000 exec, @@ -1032,8 +1032,8 @@ delta is the one-runtime topology and the symlink-in-A's-volume shape; its net-new content is the host-network leg and the vsock leg (OQ-8). - **Interfaces:** consumes `NewMicroVMRuntime(cfg MicroVMConfig) - *MicroVMRuntime`, the `ContainerRuntime` verbs - (`Create(ctx, ContainerSpec) (ContainerID, error)`, `Start`, `Exec`, + *MicroVMRuntime`, the `WorkloadRuntime` verbs + (`Create(ctx, WorkloadSpec) (WorkloadID, error)`, `Start`, `Exec`, `Remove`), `e2eConfig(t, env) MicroVMConfig` (`microvm_lifecycle_microvm_test.go:33-55`), and PR #912's isolation helpers including `TestMicroVMCrossSessionVolumeUnreachable`'s session @@ -1908,7 +1908,7 @@ Extends the existing `microvm` job (`ci.yml:624-850`) per § Approach (h). construction (`go/internal/delivery/trace_test.go:209-220`). Watch cardinality: no per-session attributes in any assertion helper. - **No production-code changes.** V8's Go deliverables are test files, test - data, and CI workflow edits; the frozen `ContainerRuntime` interface and + data, and CI workflow edits; the frozen `WorkloadRuntime` interface and all backend behavior are untouched. Where a proof requires a mutation, the mutation is transient (local build), recorded in the PR description, and never merged. diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md index f4858ae51..62bb97548 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/p2-persistent-session-volume.md @@ -306,7 +306,7 @@ flow under volume-backed: resolve-or-create volume → `Attach` → `Materialize writable session volume at P2)". This is a **doc-comment change, not a shape change**: `Mount.ReadOnly` is already a per-mount bool (`podman.go:62-66`) and the `:ro` suffix is already conditional -(`podman.go:846-850`). The downstream `ContainerSpec.Mounts` already documents +(`podman.go:846-850`). The downstream `WorkloadSpec.Mounts` already documents a **read-write** bind mount in the shipped tree — "Not all read-only … the per-container agent gateway socket is mounted read-write (the agent must connect() to it)" (`podman.go:100-103`) — so a writable mount at the layer the @@ -582,7 +582,7 @@ The package skeleton mirrors `go/internal/compute`'s layering byte-identical (GC 8). Under `SourceVolume`, `ensureCheckoutDir` still runs (idempotent `mkdir -p` on the mounted path, same uid-ownership intent, `agent.go:354-358`). The `AgentSpec.Mounts` doc comment is amended - per P2-GC-a. No `ContainerRuntime` change (the interface stays frozen, + per P2-GC-a. No `WorkloadRuntime` change (the interface stays frozen, `podman.go:399-403`). - **Depends:** W1 (the mount it documents); parallel with W3. - **Test cycle:** existing launch-path regression suite green with zero-value diff --git a/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md b/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md index 39322b5cd..3e6295cd0 100644 --- a/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md +++ b/docs/designs/infra/runtime/compass-elastic-session-runtime/virtualfs-descope-amendment.md @@ -20,7 +20,7 @@ Amends: RIG-1717 elastic session runtime record (PR #446) The frozen record's task **S1** lists the `vfs.VirtualFS` source-of-tree seam (interface + git-checkout backend + provision wiring) as a deliverable -alongside the `compute.ComputeRuntime` seam and the `ContainerRuntime.Resize` +alongside the `compute.ComputeRuntime` seam and the `WorkloadRuntime.Resize` freeze. During S1 execution, building `VirtualFS` surfaced that the seam has **no production caller at S1** and quietly bakes in an unsettled architectural decision. This amendment descopes `VirtualFS` from S1 to **P2**, where it @@ -74,7 +74,7 @@ Three findings drive the descope: **What S1 ships instead (unchanged by this amendment):** the `compute.ComputeRuntime` seam + its in-environment passthrough backend + the fail-closed routing-policy shell (`go/internal/compute`, PR #457), and the -additively-reserved `ContainerRuntime.Resize` verb + `ResourceLimits` +additively-reserved `WorkloadRuntime.Resize` verb + `ResourceLimits` (`go/internal/runtime`, PR #454). These are the two seams with teeth now and carry no clone/credential entanglement. The agent-self-clone-in-container model is left untouched (Global Constraint 8: the existing session path stays green; @@ -103,7 +103,7 @@ new code task — S1 shrinks, P2 grows. ### S1 (RIG-2393) — remove the `VirtualFS` deliverable -- S1's deliverables are **`ContainerRuntime.Resize` freeze** (PR #454) and +- S1's deliverables are **`WorkloadRuntime.Resize` freeze** (PR #454) and **`compute.ComputeRuntime`** seam + in-place backend + fail-closed routing (PR #457). The `vfs.VirtualFS` seam, its git-checkout backend, the `WorkspaceSource` variant, and the provision-materialize wiring are **removed diff --git a/docs/designs/infra/runtime/compass-host-runtime-tier/design.md b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md new file mode 100644 index 000000000..a35991a5e --- /dev/null +++ b/docs/designs/infra/runtime/compass-host-runtime-tier/design.md @@ -0,0 +1,846 @@ +# Compass host runtime tier + +Status: Draft +Tracking: RIG-3512 +Owner: compass-runner (runtime) → compass-agent (onboarding review) + +## Problem / Intent + +A new user's adoption cost is dominated by getting their existing skills, +tools, and secrets reachable by a Compass agent. Today the lowest tier is +podman (DL-325), which still asks the user to project their environment into a +container before the first useful session. Meanwhile the agent they already run +— a CLI agent on their own machine — has all of it for free. This record adds a +**host tier**: a `host` backend that runs the agent as a plain host process +with the same access as the CLI agent the user already runs, making replication +of their existing setup near-zero-setup. The ruling: the tier is worth it even +if some users never graduate, because the counterfactual is the user staying on +their current CLI agent at the same host-exposure posture — a host-tier Compass +user is strictly better off. + +The second half of the same onboarding story is the imported corpus itself: the +transport (`compass agent-config push --dir`) exists, but a user importing +their existing skills/rules cannot tell what overlaps or is superseded by +Compass's built-ins. A silently-shadowed skill looks like it worked — the worst +onboarding failure. This record designs an **agent-driven config-import +review** as part of the first-run flow, and the host tier is what makes it +land: the agent is already sitting where the user's config lives. + +## Approach + +### Half A — the `host` backend + +#### Placement + +The host tier is a third `SelectBackend` value, joining `""`/`podman`/ +`microvm`. `SelectBackend` is the sole backend selection point +(`go/internal/runtime/microvm.go:117-125`): + +```go +func SelectBackend(cfg BackendConfig) (ContainerRuntime, error) { + switch strings.TrimSpace(cfg.Backend) { + case "", "podman": + return NewPodmanCLI(), nil + case "microvm": + return NewMicroVMRuntime(cfg.MicroVM), nil + default: + return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default) and \"microvm\"", cfg.Backend) + } +} +``` + +A `case "host"` arm returns the new backend and the error string extends to +name the third accepted value. The default stays podman: `host` is opted into +explicitly, never fallen back to. + +On the DL-325 trust-model axis +(`docs/designs/DECISIONS.md:158`: "untrusted multi-tenant operation requires +the microVM hardware boundary (KVM, unchanged); self-host single-tenant +deployments keep podman as a permanent, supported entry tier"), the host tier +sits **below** podman. DL-325's rule is that **the boundary follows the trust +model, not the deployment shape** — so the host tier is scoped by *whose +machine and whose trust*, not by which product a user bought. + +The agent runs on the operator's own machine, under their own uid, on work +they already trust themselves with. That is a **single-trust-domain** tier: +the operator is the only principal, so there is no boundary for the tier to +enforce. It is never a valid backend for **untrusted** work or for isolating +**mutually-distrusting** principals from each other — a shared kernel and a +shared `$HOME` cannot separate parties, whatever the deployment shape. + +Nothing in that scoping is about the *number of tenants a deployment serves*. +A user still has their own machine whatever shape their Server runs in, and +running an agent on it — for onboarding, or for a task that genuinely needs +that box (below) — puts exactly one trust domain on the host: theirs. So the +tier is available to any user running an agent on their own machine, and the +deployment topology their Server sits in does not change the analysis. +Applying the tier to *someone else's* work, on a machine serving more than one +principal, is what DL-325 forbids — and that is a property of the trust +domain, not of the product. + +Guidance stays "prefer container/microVM" — the docs recommend graduating — +but the tier is not gated or crippled to force it. + +The tier's second motivation is host capability the container cannot provide +at all: workflows that need the user's real session bus, display, or +device access (e.g. window-management tooling driving the live desktop +session). This is not onboarding scaffolding — it is a permanent capability, +and it is the same need whichever deployment a user's Server belongs to: the +work has to run where the hardware and the session are. See Open Questions. + +#### `ContainerRuntime` implementation + +`ContainerRuntime` is frozen at S1 (`go/internal/runtime/podman.go:348-396`; +the closing comment at `:399`: "ContainerRuntime is frozen (the Resize +reservation above)"). The host backend implements it; it does not amend it. +The nine methods, per the interface doc comments, and their host-process +semantics — including where the mapping is degenerate: + +| Method | Interface contract (quoted) | Host semantics | +| --- | --- | --- | +| `Create(ctx, spec) (ContainerID, error)` | "makes a container from spec without starting it, returning its id" (`podman.go:349-350`) | Allocates a per-agent **handle**: mints a synthetic `ContainerID`, creates the agent's private state dir (workspace root, home overlay dir, socket dir) from `ContainerSpec`. No process is spawned. Spec fields that configure container machinery (image, mounts as bind specs, network) are interpreted or ignored per a documented field map — see T1. | +| `Start(ctx, id)` | "starts a created container" (`podman.go:352-353`) | **Degenerate.** There is no init process to start; the agent process itself is launched later by `ExecStreaming`. `Start` transitions the handle `created → started` and validates the state dir. It must not be pretended to be more: a "started" host handle is bookkeeping, not a running boundary. | +| `Exec(ctx, id, spec) (ExecOutput, error)` | "runs a command in a running container, capturing its output. A non-zero exit is a successful runtime call returning a failed command" (`podman.go:355-359`) | Runs the command as a **direct host subprocess** of the Runner, under the Runner's own uid, with `ExecSpec`'s env/cwd/stdin and the per-command timeout. `ExecSpec.AsUser` cannot switch user — the process runs as whoever runs the Runner — so the backend honors exactly one value, the Runner's own effective uid, and rejects (errors on) an `AsUser` naming **any other** uid rather than silently running it wrong. That strict rejection is only launchable because the host tier derives `Workspace.UID` from `os.Geteuid()` instead of the baked fleet constant, so every provision-path `AsUser` already carries the euid it will run as — see "The host-tier uid contract" below. Without that derivation this rule would error on every provision exec and no host agent could launch. | +| `ExecStreaming(ctx, id, spec) (*StreamingExec, error)` | "starts a long-lived streaming command … returning its live stdio pipes plus a kill/wait handle" (`podman.go:361-369`) | The one clean mapping: spawns the agent as a host child process in its own process group, stdio piped, bound to ctx. This is where the host-tier agent actually comes to life. | +| `Stop(ctx, id, timeout)` | "stops a running container, allowing timeout for graceful exit" (`podman.go:371-373`) | Signals the handle's process group: SIGTERM, wait up to `timeout`, then SIGKILL. Scope is the process group the backend spawned — a host process the agent double-forked out of the group is **not reliably stopped**; that leak is named, not papered over (no cgroup freezer in v1; see Open Questions). | +| `Remove(ctx, id)` | "removes a container (force-kills if still running)" (`podman.go:375-376`) | Force-kills the process group if live, then deletes the handle's state dir. It does **not** touch anything outside the state dir — the agent's writes to the real host filesystem are permanent, which is the tier's declared posture, not a cleanup bug. | +| `Exists(ctx, name) (bool, error)` | "reports whether a container with name currently exists (any state)" (`podman.go:378-379`) | **Degenerate.** There is no container registry to consult; existence means "the backend has a handle (state dir) under this name". This is handle-existence plus process-liveness, not container-existence: a crashed agent whose state dir remains still `Exists`, mirroring a stopped-but-not-removed container. | +| `MountLabel(ctx, id) (string, error)` | "reports the container's SELinux mount label (its private MCS category), read from `podman inspect`" (`podman.go:381-385`) | **Degenerate: returns `""`, nil.** There is no container and no per-container MCS category. Empty is already a first-class value in the consumer: `ConfigMaterializer.Materialize` documents "mcsLabel is \"\" on the PROVISION path … there is no label to target … skip chcon" (`go/internal/runner/config_materialize.go:138-144`). The host tier extends that meaning: empty on **every** path, so the `chcon -R` relabel (`config_materialize.go:353-354`) never runs. Agent reads succeed because materialized files carry the Runner's own label and the agent **is** the Runner's uid. See "MCS/SELinux relabel gap" below. | +| `Resize(ctx, id, limits)` | "changes a live container's cgroup resource limits in place … the resize BEHAVIOR … is C3's to fill in behind this signature" (`podman.go:387-396`) | **Degenerate for v1.** The host backend owns no cgroup. It returns a typed "unsupported on host backend" error, never a silent success — a caller that believes it resized must not be lied to. A future systemd user-scope/cgroup v2 delegation could make this real; out of scope here. | + +The honest summary: `ExecStreaming`, `Exec`, `Stop`, `Remove` are real; +`Create`/`Start`/`Exists` are bookkeeping over a state dir; `MountLabel` and +`Resize` are degenerate by construction. The backend documents each degenerate +case at the method, in these terms. + +#### The host-tier uid contract + +The `Exec` row's strict `AsUser` rejection and the fleet's baked agent uid are +in direct conflict, and resolving it is a design obligation of this tier, not an +implementation detail. The fleet uid is a constant with no override: + +```go +// The const is untyped on purpose: it flows into the uint32 runtime.AgentSpec.UID +// field and into int comparisons (e.g. os.Getuid()) alike, with no conversions at +// the call sites. Keeping it in one importable package is the single source of +// truth for the agent-uid invariant across the runner command and the runtime +// package's proofs. +const AgentUID = 1000 +``` + +(`go/internal/agentuid/agentuid.go:8-13`.) The Runner hands exactly that into +its spec defaults (`go/cmd/compass-runner/main.go:153`: `UID: agentuid.AgentUID,`), +and `BuildSpec` copies it verbatim into every agent's workspace +(`go/internal/runner/spec.go:89-93`, dedented): + +```go +Workspace: runtime.Workspace{ + CheckoutDir: d.CheckoutDir, + HomeDir: d.HomeDir, + UID: d.UID, +}, +``` + +Nothing configures it. The Runner's whole flag block declares no uid flag or env +override (`go/cmd/compass-runner/main.go:44-84`, `run()`'s flag declarations +through `flag.Parse()`; the command's only other `uid` mentions are the podman +userns-remap preflight comment at `main.go:96-98`), and +`NewConfigSpecBuilder`'s sole check on the value is that it is not root +(`go/internal/runner/spec.go:59-60`): + +```go +if defaults.UID == 0 { + return nil, errors.New("spec defaults require a non-root uid") +} +``` + +That uid is then what every provision-path exec passes as `AsUser`: +`AgentRuntime.ExecAsAgent` (`go/internal/runtime/agent.go:203-204`: +`AsUser(strconv.FormatUint(uint64(handle.spec.Workspace.UID), 10))`), +`WriteAgentFile` (`agent.go:249-250`), `installCredentials` (`agent.go:343-344`), +`ensureCheckoutDir` (`agent.go:359-360`), the secrets materializer +(`go/internal/runtime/secrets_materialize.go:435-436`), and the agent's own +streaming exec (`go/internal/runner/agent_exec.go:78-79`: +`AsUser(strconv.FormatUint(uint64(e.UID), 10))`). + +**Ruling.** On the host tier `Workspace.UID` is derived from the Runner's real +effective uid — `os.Geteuid()` at Runner startup — and never from +`agentuid.AgentUID`. The fleet `SpecDefaults.UID` constant is **not usable on +this tier**: it names the uid baked into the agent *image*, which this tier does +not run, so a host Runner whose euid is not 1000 (the normal case, and the whole +premise of the tier) would fail every provision exec against a rule that is +otherwise correct. Deriving the uid makes the strict rejection both strict and +always-satisfied: the only value that ever reaches `AsUser` is the euid the +subprocess will run as anyway, and any other uid is a real caller bug that must +error. The rejection is **not** softened to accept-and-ignore — an `AsUser` +naming a different uid means the caller believes a user switch happened, and the +host backend cannot provide one. + +Two consequences the plan carries (T1a): + +- The host tier needs its own spec derivation. `SpecDefaults` is built once at + Runner startup (`main.go:148-156`), so the host profile supplies the derived + euid there rather than the constant; the existing non-root check + (`spec.go:59-60`) still applies, so a Runner running as root is refused — the + same posture the container tiers hold (`go/internal/runtime/workspace.go:51-53`: + "UID is the unprivileged uid the agent runs as. Never container-root — that + would let the agent tear down its own egress firewall"). +- `runtime.Workspace.UID` is a `uint32` (`workspace.go:53`) while `os.Geteuid()` + returns an `int`, and it returns `-1` on platforms without the syscall — so the + derivation validates the value before narrowing rather than converting blindly. + +#### Agent transport: the socket and config paths + +`ContainerRuntime` does not deliver the agent its gateway socket or its config; +the Runner's `Provision` does, by bind-mount, to two paths that are frozen +constants on **both** sides of the rendezvous. A host process has no bind +mounts, so this is the one part of the tier that no `ContainerRuntime` +implementation can supply — it needs its own Provision leg. + +Runner side (`go/internal/runner/host.go:33-38`, tabs expanded): + +```go +const ( + agentSocketDir = "containers" + agentSocketFile = "agent.sock" + agentSocketMountPath = "/run/compass/agent.sock" + agentConfigMountPath = "/run/compass/agent-config" +) +``` + +preceded by the comment that names the contract (`host.go:30-32`): +"`agentSocketMountPath` is the fixed in-container path the socket is +bind-mounted to, so the agent needs no per-session configuration — it always +dials the same path". Both are delivered as mounts on the podman provision leg — +`host.go:198`: `spec.Mounts = append(spec.Mounts, listener.Mount(agentSocketMountPath))` +and `host.go:214`: +`spec.Mounts = append(spec.Mounts, runtime.Mount{HostPath: mount.HostPath, ContainerPath: agentConfigMountPath, ReadOnly: true})`. + +Agent side, both paths are compile-time constants with no configuration input +(`packages/compass-agent/src/cli.ts:86-91`): + +```ts +/** + * The in-container path the Runner bind-mounts this agent's socket to. Fixed by + * contract with `internal/runner/host.go:33` — the agent takes no per-session + * socket configuration, so this constant IS the rendezvous. + */ +export const AGENT_SOCKET_PATH = "/run/compass/agent.sock"; +``` + +and (`packages/compass-agent/src/config-reader.ts:47-53`): + +```ts +/** + * The in-container path the Runner materializes the agent-config bundle to. + * Fixed by contract with the Runner's mount (design §CD-3) — the agent takes no + * per-session config location, so this constant IS the rendezvous. The agent + * reads through `/current`, the symlink the Runner flips. + */ +export const AGENT_CONFIG_MOUNT_PATH = "/run/compass/agent-config"; +``` + +Both are pinned by contract tests. `packages/compass-agent/src/cli.test.ts:113-116` +(tabs expanded): + +```ts +describe("AGENT_SOCKET_PATH", () => { + test("matches the Runner's fixed in-container mount path", () => { + expect(AGENT_SOCKET_PATH).toBe("/run/compass/agent.sock"); + }); +``` + +and `packages/compass-agent/src/config-reader.test.ts:67-70`, whose preceding +comment states why it is pinned (`config-reader.test.ts:63-66`) — "A drift is a +silent unconfigured boot, so it is pinned — beside `AGENT_SOCKET_PATH`'s +contract test": + +```ts +describe("AGENT_CONFIG_MOUNT_PATH", () => { + test("is the frozen /run/compass/agent-config contract path", () => { + expect(AGENT_CONFIG_MOUNT_PATH).toBe("/run/compass/agent-config"); + }); +``` + +So a host-tier agent launched with no transport design dials a literal +`/run/compass/agent.sock` that either does not exist (no gateway, dial timeout) +or — if the host backend created it for real — is machine-global and needs root +to bind, which makes it structurally un-per-agent and contradicts the tier's +one-state-dir-per-handle model. The config path fails worse: absent, it is a +**silent unconfigured boot**, exactly the drift the test above exists to catch. + +**Ruling.** The host tier gets its own `Provision` leg, beside the existing +podman and microVM legs, and the two agent-side constants become +env-overridable: + +- The leg serves the per-agent gateway socket inside the handle's own state dir + (the same 0700 per-agent dir `Create` mints), not under a machine-global + `/run/compass`, and materializes the config tree to a path in that dir. No + mounts are appended — there is nothing to mount into. +- It threads both paths to the agent as environment variables on the streaming + exec that starts it (`AgentEnv.execSpec`, `go/internal/runner/agent_exec.go:77-81`, + already the seam that sets `HOME`/`COMPASS_WORKDIR`). +- `AGENT_SOCKET_PATH` and `AGENT_CONFIG_MOUNT_PATH` become **env-overridable + with today's literals as defaults**, so an agent that receives no override + behaves byte-identically to today and the container tiers are untouched. The + two contract tests keep pinning the default; each gains a case asserting the + override path. `cli.ts` already carries the precedent for the config half — + `MainDeps.configMount` is documented as "Overridable ONLY so a test can point + the reader at a tempdir fixture instead of the container path" + (`cli.ts:586-590`) — this promotes that from a test-only dependency seam to a + first-class environment input, which is a change to the frozen contract and is + named as such. + +This is a change to the `compass-agent` package's frozen path contract and to +its two pinned contract tests. It is deliberate and scoped: the frozen value +stays the default, and only the host tier ever supplies an override. The +alternative (a Provision-side probe seam alone, mirroring `vsockGatewayEngine`) +is rejected in Alternatives considered — a Provision-side probe can only change +what the *Runner* does, and cannot change a path the agent resolves from a +constant with no configuration input. + +#### Egress: explicitly unenforced + +The container tiers arm a default-deny nftables firewall **in the container's +own network namespace** (`go/internal/runtime/egress.go:1-4`): + +> "Default-deny + allowlist egress firewall for an agent container … The +> container's own network namespace is firewalled with nftables, so a +> compromised agent can't exfiltrate to an arbitrary host" + +A host process has no private netns; that mechanism is structurally +unenforceable here. The ruling: host-tier egress is **explicitly unenforced** — +a first-class declared posture, not a degraded arm. + +Concretely, the host backend must **not** implement the `inGuestEgressArmer` +probe-and-skip seam. That seam exists so a backend that armed egress itself can +tell `AgentRuntime.provision` to skip the host-side arm exec +(`go/internal/runtime/agent.go:307-312`): + +```go +func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { + if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { + if err := r.armEgress(ctx, id, spec.Egress); err != nil { + return err + } + } +``` + +and its contract is "the backend armed it internally" — the test names it "a +fakeRuntime that self-arms egress in-guest … so AgentRuntime.provision must +skip the host-side armEgress exec — mirroring the microVM backend" +(`go/internal/runtime/agent_test.go:298-301`). Returning `true` from +`EgressArmedInGuest()` on the host backend would **falsely claim someone armed +the firewall** when nobody did and nobody can. Instead: + +- `AgentRuntime` grows an explicit unenforced path: a backend marker interface + (e.g. `EgressUnenforced() bool`, name settled at T2) that makes provision + skip `armEgress` **and** record the posture as unenforced — a distinct state, + never conflated with armed. +- The unenforced posture is **visible in session state and UI**: the session + carries an egress-posture field surfaced wherever session status renders, so + a green launch is never read as contained. A user must be able to see, per + session, "egress: unenforced (host tier)". +- A host-tier launch that carries **any** `EgressPolicy` reaching provision + fails loud ("host backend cannot enforce an egress policy"), never silently + ignores it. The trigger is the policy's **presence**, not a non-empty + allowlist: an empty host set is the *strictest* posture, not the absence of a + policy (`go/internal/runtime/egress.go:29-31`): + + ```go + // EgressPolicy is the set of destinations an agent container may reach. An empty + // host set is pure default-deny (only loopback, established flows, and DNS to + // the container's own resolver). + ``` + + and empty is also the Runner's default: `--egress-allow` defaults to `""` + (`go/cmd/compass-runner/main.go:58-59`) and the parse turns that into a real + policy (`main.go:378-381`): + + ```go + func parseEgress(csv string) (runtime.EgressPolicy, error) { + if strings.TrimSpace(csv) == "" { + return runtime.AllowEgress() + } + ``` + + Keying the check on non-emptiness would therefore reject a *looser* policy + while silently discarding the *tightest* one — the exact silent-ignore this + bullet exists to prevent, inverted. +- Making presence expressible is a small upstream change the tier requires. + `EgressPolicy` today draws no configured/unconfigured distinction: its only + accessor is `Hosts()` (`go/internal/runtime/egress.go:67`), so a zero-value + `EgressPolicy{}` and an explicit `AllowEgress()` are indistinguishable. T2 + adds a `configured bool` set by `AllowEgress`/`MustAllowEgress` plus a + `Configured()` accessor, and the host backend refuses any spec whose policy + reports configured. `Hosts()` and `NftScript()` are untouched, so container + arming stays byte-identical. +- Consequently a host-tier launch must come through a path that carries **no** + egress policy at all — the host Runner profile leaves `SpecDefaults.Egress` at + its zero value rather than calling `parseEgress` — instead of relying on an + allowlist happening to be empty. + +Most users of this tier will not have armed egress anyway — it is primarily an +enterprise-posture control. A future bubblewrap (Linux) / `sandbox-exec` +(macOS) wrapping mode could add real containment to the host tier later; it is +noted as future work and deliberately not designed here. + +#### Secrets: pin the SecretSpec `keyring://` provider + +Per DL-024 (`docs/designs/DECISIONS.md:137`): "Each agent runs in a per-agent +container on the Runner for blast-radius isolation, not credential avoidance." +The container was never the thing keeping secrets from the agent — the agent is +handed resolved values regardless, and they are the user's own secrets. So the +host tier changes nothing about *who sees* secrets. What this record does pin, +**on merit and explicitly not as a mitigation**, is at-rest handling on the +Server side for host-tier (self-host, single-box) deployments: the SecretSpec +resolver's provider is pinned to `keyring://`, so resolved values live in the +OS keyring rather than wherever the SDK's default chain lands. + +The seam exists and is currently unused: `WithProvider` pins the provider URI +(`go/internal/secrets/resolver.go:83-85`): + +```go +// WithProvider pins the SecretSpec provider URI (e.g. "keyring://", +// "onepassword://Production"). Empty uses the SDK's default provider chain. +func WithProvider(uri string) SpecOption { return func(r *SpecResolver) { r.provider = uri } } +``` + +and production pins nothing today (`go/server/serve.go:528`): + +```go +resolver := secrets.NewSpecResolver(st, secretsStateDir(cfg)) +``` + +T3 threads a config knob through `serve.go` and defaults the host-tier +single-box profile to `keyring://`. This does not depend on, replace, or +preempt the gateway-credentials at-rest encryption record +(`docs/designs/server/compass-gateway-credentials-at-rest-encryption.md`), +whose T0–T5 are all unimplemented — see Global Constraints. + +**The `$HOME/.compass/{env,secrets}` collision.** The materializer writes +resolved secrets into the agent's `$HOME/.compass/env` before agent start +(`go/internal/runner/host.go:360-384`: "Materialize the agent's secrets into +the container BEFORE exec'ing the agent … `h.materializer.Install(ctx, +handle.ID(), handle.HomeDir(), …)`"; the agent "sources that file from its own +namespace at startup", `go/internal/runner/agent_exec.go:72-74`). In a +container, `$HOME` is container-private. On the host tier, a naive `$HOME` is +the user's **real** home — colliding with any `.compass` state the user's own +CLI tooling keeps, and strewing per-agent runtime files into a shared dir. +This is an **ergonomics/path question, not a security one** (the values are +the same user's secrets either way, on the same machine, under the same uid). +Resolution: the host backend sets the agent's `HOME` to the handle's private +home-overlay dir inside the state dir (the `handle.HomeDir()` seam already +threads it), so `$HOME/.compass/env` lands per-agent and `Remove` cleans it. +The user's real home is reachable by path — the whole point of the tier — but +is not the agent's `$HOME`. + +#### MCS/SELinux relabel gap + +The config-update path reads the container's MCS label via `podman inspect` +`MountLabel` and `chcon -R`s the freshly materialized version dir into it +(`go/internal/runner/config_materialize.go:141-144`: "read via `podman +inspect` MountLabel … chcon -R it into the container's MCS category AFTER +writing and BEFORE the flip, or a confined agent gets EACCES"; the relabel +shellout at `:353-354`). With no container there is no label and no confined +domain: the host backend's `MountLabel` returns `""`, `Materialize` takes its +already-documented skip-chcon path on every call, and reads succeed because the +agent process runs as the same uid that wrote the files. No new mechanism; the +tier reuses the empty-label contract that already exists for the provision +path. + +#### Structurally absent protections (declared, not weakened) + +These are absent in this tier, not weaker versions of present ones. The tier's +documentation and session UI state them: + +- **Host filesystem**: the agent runs as the user's uid with the user's full + filesystem access. No mount narrowing, no MCS confinement, no private root. +- **Inter-agent isolation**: two host-tier agents on one box are two processes + under one uid; each can read the other's state dir, sockets, and secrets + file. (Corollary: the host tier is single-agent-at-a-time by default — + see Open Questions.) +- **Egress**: unenforced, per above. + +The counterfactual framing is the justification: the user's existing CLI agent +already runs at exactly this posture. The host tier adds Compass's session +management, config, and review flow at that same posture; it removes nothing +the user had. + +### Half B — agent-driven config-import review + +#### What exists and what is missing + +The transport is implemented. `compass agent-config push --dir ` +tars+gzips a local directory and `PutAgentConfig`s it +(`go/cmd/compass/agent_config.go:30-32`: "newPushCmd builds `agent-config push +--dir `: tar+gzip the dir into a bundle the store door accepts and +PutAgentConfig it (admin-gated)"). The bundle grammar whitelists top dirs +`skills/`, `extensions/`, `mcp/`, `settings/`, `rules/`, `agents/`, `prompts/`, +`profiles/` plus top-level `AGENTS.md` and `models.yml` +(`go/cmd/compass/bundle.go:29-53,71-80`, mirroring "the store door +(internal/store/agent_config.go) so a bundle this builder produces passes +validateAndHashConfigBundle", `bundle.go:23-24`). + +What is missing is judgment. A user pushing their existing corpus cannot tell +which of their hand-written skills/rules Compass's built-ins already cover, +which conflict, and which are safe to drop. Filename collision checks are the +shallow half; real overlap is **semantic** — a hand-written skill that does +what a built-in does, differently, sharing no filename. Per the ruling this is +**not a deterministic gate**: it is an agent task. + +#### The review task + +A first-run onboarding flow in which a Compass agent (host tier — see below) +reads the user's imported corpus against Compass's built-ins and produces a +**report**; the user decides. Shape: + +1. **Ingest**: the agent reads the source corpus directly from where it lives + (`~/.agents`, `~/.claude`, an existing bundle dir) and enumerates candidate + members against the bundle grammar (what would even be importable). +2. **Compare**: for each candidate, the agent reads it and the built-in corpus + and classifies: **redundant** (a built-in already does this — including + semantic overlap with no shared filename), **conflicting** (contradicts a + built-in rule/skill or Compass's composition semantics), **complementary** + (safe to import as-is), with a one-line rationale and the specific built-in + it overlaps. +3. **Explain composition**: the report states, per category, what will actually + happen on import, grounded in the shipped semantics — settings are + fleet-first whole-file ("overlay-over-project precedence", DL-123), rules + and AGENTS.md compose additively ("fleet-first, both levels load, no + cross-level dedup", `docs/designs/agent/compass-agent-config-passthrough/design.md:481-483`; + "the fleet file composes additively with the checkout's own AGENTS.md + chain", `:50-53`); the bundle is a fleet-wide **singleton**, admin-gated, + current-only ("upserts it as the single current bundle … + current-only retention via the singleton PK upsert", + `go/internal/store/agent_config.go:131-138`); and credential-marked settings + are rejected at the door ("credentials never ride the config bundle", + `go/internal/store/agent_config.go:1026-1030`) — so the agent tells the + user up front which members will bounce and why. +4. **Decide**: the user marks each finding keep/drop/rewrite. The agent then + assembles the approved subset into a bundle dir and (with the user's + go-ahead) runs the existing push. **The agent proposes; the user disposes.** + The review never mutates the user's source corpus and never pushes without + an explicit user decision. + +The deliverable of the review is the report plus the user's recorded +decisions — not an automatic mutation of anything. + +#### Why the host tier makes this land + +On the container tiers, reviewing a not-yet-imported corpus needs a +push-then-inspect round trip: the corpus must enter the bundle pipeline before +any agent can see it, which is backwards — the review is supposed to happen +*before* the push. A host-tier agent is already sitting where the user's config +lives and reads the source corpus directly. That makes the review a natural +first-run task on the exact tier a new user starts on, and it is a +demonstration of value in the first session: the first thing Compass does is +tell the user something true about their own setup. + +### Cross-references + +- The living tier spec (what the tiers are, operator-facing): + `docs/specs/runtime/runner-tiers.md`. This record is the point-in-time why; + the spec is the living what. +- The trust-model split this extends: DL-325 via + `docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md`. +- The embedded-mode front door this sits beside (DL-319/DL-320, + `docs/designs/DECISIONS.md:321-322`): embedded mode lowers *stack* friction + (the app spawns a local podman-backed stack); the host tier lowers *agent + environment* friction. They compose: an embedded-local stack can run a + host-backend Runner. + +## Alternatives considered + +### A dedicated `HostRuntime` interface instead of implementing `ContainerRuntime` + +Rejected. `ContainerRuntime` is frozen at S1 and `SelectBackend` is the sole +selection point; a parallel interface would fork the `AgentRuntime` lifecycle +façade (Launch → provision → credentials) that all tiers share, for no gain — +the degenerate methods are few and honestly documentable. The microVM backend +already set the precedent of a non-podman backend behind the same interface. +The interface layer was never the hard part, and this record does not argue the +tier's feasibility there: the load-bearing work is outside `ContainerRuntime` +entirely — the uid derivation and the Provision transport leg above, neither of +which a parallel interface would have made easier. + +### Deterministic import linting instead of an agent review + +Rejected by ruling. A filename/collision linter catches only the shallow half +and gives false confidence on the dangerous half (semantic overlap). The +deterministic checks that make sense (bundle grammar, credential denylist) +already exist at the door and the client builder; the review's job is exactly +the part that needs reading comprehension. + +### Sandboxed-by-default host tier (bubblewrap/sandbox-exec from day one) + +Deferred, not rejected. Wrapping the host process would blunt the tier's core +promise — same access as the user's existing CLI agent, zero setup — and each +wrapper is platform-specific. Noted as future work; a later record may add an +opt-in wrapped mode. + +### A Provision-side probe seam alone for the agent transport + +Rejected as insufficient, not as ugly. The microVM backend's precedent is a +Provision probe (`go/internal/runner/host.go:50-60`, `vsockGatewayEngine`, whose +leg at `host.go:800-810` "launches the container with NO agent-socket mount and +NO config mount"), and the host tier does need the equivalent leg. But a probe +only changes what the **Runner** does. The path the agent dials is resolved from +a module-level constant with no configuration input +(`packages/compass-agent/src/cli.ts:91`, `config-reader.ts:53`), so no +Runner-side seam can redirect it. The agent-side override is unavoidable; the +probe leg is necessary but not sufficient, and the record takes both. + +## Global Constraints + +- **Public repo.** No managed/multi-tenant product detail; managed-plane + concerns are named and deferred, never sequenced here + (`docs/concepts/self-host-and-managed.md`, + `docs/designs/meta/oss-core-managed-boundary/design.md`). +- **Do not weaken the container tiers.** The podman/microVM egress path + (`armEgress`, `EgressArmedInGuest`) is untouched; the host tier adds a + distinct unenforced posture beside it, never a change to arming. +- **`ContainerRuntime` is frozen at S1.** The host backend implements the + 9-method interface as-is (`go/internal/runtime/podman.go:348-396`); no + interface amendment. +- **No dependency on gateway-credentials at-rest encryption.** That record's + T0–T5 are all unimplemented + (`docs/designs/server/compass-gateway-credentials-at-rest-encryption.md`, + tasks unchecked); nothing here waits on or assumes it. +- **The host tier is a single-trust-domain backend** — valid only for an + operator running their own agents on their own machine, never for untrusted + work and never to isolate mutually-distrusting principals from each other + (DL-325's axis: the boundary follows the trust model, not the deployment + shape). It is **not** scoped by deployment topology: a user of any + deployment shape may run an agent on their own box. +- **Agent proposes, user disposes** — the import review never mutates the + user's source corpus and never pushes without an explicit user decision. +- **Bundle grammar and door checks are authoritative and unchanged** — the + review explains them; it does not bypass or re-implement them. +- **The host tier derives its own uid.** `Workspace.UID` comes from the + Runner's `os.Geteuid()`, never `agentuid.AgentUID` + (`go/internal/agentuid/agentuid.go:13`) — that constant names the uid baked + into the agent image and has no Runner-side override. Root is still refused + (`go/internal/runner/spec.go:59-60`). +- **Container-tier agent behaviour stays byte-identical.** The two frozen + agent-side path constants (`packages/compass-agent/src/cli.ts:91`, + `config-reader.ts:53`) become env-overridable with today's literals as + defaults; only the host tier ever supplies an override, and the existing + contract tests keep pinning the defaults. +- **No egress policy reaches the host backend.** Presence, not emptiness, is + the fail-loud trigger — empty is the strictest policy + (`go/internal/runtime/egress.go:29-31`), so the host launch path must carry + no policy at all. + +## Plan + +- **T1 — `HostRuntime` backend** (`go/internal/runtime/host_backend.go`). + The 9-method implementation per the table above: state-dir handle model, + process-group spawn/stop, degenerate `MountLabel`/`Resize`/`AsUser` + documented at the method. Includes the `ContainerSpec` field map (which + fields are honored, interpreted, or rejected on host). + Interfaces: implements `ContainerRuntime` + (`go/internal/runtime/podman.go:348-396`) exactly; registered in + `SelectBackend` (`go/internal/runtime/microvm.go:117-125`) as `case "host"`, + error string extended. Unit tests with a real short-lived process + (spawn/exec/stop/remove/exists), plus the degenerate-method contracts. +- **T1a — host-tier spec + uid derivation** (`go/cmd/compass-runner/main.go`, + `go/internal/runner/spec.go`). The host Runner profile derives + `SpecDefaults.UID` from `os.Geteuid()` instead of `agentuid.AgentUID`, + validating the `int` → `uint32` narrowing (and the `-1` no-syscall case) + before use; the existing non-root check stays, so a root Runner is refused at + startup. The host backend's `Exec`/`ExecStreaming` reject an `AsUser` naming + any uid other than the Runner's own euid. + Interfaces: consumes `runner.SpecDefaults` as built at `main.go:148-156`; + produces `runtime.Workspace{UID: }` (`spec.go:89-93`, + `go/internal/runtime/workspace.go:51-53`). Tests: the derived uid equals + `os.Geteuid()` and is what every provision `AsUser` carries + (`go/internal/runtime/agent.go:203-204`); a mismatched `AsUser` errors; a + uid-0 derivation is refused (`spec.go:59-60`); a full host provision + + launch succeeds on a box whose euid is NOT 1000 — the regression this task + exists to prevent. +- **T1b — host `Provision` leg: agent socket + config delivery** + (`go/internal/runner/host.go`, `packages/compass-agent/src/cli.ts`, + `packages/compass-agent/src/config-reader.ts`). A third Provision leg beside + the podman and `vsockGatewayEngine` legs: serve the per-agent gateway socket + inside the handle's own 0700 state dir, materialize the config tree there, + append no mounts, and thread both paths to the agent as env vars on the + starting streaming exec. `AGENT_SOCKET_PATH` and `AGENT_CONFIG_MOUNT_PATH` + become env-overridable, defaulting to today's literals. **This touches the + `compass-agent` package's frozen path contract and its two pinned contract + tests** (`cli.test.ts:113-117`, `config-reader.test.ts:67-70`), which keep + pinning the defaults and each gain an override case. + Interfaces: consumes the leg-selection seam (`host.go:191-193`) and the + socket/config mount constants (`host.go:33-38`, delivered at `host.go:198` + and `host.go:214`); produces two env vars on `AgentEnv.execSpec` + (`go/internal/runner/agent_exec.go:77-81`). Tests: the host leg appends no + mounts (mirroring `host_vsock_gateway_test.go:121-128`); the agent dials the + overridden socket and reads the overridden config root; with no override both + resolve to today's literals. +- **T2 — unenforced-egress posture** (`go/internal/runtime/agent.go` + session + state). New backend marker (distinct from `inGuestEgressArmer`) making + `provision` skip `armEgress` while recording posture=unenforced; fail-loud on + ANY `EgressPolicy` that reaches provision (presence, not a non-empty + allowlist — empty is the strictest policy, `go/internal/runtime/egress.go:29-31`), + which requires adding a `configured bool` + `Configured()` to `EgressPolicy` + (today `Hosts()` at `egress.go:67` is its only accessor) and leaving the host + Runner profile's `SpecDefaults.Egress` at its zero value; posture threaded + into session state and rendered + in the session UI/status surface. + Interfaces: consumes the `provision` seam (`agent.go:307-312`); produces an + egress-posture field on the session (exact proto/field shape decided at + implementation, additive only). Tests mirror + `TestInGuestArmerSkipsHostArmEgress` (`agent_test.go:312`) for the new + marker, plus the fail-loud policy case, plus a test asserting the host + backend does NOT satisfy `inGuestEgressArmer`. +- **T3 — `keyring://` provider pin** (`go/server/serve.go`, + `go/internal/secrets`). Config knob for the resolver provider; host-tier + single-box profile defaults it to `keyring://`. + Interfaces: `secrets.NewSpecResolver(st, dir, secrets.WithProvider(uri))` + (`go/internal/secrets/resolver.go:83-85,97`); wiring at `serve.go:528`. + Test: resolver receives the configured URI; empty config preserves today's + default chain. +- **T4 — per-agent `$HOME` overlay** (host backend + materializer path + threading). The handle's private home dir is the agent's `HOME`; + `$HOME/.compass/{env,secrets}` land there; `Remove` cleans them. + Interfaces: `handle.HomeDir()` as consumed by `h.materializer.Install` + (`go/internal/runner/host.go:384`); `HOME` on the streaming exec + (`go/internal/runner/host_test.go:1194-1197` names the existing contract). +- **T5 — config-import review agent task** (compass-agent lane). The first-run + review flow per Half B: ingest/compare/explain/decide, report format, and + the assemble-and-push handoff to the existing + `compass agent-config push --dir` path. + Interfaces: consumes the bundle grammar (`go/cmd/compass/bundle.go:29-53`) + and door semantics (`go/internal/store/agent_config.go:131-174,1026-1033`) + read-only; produces a report artifact + an approved bundle dir. No new RPC. +- **T6 — docs**. Tier documentation: the declared-absent protections list, the + graduation guidance (host → podman → microVM), and the first-run review + walkthrough. Cross-links `docs/specs/runtime/runner-tiers.md`. + +## Tasks + +- [ ] T1 — `HostRuntime` backend implementing the frozen `ContainerRuntime`, + registered in `SelectBackend` as `host` +- [ ] T1a — host-tier uid derivation: `Workspace.UID` from `os.Geteuid()`, not + `agentuid.AgentUID`; `AsUser` rejects any other uid; launch proven on a + non-1000 euid +- [ ] T1b — host `Provision` leg: per-agent socket + config path in the handle's + state dir, threaded as env vars; `AGENT_SOCKET_PATH` / + `AGENT_CONFIG_MOUNT_PATH` env-overridable (touches the two pinned + `compass-agent` contract tests) +- [ ] T2 — unenforced-egress posture: new marker (not `inGuestEgressArmer`), + fail-loud on ANY `EgressPolicy` reaching provision (presence, not + non-emptiness) via a new `Configured()` distinction, posture visible in + session state/UI +- [ ] T3 — SecretSpec provider knob; host-tier profile pins `keyring://` +- [ ] T4 — per-agent `$HOME` overlay for `.compass/{env,secrets}` +- [ ] T5 — agent-driven config-import review (first-run onboarding flow) +- [ ] T6 — tier docs: absent protections, graduation guidance, review + walkthrough + +## Open Questions + +- **Two motivations, one feature — RULED: both are permanent, and the tier is + not deployment-scoped.** (Was: does host-capability want its own record?) + Onboarding convenience and host capability (workflows needing the real + session bus/display, which no container tier can provide) are two + motivations wearing one backend, and the second is **not** onboarding + scaffolding that a user graduates off. Some work simply has to run on the + user's own box: the hardware, the display, and the live session are there + and nowhere else. The tier therefore ships as a **permanent capability**, + not a wedge, and the earlier framing ("a supported consequence, not a + designed-for surface") is withdrawn as too weak. + + The same ruling settles the scope question: **availability follows the trust + domain, not the deployment shape.** A user whose Server sits in any + deployment topology still has their own machine, and running an agent there + puts one trust domain on that host — theirs. So the tier is not restricted + to a single-tenant deployment; what DL-325 forbids is applying it to + untrusted work or to separate mutually-distrusting principals, which is a + property of the trust domain (see Approach § Placement). + + Still open, narrowly: whether the device/session-bus surface (which devices, + which sockets, how documented) wants its own follow-up record once a + concrete workflow pins the requirements. That is a documentation and + surface-area question, not a tier-existence question. +- **Concurrent host-tier agents** (non-load-bearing, deferred): v1 documents + the tier as effectively single-agent (no inter-agent isolation exists; + process-group stop cannot contain a double-forked escapee). Whether to add a + soft cap or a cgroup-scoped v2 is deferred until demand exists. +- **`Resize` future** (non-load-bearing, deferred): a systemd user-scope / + cgroup v2 delegation could make host `Resize` real; deferred until C3's + resize behavior lands anywhere. +- **The `Container*` vocabulary — RULED and DONE (Matt): renamed to + `Workload*` in this change.** Not deferred. The interface is named for one of + its backends, and it now has four: podman containers, microVM guests + (`MicroVMRuntime`, `go/internal/runtime/microvm.go:71`), Apple `container` on + macOS (`AppleContainerCLI`, DL-330), and the direct host processes this tier + adds. `SelectBackend`'s own comment (`microvm.go:110-116`) says the podman + path eventually goes away entirely, which would leave an interface named + `ContainerRuntime` with no container implementation at all. + + Applied: `ContainerRuntime` → `WorkloadRuntime` (85 refs), `ContainerID` → + `WorkloadID` (198), `ContainerSpec` → `WorkloadSpec` (58), `InContainerError` + → `InWorkloadError` (8). `Session*` was rejected — a session is already the + user-facing conversational stream (`SessionEvent` and siblings in + `proto/compass/v1/compass.proto`), and one workload outlives many sessions, + so the name would assert a one-to-one relation that does not hold. + `Sandbox` was rejected as asserting isolation the host tier explicitly does + not provide. + + Deliberately NOT renamed, because these are genuinely containers: + `ContainerController` (the podman-only stack supervisor, + `go/internal/stack/deps.go:219`), `ContainerRef` (a *message* container, + `go/internal/store/types.go:268`), the testcontainer specs + (`PostgresContainerSpec`, `NatsContainerSpec`, `CollectorContainerSpec`), and + the `container_name` wire field (`proto/compass/v1/compass.proto:645,653,666,708`), + which is a compatibility boundary. `AgentRuntime` + (`go/internal/runtime/agent.go:155`) also keeps its name — it is the + per-agent lifecycle façade over a backend, and that name is accurate. + + The S1 freeze (`go/internal/runtime/podman.go` freeze comment) reserves the + **method set** — "a backend that self-arms egress does NOT grow a verb here" + — not the identifier, so the rename is legal under it. No signature, method + set, or behaviour changed. + +## Ledger delta + +Proposed rows for the coordinator to mint at freeze (described, ids not +invented here): + +- **Host tier row**: a `host` backend joins `SelectBackend` + (`""`/`podman`/`microvm`/`host`) as a permanent tier for an operator running + agents on their own machine — agent as a host process at the user's existing + CLI-agent exposure; egress explicitly unenforced (a declared posture, + visible in session state, never `EgressArmedInGuest`); blast-radius + protections (host filesystem, inter-agent isolation, egress) structurally + absent and declared. It serves two permanent cases, onboarding and + host-capability work no container tier can reach (real session bus, display, + device access). **AMENDS DL-325's trust-model axis** with a third tier below + podman: microVM required for untrusted multi-tenant, podman the permanent + self-host container tier, host the single-trust-domain tier. Per DL-325's + own rule the boundary follows the **trust model, not the deployment shape**, + so the host tier is **not scoped by deployment topology** — it is available + to any user running an agent on their own machine, and is never valid for + untrusted work or for isolating mutually-distrusting principals. + The tier also pins two mechanism decisions: `Workspace.UID` is derived from + the Runner's `os.Geteuid()` rather than the baked `agentuid.AgentUID`, and the + agent's socket/config rendezvous paths become env-overridable (defaults + unchanged) so the host Provision leg can serve them per-agent inside the + handle's state dir instead of by bind-mount. +- **Secrets-provider row**: the Server's SecretSpec resolver provider becomes + configurable; the host-tier single-box profile pins `keyring://` — an + at-rest-handling improvement on merit under DL-024's framing (isolation was + never credential avoidance), explicitly not a mitigation. +- **Import-review row**: config-import review is an agent-driven first-run + onboarding task (semantic overlap in scope, agent-proposes/user-disposes), + not a deterministic gate; the deterministic door checks (grammar, credential + denylist) remain the sole automatic enforcement. +- **Runtime-vocabulary row**: the backend seam is renamed from `Container*` to + `Workload*` (`WorkloadRuntime`/`WorkloadID`/`WorkloadSpec`/`InWorkloadError`) + because the interface spans four backends — podman, microVM, Apple + `container`, host process — only some of which are containers, and the podman + path is slated to go away. Ruled by Matt. The S1 freeze covers the method + set, not the identifier; no signature or behaviour changed. Genuine + containers keep the old vocabulary (`ContainerController`, `ContainerRef`, + the testcontainer specs, the `container_name` wire field). diff --git a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md index fcc099c3c..85dcb2eef 100644 --- a/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md +++ b/docs/designs/infra/runtime/compass-runner-adoption-strategy/design.md @@ -4,6 +4,15 @@ Status: Active Tracking: RIG-3070 Owner: compass-obs (design) → compass-runner (impl, runtime/sequencing) +> **The living strategy now lives at +> [`docs/specs/runtime/runner-tiers.md`](../../../../specs/runtime/runner-tiers.md).** +> This record stays the point-in-time ruling — why the trust model is the +> axis, what it ratifies/supersedes/amends, and the execution plan. The +> durable tier strategy — the tier table, the adoption funnel, the standing +> guidance, and the **host tier** that has since joined the axis (designed in +> [`../compass-host-runtime-tier/design.md`](../compass-host-runtime-tier/design.md)) +> — is maintained in that spec, not here. + ## Problem / Intent The runtime corpus froze a microVM trajectory (DL-259 self-host KVM stack, @@ -153,10 +162,10 @@ Grounding the current state: - The seam is exactly what makes the permanent split cheap. The podman implementation is explicitly a thin seam (`go/internal/runtime/podman.go:10-13`: "podman.go — a thin - ContainerRuntime over the podman CLI: the only place a subprocess is + WorkloadRuntime over the podman CLI: the only place a subprocess is spawned. Everything above depends on the interface"), backend selection is constructor-time (`go/internal/runtime/microvm.go:117`: - `func SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`), and + `func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)`), and the frozen record pins byte-identical container behavior during coexistence (`docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-runner.md:397-402`: @@ -197,9 +206,10 @@ an argument against embedded. Compass is fundamentally an always-on server — agents keep working while you are away — and a personal laptop sleeps; so embedded-local is the try-it-on-your-box on-ramp, and a user who wants always-on operation graduates to a self-host stack on a -dedicated box or VPS, or to managed. The funnel: embedded-local (front -door, your box) → self-host stack (always-on, dedicated box) → managed -(hosted always-on). +dedicated box or VPS, or to managed. The durable funnel — since extended +at its front with the host tier — is maintained in the living spec +(`docs/specs/runtime/runner-tiers.md`), not in this record's frozen +prose. The embedded supervision subsystem was deleted under DL-235 (`docs/designs/ui/compass-native-client-only/design.md:42-43`: "The @@ -222,18 +232,17 @@ removes the KVM premium at the self-host front door — cheap VPS tiers mostly do not expose `/dev/kvm`, and a single-tenant operator gains little from a hardware boundary that exists to isolate untrusted tenants. The standing two-backend maintenance surface is the acknowledged price, bounded -by the frozen `ContainerRuntime` seam and the now-permanent byte-identical +by the frozen `WorkloadRuntime` seam and the now-permanent byte-identical parity constraint. ### Guided onboarding: embedded-local front door, then self-host -The adoption funnel starts before self-host: the zero-setup front door is -embedded-local — brew install the app, launch it, sign in with your own -subscription, and agents run locally on the podman backend (the -app-architecture that delivers this is the compass-native lane's -embedded-revival record, not this record). Self-host is the graduation -tier for always-on operation, and its bring-up must be near-one-command -on the user's own Linux box or a VPS. The entrypoint already exists: +The durable adoption funnel (host tier → embedded-local front door → +self-host graduation) lives in the living spec +(`docs/specs/runtime/runner-tiers.md`); this section records only the +execution consequences ruled here. Self-host is the graduation tier for +always-on operation, and its bring-up must be near-one-command on the +user's own Linux box or a VPS. The entrypoint already exists: `compass-stack` dispatches `up|down|status|preflight` (`go/cmd/compass-stack/main.go:8-13`: "up: bring the embedded stack to Ready (or attach to a live one) … preflight: check the @@ -247,13 +256,12 @@ host-floors unconditionally (`go/cmd/compass-stack/preflight.go`), so on a zero-KVM podman box it exits non-zero — a green-preflight experience for the podman entry tier is part of T1's deliverable, and T2's guide must not instruct a `preflight` run on the podman tier before then. This record adds -the adoption framing on top: -an onboarding guide (T2) that walks the funnel — the embedded-local front -door first, then both self-host graduation paths: the zero-KVM podman -path on any VPS or box (the entry tier), and the recommended microVM path -on a KVM-capable box or nested-virt-enabled instance (the docs recommend -microVM even on self-host; podman remains fully supported for users who -don't want the KVM premium). The specific VPS provider recommendation is +an onboarding guide (T2) that walks that funnel, covering both self-host +graduation paths: the zero-KVM podman path on any VPS or box (the entry +tier), and the recommended microVM path on a KVM-capable box or +nested-virt-enabled instance (the docs recommend microVM even on +self-host; podman remains fully supported for users who don't want the +KVM premium). The specific VPS provider recommendation is deferred to doc-writing time (OQ-2). The guide content itself is an impl task (T2), not frozen prose here. @@ -344,7 +352,7 @@ task (T2), not frozen prose here. (`go/internal/runtime/microvm.go:110-113`) — and, per the trust-model split, stays permanently for self-host. - **Other runtime backends behind the seam — deferred, not declined-forever.** - The `ContainerRuntime` interface is frozen precisely so a new backend is one + The `WorkloadRuntime` interface is frozen precisely so a new backend is one `SelectBackend` case plus an implementation, no caller churn (`go/internal/runtime/podman.go`: "Everything above depends on the interface, so a libpod-REST backend can replace it without touching a @@ -451,8 +459,8 @@ in this record. (`go/cmd/compass-stack/preflight.go`). This is the green-preflight deliverable that §Guided onboarding names as T1's, and on which T2's podman-tier `preflight` instructions are blocked until it lands. -- **Interfaces:** consumes the frozen `ContainerRuntime` interface and - `SelectBackend(cfg BackendConfig) (ContainerRuntime, error)` +- **Interfaces:** consumes the frozen `WorkloadRuntime` interface and + `SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)` (`go/internal/runtime/microvm.go:117`); consumes the landed startup preflight surface (`verifyBackendPreflight`, above) and the microVM e2e/CI suites @@ -580,8 +588,23 @@ freeze-time delta shape the directory's amendments use client-only charter) is designed in the compass-native lane's embedded-revival record and carries its own ledger row there. AMENDS the frozen KVM-only amendment (`microvm-kvm-only-amendment.md:96-97`) - with the self-host carve-out; the `ContainerRuntime` interface stays + with the self-host carve-out; the `WorkloadRuntime` interface stays frozen. +2. **Proposed (2026-09, spec split + host tier — no DL id minted here; the + coordinator assigns one at freeze).** The living runner tier strategy + moves out of this record into a spec, + `docs/specs/runtime/runner-tiers.md`, which becomes the maintained + source-of-truth for the tier table, the adoption funnel, and the + standing tier guidance; this record stays the point-in-time ruling. And + the **host tier** joins the DL-325 trust-model axis as a third tier — + a `SelectBackend` value `"host"` beside `""`/`"podman"`/`"microvm"` — + for single-tenant operation on the operator's own machine, with no + isolation boundary and egress explicitly UNENFORCED, designed in + `docs/designs/infra/runtime/compass-host-runtime-tier/design.md`. This + row AMENDS DL-325 (extends its axis with a third tier and relocates the + strategy's living home) rather than superseding it: DL-325's + untrusted-multi-tenant microVM requirement and permanent self-host + podman tier are unchanged. This stanza is human-readable guidance for the freeze coordinator; the ledger row is encoded in `DECISIONS.md` in the same PR at freeze time. diff --git a/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md b/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md index 502405088..5d1333fcd 100644 --- a/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md +++ b/docs/designs/infra/runtime/compass-runner-arbitrary-uid/design.md @@ -119,7 +119,7 @@ The rationale for bare `keep-id` is `podman.go:25-26`: "Containers run with --userns=keep-id so files the agent writes in a bind-mount map back to the invoking user on the host" (restated at the flag itself, `:363-364`). The runner's bind-mounts are two kinds: the read-only config/cache mounts -(`ContainerSpec.Mounts`, `podman.go:82-84` — whose "read-only" field comment +(`WorkloadSpec.Mounts`, `podman.go:82-84` — whose "read-only" field comment is now stale, see P1; the materialized config tree, `go/internal/runner/config_materialize.go:199-201`), and one **read-write** mount — the per-container agent gateway socket @@ -233,28 +233,28 @@ unchanged by ordering. ### P1 — Remap the userns flag in `Create` Switch `PodmanCLI.Create` from the bare token to the remap, threading the -agent uid into `ContainerSpec` so the flag derives from the same value every +agent uid into `WorkloadSpec` so the flag derives from the same value every exec already uses (the T1/T2 invariant). - Changes: - - `go/internal/runtime/podman.go`: add `UID uint32` to `ContainerSpec` - (`:70-90`; note `ContainerSpec` has no `User` field — `:92-97` is + - `go/internal/runtime/podman.go`: add `UID uint32` to `WorkloadSpec` + (`:70-90`; note `WorkloadSpec` has no `User` field — `:92-97` is `ExecSpec`), documented as the container uid the invoking host user is mapped to. `Create` (`:355-366`) emits `fmt.Sprintf("--userns=keep-id:uid=%d,gid=%d", spec.UID, spec.UID)` in place of `"--userns=keep-id"` — gid is collapsed to uid because the image bakes gid==uid==1000 (`containers.nix:55-56`); a distinct GID field is not threaded until an image diverges. Extract the argv assembly into - `createArgs(spec ContainerSpec) []string` (mirroring + `createArgs(spec WorkloadSpec) []string` (mirroring `execStreamingArgs`, `:635`, "split out so the argv assembly is unit-testable without spawning podman"). - `go/internal/runtime/agent.go` `createAndStart` (`:245-254`): set - `UID: spec.Workspace.UID` on the `ContainerSpec`. + `UID: spec.Workspace.UID` on the `WorkloadSpec`. - Update the stale comments: `podman.go:25-26` and `:363-364` (the "maps back to the invoking user" rationale now reads "the invoking host user is mapped to the baked agent uid; files the agent writes in a bind-mount still map back to the invoking user"); `podman.go:82-84` - (`ContainerSpec.Mounts` "read-only host bind mounts" — false, the agent + (`WorkloadSpec.Mounts` "read-only host bind mounts" — false, the agent socket is read-write, see §(c)); `podman.go:95-96` (`ExecSpec.User` "Nil runs as the image's default user (root)" — the image default is uid 1000, not root); `agent.go:282-283` (`armEgress` "as root" — it runs as the @@ -262,12 +262,12 @@ exec already uses (the T1/T2 invariant). `agent-image/devenv.nix:69-82` identity comment (it cites bare keep-id and the verifyRunnerUID guard). - Interfaces: - - `type ContainerSpec struct { ...; UID uint32 }` (new field). - - `func createArgs(spec ContainerSpec) []string` (new, package-private). - - `func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID, error)` — unchanged signature. + - `type WorkloadSpec struct { ...; UID uint32 }` (new field). + - `func createArgs(spec WorkloadSpec) []string` (new, package-private). + - `func (p *PodmanCLI) Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error)` — unchanged signature. - Test cycle: new `TestCreateArgsRemapsUserns` in `go/internal/runtime/podman_test.go` pinning the exact token - `--userns=keep-id:uid=1000,gid=1000` for `ContainerSpec{UID: 1000}`. + `--userns=keep-id:uid=1000,gid=1000` for `WorkloadSpec{UID: 1000}`. Order within P1: extract `createArgs` first (still emitting the bare token), commit the test red against the bare token, then flip the flag to green. Run `go test ./go/internal/runtime/ -run TestCreateArgs`. @@ -311,7 +311,7 @@ exercises the identical mechanism an arbitrary-host-uid deployment relies on. `podmanUsable()` (the existing skip helper, `lifecycle_test.go:54-59`), alpine-based like `config_mount_test.go` (no compass-agent image dependency): - 1. `Create`/`Start` a container with `ContainerSpec{UID: }` shape — i.e. drive the real `PodmanCLI.Create` with a `UID` distinct from `os.Getuid()` — and assert `id -u` inside equals the spec'd UID (the remap maps host→spec'd uid). @@ -336,7 +336,7 @@ it implements the preflight per that ruling. ## Tasks -- [ ] P1 — `ContainerSpec.UID` + `createArgs` extraction + +- [ ] P1 — `WorkloadSpec.UID` + `createArgs` extraction + `--userns=keep-id:uid=,gid=` in `Create`; comment sweep (`podman.go:25,363`, `agent-image/devenv.nix:69-82`); `TestCreateArgsRemapsUserns` green. diff --git a/docs/designs/platform/apple-container-macos-runner/design.md b/docs/designs/platform/apple-container-macos-runner/design.md index 40c82b9e7..9ac08b24d 100644 --- a/docs/designs/platform/apple-container-macos-runner/design.md +++ b/docs/designs/platform/apple-container-macos-runner/design.md @@ -12,7 +12,7 @@ Linear: RIG-3238 (design) Investigation + design record for RIG-3238: whether Apple `container` (github.com/apple/container) becomes a supported backend behind the frozen -`ContainerRuntime`/`SelectBackend` seam for the Compass native app's embedded +`WorkloadRuntime`/`SelectBackend` seam for the Compass native app's embedded macOS front door, and if so, the adoption sequencing. This record carries Matt's RIG-3246 ruling plus an adoption plan whose BUILD (not direction) is gated on the T-1 spike; it does not implement the backend. @@ -130,16 +130,16 @@ Reasoning, in order of weight: no podman", OQ-13 resolved), so the "no machine / no podman" win covers the WHOLE macOS stack, not only the agent containers — the DL-260 podman shell for postgres is swapped for apple-container on macOS (T-2 scope). -3. **The seam was built for this.** `ContainerRuntime` is a frozen interface - (`go/internal/runtime/podman.go:343-348`: "ContainerRuntime is the +3. **The seam was built for this.** `WorkloadRuntime` is a frozen interface + (`go/internal/runtime/podman.go:343-348`: "WorkloadRuntime is the container engine seam … An interface so the Runner can hold a - ContainerRuntime and tests can substitute a fake") and `SelectBackend` + WorkloadRuntime and tests can substitute a fake") and `SelectBackend` is an explicit switch (`go/internal/runtime/microvm.go:117-126`) whose error copy already anticipates growth ("accepted values are \"podman\" (default) and \"microvm\"", `microvm.go:124`). A third case + impl type is the designed extension path. Apple `container` is a DISTINCT non-podman CLI (its own argv grammar, its own `container-apiserver` service), so it - is a new `ContainerRuntime` implementation — NOT a + is a new `WorkloadRuntime` implementation — NOT a `PodmanCLI.WithProgram` swap, which only substitutes a podman-compatible binary path (`podman.go:433-435`: "WithProgram uses an explicit engine binary (e.g. an absolute path, or `docker` in a pinch)"). @@ -177,7 +177,7 @@ Why spike-first, then flip (not default-the-instant-it-builds): (`microvm.go:63-69`) gains an `AppleContainer AppleContainerConfig` field mirroring how `MicroVM MicroVMConfig` rides beside `Backend`. - **The impl type** is `AppleContainerCLI`, a subprocess-driving - `ContainerRuntime` shaped like `PodmanCLI` (program + timeout, + `WorkloadRuntime` shaped like `PodmanCLI` (program + timeout, `podman.go:421-425`), speaking the `container` CLI: `create`/`start`/ `exec`/`stop`/`rm`/`inspect` exist with familiar semantics (). @@ -334,8 +334,8 @@ trivially satisfiable (a version-floor probe on one binary, like `container system start` command") — inside the invariant. The installer requiring admin once to place files under /usr/local is an install-time cost, not a runtime posture. -- **The `ContainerRuntime` interface stays frozen.** The new backend - implements all nine `ContainerRuntime` verbs, Resize included as the +- **The `WorkloadRuntime` interface stays frozen.** The new backend + implements all nine `WorkloadRuntime` verbs, Resize included as the additively-reserved one (`podman.go:348-397`), and adds NO verbs. Any backend-specific need rides the off-interface marker pattern (`podman.go:399-406`) or the config struct, never an interface change. @@ -437,7 +437,7 @@ Matt ruled OQ-9 A (the mac mini on Woodpecker, ssh access provisioned).** plain Go): type `AppleContainerCLI{program string, timeout time.Duration}` mirroring `PodmanCLI` (`podman.go:421-425`), argv builders split from spawning (the `createArgs` discipline, `podman.go:455-462`), implementing - all nine `ContainerRuntime` verbs (`podman.go:348-397`): Create/Start/ + all nine `WorkloadRuntime` verbs (`podman.go:348-397`): Create/Start/ Exec/ExecStreaming/Stop/Remove/Exists/MountLabel/Resize. Additionally the two OFF-interface podman surfaces the embedded stack drives on macOS. (1) The image adapter's `imageCLI` requires `ImageExists` + `Pull` @@ -467,10 +467,10 @@ Matt ruled OQ-9 A (the mac mini on Woodpecker, ssh access provisioned).** `container --version`, the `VerifyUsernsRemapSupport` shape (`podman.go:497-518`). - **Interfaces:** produces `NewAppleContainerCLI(cfg AppleContainerConfig) - *AppleContainerCLI` satisfying `runtime.ContainerRuntime` + *AppleContainerCLI` satisfying `runtime.WorkloadRuntime` (`podman.go:348-397`), `func (a *AppleContainerCLI) VerifyAppleContainerSupport(ctx context.Context) error`, and the widened - `SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`. Consumes + `SelectBackend(cfg BackendConfig) (WorkloadRuntime, error)`. Consumes T-1's findings for argv specifics. - **Test cycle:** unit tests over the argv builders (no binary spawned — the `TestCreateArgsRemapsUserns` pattern, `podman_test.go:99-104`); diff --git a/docs/designs/platform/apple-container-macos-runner/spike-findings.md b/docs/designs/platform/apple-container-macos-runner/spike-findings.md index d1aac04ba..71a2af13a 100644 --- a/docs/designs/platform/apple-container-macos-runner/spike-findings.md +++ b/docs/designs/platform/apple-container-macos-runner/spike-findings.md @@ -470,7 +470,7 @@ changes. Unaffected: the exec/kill contract, the host-side-runner topology, and T-5's flip criteria — now backed by real numbers. On the -`SelectBackend`/`ContainerRuntime` seam, all nine verbs are accounted for: six +`SelectBackend`/`WorkloadRuntime` seam, all nine verbs are accounted for: six were exercised incidentally through the `run`/`exec` probes (Create, Start, Exec, ExecStreaming, Stop, and Remove), and `Exists` was driven directly (`container inspect ` exits 0 in any state and 1 with a distinguishable diff --git a/docs/designs/repo/compass-agent-effect-otel/design.md b/docs/designs/repo/compass-agent-effect-otel/design.md index d545619d5..6fed8a248 100644 --- a/docs/designs/repo/compass-agent-effect-otel/design.md +++ b/docs/designs/repo/compass-agent-effect-otel/design.md @@ -575,7 +575,7 @@ merges. record, no cross-package split.** Matt ruled fold-it-in; compass-runner confirmed the injection path is the TS CLI env-file sourcing (`packages/compass-agent/src/cli.ts:96-105,500-523`) — the compass-agent TS - package (this lane), sitting ABOVE the Go `ContainerRuntime` seam, NOT the + package (this lane), sitting ABOVE the Go `WorkloadRuntime` seam, NOT the Runner's Go lane and NOT in the microVM record's scope. The endpoint key is not `COMPASS_*`-prefixed, so `isReservedEnvKey` (`cli.ts:103-105`) does not drop it and it flows through the generic env-file sourcing diff --git a/docs/specs/runtime/README.md b/docs/specs/runtime/README.md new file mode 100644 index 000000000..9657ff784 --- /dev/null +++ b/docs/specs/runtime/README.md @@ -0,0 +1,21 @@ +# Runtime Specs + +Living source-of-truth for the **Compass runtime tier strategy** — which +runner backends exist, what each isolates, and how a user adopts them. The +point-in-time *design records* (the why) live in the +[design corpus](../../designs/) (`../../designs/`), bucketed by domain and +indexed by [`DECISIONS.md`](../../designs/DECISIONS.md). + +Available specs: + +- [`runner-tiers.md`](runner-tiers.md) — the runner tier strategy: the + trust-model axis (DL-325), the three tiers (host / podman / microVM) with + each tier's isolation boundary and egress posture, the adoption funnel from + host tier through embedded-local to self-host graduation, and the standing + guidance on when each tier is (and is not) the right choice. + +> These specs describe the strategy and current behavior. The *why* — the +> rulings behind the trust-model split and each tier — lives in the design +> records under [`../../designs/`](../../designs/) (bucketed by domain, +> indexed by `DECISIONS.md`); each spec's "Not yet specified" section names +> the surfaces still ahead of the code. diff --git a/docs/specs/runtime/runner-tiers.md b/docs/specs/runtime/runner-tiers.md new file mode 100644 index 000000000..591aab395 --- /dev/null +++ b/docs/specs/runtime/runner-tiers.md @@ -0,0 +1,185 @@ +# Runner tiers + +Living source-of-truth for the **Compass runner tier strategy**: which runtime +backends exist, what boundary each provides, and how a user adopts them. The +point-in-time rationale — why the axis is the trust model, why podman is +permanent, why a host tier exists at all — lives in the design records this +spec cites; this spec states only the standing strategy. + +Two records carry the why: + +- [Runner adoption strategy](../../designs/infra/runtime/compass-runner-adoption-strategy/design.md) + — the trust-model split ruling (DL-325) and its execution plan. +- [Host runtime tier](../../designs/infra/runtime/compass-host-runtime-tier/design.md) + — the host tier's design record (the third tier, not yet built). + +## The trust-model axis + +**The security boundary follows the trust model, not the deployment +uniformly.** That is the ruled axis (DL-325, +[`DECISIONS.md`](../../designs/DECISIONS.md): "The runner end state splits by +trust model (RIG-3070): untrusted multi-tenant operation requires the microVM +hardware boundary (KVM, unchanged); self-host single-tenant deployments keep +podman as a permanent, supported entry tier requiring no `/dev/kvm`, with +microVM the recommended (not required) upgrade"). + +The consequence: a tier is chosen by asking *who is being isolated from whom*, +not by asking which deployment shape is in play. Untrusted multi-tenant +operation runs code from mutually-distrusting tenants and requires the +hardware isolation boundary. A self-host single-tenant deployment runs the +operator's own agents on their own code on their own box — there is no +untrusted tenant to isolate from — so the boundary strength is the operator's +choice, graded across three tiers. + +One doctrine governs what every tier's boundary is *for* (DL-024, +[`DECISIONS.md`](../../designs/DECISIONS.md): "Each agent runs in a per-agent +container on the Runner for blast-radius isolation, not credential +avoidance"). No tier withholds credentials from the agent — secrets are +materialized into the agent's environment on every tier. What varies across +tiers is the *blast radius* a misbehaving or compromised agent can reach, not +what the agent is trusted with. + +## The tiers + +Tiers are selected at constructor time through the single backend seam, +`SelectBackend` (`go/internal/runtime/microvm.go:117`: +`func SelectBackend(cfg BackendConfig) (ContainerRuntime, error)`), which +today accepts `""`/`"podman"` and `"microvm"` and rejects anything else +(`go/internal/runtime/microvm.go:124`: `accepted values are "podman" +(default) and "microvm"`). The **host** tier joins that seam as a third +`SelectBackend` value, `"host"` — designed, not yet built (see +[Not yet specified](#not-yet-specified)). + +| Tier | Isolation boundary | Egress enforcement | Trust model served | +| --- | --- | --- | --- | +| **host** *(not yet built)* | None — the agent runs as a process on the operator's own machine | **Explicitly unenforced** (see below) | Single **trust domain**: the operator's own box, own code, own agents. Not scoped by deployment shape — available to any user running an agent on their own machine | +| **podman** | Rootless container (shared host kernel) | Enforced: default-deny nftables in the container's own netns | Self-host single-tenant — the permanent supported entry tier | +| **microVM** | Hardware virtualization (cloud-hypervisor/KVM) | Enforced: armed in-guest by the backend | Required for untrusted multi-tenant; recommended self-host upgrade | + +### host *(not yet built)* + +- **Boundary:** none. The agent runs directly on the operator's machine, at + the same host exposure as any CLI agent the user already runs. There is no + container, no separate kernel, no namespace boundary. +- **Egress:** **explicitly unenforced** — not a degraded or partial arm. + Compass's egress firewall is default-deny nftables applied to the + container's own network namespace (`go/internal/runtime/egress.go:2-4`: + "The container's own network namespace is firewalled with nftables, so a + compromised agent can't exfiltrate to an arbitrary host"); a host process + has no such namespace, so the mechanism is structurally inapplicable. The + host tier does not reuse the in-guest-armed marker + (`go/internal/runtime/agent.go:298-299`: `type inGuestEgressArmer interface + { EgressArmedInGuest() bool }`), because that marker means a backend armed + egress itself — claiming it would be false. Host mode states plainly that + egress policy is not enforced. +- **When to use:** two permanent cases, on the operator's own machine. + **Onboarding** — near-zero setup, replicating the user's existing CLI-agent + posture with Compass's server, comms, and config machinery on top. And + **host-capability work a container cannot reach** — the real session bus, + display, or device access (window-management tooling driving the live + desktop, for instance). The second is not a transitional case a user + graduates off: that work has to run where the hardware and the session are. +- **Scope:** the tier is bounded by **trust domain, not deployment shape**. + Any user running an agent on their own machine puts exactly one principal + on that host — themselves — whatever topology their Server sits in. The + deployment a Server serves does not enter the analysis. +- **When NOT to use:** untrusted work, and any case that needs to isolate + mutually-distrusting principals from each other — a shared kernel and a + shared `$HOME` cannot separate parties. Also unsuitable wherever egress + policy must actually bind. It is not the preferred steady state for + general agent work (see [Standing guidance](#standing-guidance)). +- **What it does and does not protect:** per DL-024 the container was never + credential avoidance — agents receive the user's own secrets on every tier. + The host tier gives up only the blast-radius boundary; it does not hand the + agent anything the other tiers withhold. + +### podman + +- **Boundary:** a rootless per-agent container over the podman CLI — a + shared-kernel namespace boundary, no `/dev/kvm` required. The backend is + the thin seam implementation (`go/internal/runtime/podman.go:11-12`: + "a thin ContainerRuntime over the podman CLI: the only place a subprocess + is spawned. Everything above depends on the interface"). +- **Egress:** enforced. The host-side arm execs the nftables script inside + the container before the agent runs (`go/internal/runtime/agent.go:319-321`: + "armEgress arms the egress firewall as the image's default user (uid 1000) + with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with + no capabilities — cannot alter the ruleset"). +- **When to use:** the permanent, supported self-host entry tier — any Linux + box or VPS without `/dev/kvm`, and macOS via podman-machine. It is the + production default today (`go/internal/runtime/microvm.go:119-120`: + `case "", "podman": return NewPodmanCLI(), nil`) and the backend behind the + embedded-local front door (DL-319). +- **When NOT to use:** untrusted multi-tenant operation — a shared kernel is + not the required boundary there. + +### microVM + +- **Boundary:** hardware virtualization — a per-session microVM under + cloud-hypervisor on KVM. Requires Linux with `/dev/kvm`; a KVM-absent host + hard-fails on this path, with no silent degrade. +- **Egress:** enforced, armed in-guest by the backend itself before the exec + gate opens; the runtime advertises this via the in-guest-armed marker so + the host-side arm is skipped (`go/internal/runtime/agent.go:304-306`: "has + already armed by Start, so the host-side armEgress exec … is skipped"). +- **When to use:** required for untrusted multi-tenant operation (DL-325); + recommended (not required) for self-host, for defense-in-depth or an + operator who runs untrusted code or shares the box. +- **When NOT to use:** it is never wrong on a capable host — the constraint + is the KVM floor, which cheap VPS tiers mostly cannot expose. + +## The adoption funnel + +Each stage is a graduation, never a gate — a user may stay at any stage. + +1. **Host tier** *(not yet built)* — the near-zero-setup front step: run + Compass agents at the same host exposure as the CLI agent you already use. + The counterfactual is not "that user on a container tier"; it is that user + staying on their existing agent with the same exposure and none of + Compass. +2. **Embedded-local (podman)** — the low-friction onboarding front door + (DL-319, [`DECISIONS.md`](../../designs/DECISIONS.md): "`mode="embedded"` + returns as the low-friction onboarding / local-dev front door — the app + spawns/supervises a LOCAL stack via rootless podman on the user's own + machine"), with zero-config mode selection (DL-320: "absent → embedded + (the zero-config onboarding default returns)"). Real isolation, still on + your own box. +3. **Self-host graduation** — always-on operation on a dedicated box: the + podman entry tier on any VPS (no `/dev/kvm` needed), or the microVM tier + on a KVM-capable machine. Client mode is the recommended steady state + (DL-319: client mode "stays first-class and is the RECOMMENDED + steady-state for real self-host"). + +## Standing guidance + +**The host tier is not the preferred steady state — as guidance, not a +gate.** The reason: it provides no blast-radius boundary and no egress +enforcement, so everything an agent can do, it can do to the whole machine. +The container and microVM tiers exist because that boundary is worth having +(DL-024's blast-radius doctrine). Compass therefore *recommends* graduating +to a container-backed tier, and the onboarding surfaces say so — but the host +tier is not crippled, feature-gated, or nagged into disuse to force the move. +A user who stays on the host tier indefinitely is a supported user, strictly +better off than on a bare CLI agent at the same exposure. + +The same guidance-not-gate posture holds one tier up: podman is the permanent +self-host entry tier, microVM the recommended upgrade — recommended in the +docs, never required for single-tenant self-host. + +## Not yet specified + +This spec mixes current behavior with ruled strategy. The line: + +- **Current:** the podman and microVM backends behind `SelectBackend` + (`go/internal/runtime/microvm.go:117-125`), podman as the default, egress + enforcement on both, and the trust-model split itself (DL-325, Active). +- **Not yet built:** the **host tier** in its entirety — there is today no + host/process backend and no `"host"` value in `SelectBackend`. Its design + lives in the + [host runtime tier record](../../designs/infra/runtime/compass-host-runtime-tier/design.md). +- **Not yet built:** the embedded-local front door's app architecture + (DL-319/DL-320's dual-mode revival) is designed in the compass-native + lane's embedded-revival record and lands there. +- **Future work, not designed:** an OS-sandbox egress mode for the host tier + (bubblewrap / sandbox-exec) is a possible later addition; nothing in this + spec depends on it. diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 77d0d0ff6..a0ad6831c 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -209,7 +209,7 @@ type canaryBooter interface { // startup error naming the concrete type — never a silent skip, so a backend // added without a preflight surfaces loudly at launch rather than running // unchecked. -func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime) error { +func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error { switch e := engine.(type) { case microVMPreflighter: return runMicroVMPreflight(ctx, e, engine) @@ -226,7 +226,7 @@ func verifyBackendPreflight(ctx context.Context, engine runtime.ContainerRuntime // satisfies microVMPreflighter but not canaryBooter is a fail-closed startup // error naming the type, never a silent skip (same posture as the neither-probe // default). Split out so verifyBackendPreflight stays within funlen. -func runMicroVMPreflight(ctx context.Context, pre microVMPreflighter, engine runtime.ContainerRuntime) error { +func runMicroVMPreflight(ctx context.Context, pre microVMPreflighter, engine runtime.WorkloadRuntime) error { if err := pre.VerifyMicroVMSupport(ctx); err != nil { return err } @@ -330,7 +330,7 @@ func registerBackendFlags() backendFlags { // selectEngine resolves the configured runtime backend from the parsed flags // and their environment fallbacks. -func (f backendFlags) selectEngine() (runtime.ContainerRuntime, error) { +func (f backendFlags) selectEngine() (runtime.WorkloadRuntime, error) { cfg, err := f.backendConfig() if err != nil { return nil, err diff --git a/go/cmd/compass-runner/main_test.go b/go/cmd/compass-runner/main_test.go index 67d0b4e4b..d2d8dd945 100644 --- a/go/cmd/compass-runner/main_test.go +++ b/go/cmd/compass-runner/main_test.go @@ -75,9 +75,9 @@ func TestParseMount(t *testing.T) { } // podmanOnlyEngine exposes only the podman probe; its embedded nil -// ContainerRuntime satisfies the param type but is never called. +// WorkloadRuntime satisfies the param type but is never called. type podmanOnlyEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool err error } @@ -92,7 +92,7 @@ func (e podmanOnlyEngine) VerifyUsernsRemapSupport(context.Context) error { // static check passes, so a fake missing BootCanary would trip the fail-closed // canary assertion rather than exercise the static-probe dispatch. type microVMOnlyEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool canaryCalled *bool err error @@ -116,7 +116,7 @@ func (e microVMOnlyEngine) BootCanary(context.Context) (runtime.CanaryReport, er // a microVM backend that cannot boot-canary. The gate must fail closed on it, // naming the type, never silently skipping the canary. type microVMNoCanaryEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime called *bool err error } @@ -126,16 +126,16 @@ func (e microVMNoCanaryEngine) VerifyMicroVMSupport(context.Context) error { return e.err } -// neitherEngine exposes no probe: only the embedded (nil) ContainerRuntime. +// neitherEngine exposes no probe: only the embedded (nil) WorkloadRuntime. type neitherEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime } // bothProbesEngine exposes BOTH probes. No real engine does today, but it locks // the microVM-first precedence of verifyBackendPreflight's type switch: the // microVM branch must win and the podman branch must not run. type bothProbesEngine struct { - runtime.ContainerRuntime + runtime.WorkloadRuntime microVMCalled *bool podmanCalled *bool err error diff --git a/go/internal/compute/compute.go b/go/internal/compute/compute.go index ce76bcfc7..df9b54f42 100644 --- a/go/internal/compute/compute.go +++ b/go/internal/compute/compute.go @@ -14,7 +14,7 @@ // touching a caller. // - inplace.go — the S1 backend: an in-environment local-exec passthrough that // runs the op in the session's own environment at its current size, by -// delegating to the injected runtime.ContainerRuntime against the session +// delegating to the injected runtime.WorkloadRuntime against the session // container. The genuinely trivial fused configuration the design lands S1 // end to end with. // - routing.go — the fail-closed routing-policy shell (Global Constraint 3): a @@ -24,7 +24,7 @@ // invariants and are implemented and tested now. // // Reserved-not-implemented surface: ExecStreaming is declared in the interface -// now (mirroring runtime.ContainerRuntime.ExecStreaming) so a later streaming +// now (mirroring runtime.WorkloadRuntime.ExecStreaming) so a later streaming // consumer lands no interface change, but the S1 backend returns an honest // not-implemented sentinel rather than a silent no-op. // @@ -105,7 +105,7 @@ type ComputeRuntime interface { // ExecStreaming is reserved: it will run a long-lived streaming op returning // its live stdio pipes plus a kill/wait handle, mirroring - // runtime.ContainerRuntime.ExecStreaming. It is declared in the interface now + // runtime.WorkloadRuntime.ExecStreaming. It is declared in the interface now // so a later streaming consumer lands no interface change; the S1 backend // returns ErrExecStreamingNotImplemented rather than silently succeeding. ExecStreaming(ctx context.Context, spec ComputeSpec) (*runtime.StreamingExec, error) diff --git a/go/internal/compute/compute_test.go b/go/internal/compute/compute_test.go index 19489439f..33f6a793c 100644 --- a/go/internal/compute/compute_test.go +++ b/go/internal/compute/compute_test.go @@ -78,18 +78,18 @@ func TestInPlaceSatisfiesContract(t *testing.T) { for _, tc := range contractCases { t.Run(tc.name, func(t *testing.T) { eng := &recordingEngine{output: runtime.ExecOutput{ExitCode: 0}} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) tc.run(t, cr) }) } } -// recordingEngine is a runtime.ContainerRuntime that records the id and ExecSpec +// recordingEngine is a runtime.WorkloadRuntime that records the id and ExecSpec // its Exec receives, so the in-place backend's mapping (command, workdir, env) // and its target container can be asserted without a real container. Every other // method is an unused stub — the in-place backend only ever calls Exec. type recordingEngine struct { - gotID runtime.ContainerID + gotID runtime.WorkloadID gotSpec runtime.ExecSpec gotCtx context.Context execN int @@ -97,7 +97,7 @@ type recordingEngine struct { execErr error } -func (e *recordingEngine) Exec(ctx context.Context, id runtime.ContainerID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { +func (e *recordingEngine) Exec(ctx context.Context, id runtime.WorkloadID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { e.gotID = id e.gotSpec = spec e.gotCtx = ctx @@ -105,25 +105,25 @@ func (e *recordingEngine) Exec(ctx context.Context, id runtime.ContainerID, spec return e.output, e.execErr } -func (e *recordingEngine) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { +func (e *recordingEngine) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { return "", errors.New("recordingEngine: Create unused") } -func (e *recordingEngine) Start(context.Context, runtime.ContainerID) error { +func (e *recordingEngine) Start(context.Context, runtime.WorkloadID) error { return errors.New("recordingEngine: Start unused") } -func (e *recordingEngine) ExecStreaming(context.Context, runtime.ContainerID, runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (e *recordingEngine) ExecStreaming(context.Context, runtime.WorkloadID, runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { return nil, errors.New("recordingEngine: ExecStreaming unused") } -func (e *recordingEngine) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (e *recordingEngine) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return errors.New("recordingEngine: Stop unused") } -func (e *recordingEngine) Remove(context.Context, runtime.ContainerID) error { +func (e *recordingEngine) Remove(context.Context, runtime.WorkloadID) error { return errors.New("recordingEngine: Remove unused") } func (e *recordingEngine) Exists(context.Context, string) (bool, error) { return false, errors.New("recordingEngine: Exists unused") } -func (e *recordingEngine) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (e *recordingEngine) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", errors.New("recordingEngine: MountLabel unused") } @@ -131,7 +131,7 @@ func (e *recordingEngine) MountLabel(context.Context, runtime.ContainerID) (stri // Resize verb the in-place backend never calls, so the fake carries it to stay // a total implementation of the engine interface as that interface freezes the // verb in. -func (e *recordingEngine) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (e *recordingEngine) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return errors.New("recordingEngine: Resize unused") } @@ -141,7 +141,7 @@ func (e *recordingEngine) Resize(context.Context, runtime.ContainerID, runtime.R // targeted the wrong container would fail here. func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { eng := &recordingEngine{output: runtime.ExecOutput{Stdout: "ok", ExitCode: 0}} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) spec := ComputeSpec{ Command: []string{"go", "test", "./..."}, @@ -158,7 +158,7 @@ func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { if eng.execN != 1 { t.Fatalf("engine.Exec called %d times, want 1", eng.execN) } - if eng.gotID != runtime.ContainerID("sess-container") { + if eng.gotID != runtime.WorkloadID("sess-container") { t.Fatalf("delegated to container %q, want session container", eng.gotID) } if !slices.Equal(eng.gotSpec.Command, spec.Command) { @@ -182,7 +182,7 @@ func TestInPlaceExecMapsSpecAndDelegatesToSessionContainer(t *testing.T) { // directory is the intended behavior. func TestInPlaceExecOmitsWorkdirWhenDirEmpty(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"true"}}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -200,7 +200,7 @@ func TestInPlaceExecOmitsWorkdirWhenDirEmpty(t *testing.T) { func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { t.Run("positive timeout sets a deadline", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"go", "test"}, Timeout: 30 * time.Second}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -215,7 +215,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { }) t.Run("zero timeout leaves ctx untouched", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) if _, err := cr.Exec(context.Background(), ComputeSpec{Command: []string{"true"}}); err != nil { t.Fatalf("Exec returned error: %v", err) @@ -226,7 +226,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { }) t.Run("caller deadline shorter than timeout wins", func(t *testing.T) { eng := &recordingEngine{} - cr := NewInPlace(eng, runtime.ContainerID("sess-container"), runtime.EgressPolicy{}) + cr := NewInPlace(eng, runtime.WorkloadID("sess-container"), runtime.EgressPolicy{}) // A caller ctx already bounded tighter than spec.Timeout: the effective // deadline must stay the caller's, never be pushed out to now+Timeout. @@ -249,7 +249,7 @@ func TestInPlaceExecAppliesTimeoutAsContextDeadline(t *testing.T) { // fake and the real backend (a compile-time check that would break if the // interface drifted from the implementations). var ( - _ ComputeRuntime = fakeCompute{} - _ ComputeRuntime = (*InPlace)(nil) - _ runtime.ContainerRuntime = (*recordingEngine)(nil) + _ ComputeRuntime = fakeCompute{} + _ ComputeRuntime = (*InPlace)(nil) + _ runtime.WorkloadRuntime = (*recordingEngine)(nil) ) diff --git a/go/internal/compute/inplace.go b/go/internal/compute/inplace.go index 6ac623fc1..1b630fc27 100644 --- a/go/internal/compute/inplace.go +++ b/go/internal/compute/inplace.go @@ -27,14 +27,14 @@ var ErrExecStreamingNotImplemented = errors.New("compute: ExecStreaming not impl // handle is exercised (the passthrough runs in place, arming nothing new); the // engine and egress are held for the heavier backends behind the same seam. type InPlace struct { - engine runtime.ContainerRuntime - container runtime.ContainerID + engine runtime.WorkloadRuntime + container runtime.WorkloadID egress runtime.EgressPolicy } // NewInPlace builds the S1 in-place backend bound to a session's container- // runtime engine, its container handle, and its egress policy. -func NewInPlace(engine runtime.ContainerRuntime, container runtime.ContainerID, egress runtime.EgressPolicy) *InPlace { +func NewInPlace(engine runtime.WorkloadRuntime, container runtime.WorkloadID, egress runtime.EgressPolicy) *InPlace { return &InPlace{engine: engine, container: container, egress: egress} } diff --git a/go/internal/gen/compass/v1/guest_control.pb.go b/go/internal/gen/compass/v1/guest_control.pb.go index 3442c8407..0d0d07d7f 100644 --- a/go/internal/gen/compass/v1/guest_control.pb.go +++ b/go/internal/gen/compass/v1/guest_control.pb.go @@ -833,10 +833,10 @@ type ProvisionRequest struct { // skips the arm and is a hermetic test seam (the host production path always // sends a non-empty default-deny ruleset). NftScript string `protobuf:"bytes,1,opt,name=nft_script,json=nftScript,proto3" json:"nft_script,omitempty"` - // default_exec_uid is the session's agent uid (ContainerSpec.UID), the + // default_exec_uid is the session's agent uid (WorkloadSpec.UID), the // default for an exec with no uid. Validated non-zero. DefaultExecUid uint32 `protobuf:"varint,2,opt,name=default_exec_uid,json=defaultExecUid,proto3" json:"default_exec_uid,omitempty"` - // base_env is the base environment every exec inherits (ContainerSpec.Env). + // base_env is the base environment every exec inherits (WorkloadSpec.Env). BaseEnv map[string]string `protobuf:"bytes,3,rep,name=base_env,json=baseEnv,proto3" json:"base_env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/go/internal/runner/agent_exec.go b/go/internal/runner/agent_exec.go index 1e149cb9a..402a320d3 100644 --- a/go/internal/runner/agent_exec.go +++ b/go/internal/runner/agent_exec.go @@ -133,7 +133,7 @@ func (s *AgentStream) SessionID() string { return s.sessionID } // carries the identity and configuration the exec runs with. The returned // AgentStream lives until Stop or ctx cancellation terminates the in-container // agent. -func (l *ServerLink) StartAgent(ctx context.Context, sessionID string, id runtime.ContainerID, engine runtime.ContainerRuntime, env AgentEnv, log *slog.Logger) (*AgentStream, error) { +func (l *ServerLink) StartAgent(ctx context.Context, sessionID string, id runtime.WorkloadID, engine runtime.WorkloadRuntime, env AgentEnv, log *slog.Logger) (*AgentStream, error) { if log == nil { log = slog.Default() } diff --git a/go/internal/runner/agent_exec_test.go b/go/internal/runner/agent_exec_test.go index 2852cd125..a66c2b2ff 100644 --- a/go/internal/runner/agent_exec_test.go +++ b/go/internal/runner/agent_exec_test.go @@ -71,7 +71,7 @@ func TestStderrFloodDoesNotStallTheAgent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-flood", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-flood", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -127,7 +127,7 @@ func TestStdoutFrameIsNotPublishedAfterCutover(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-cutover", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-cutover", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -180,7 +180,7 @@ func TestStdoutIsDrainedToDiagnosticLog(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-drain", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-drain", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -216,7 +216,7 @@ func TestStderrIsDrainedUnderItsOwnLabel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-err", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-err", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -262,7 +262,7 @@ func TestBareTrailingCarriageReturnIsPayload(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-cr", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-cr", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -294,7 +294,7 @@ func TestOverlongLineTruncatesButKeepsDraining(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - if _, err := link.StartAgent(ctx, "sess-long", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { + if _, err := link.StartAgent(ctx, "sess-long", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()); err != nil { t.Fatalf("StartAgent = %v", err) } @@ -407,7 +407,7 @@ func TestCleanStopEmitsNoDrainWarning(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-quiet", runtime.ContainerID("c1"), engine, testAgentEnv(), logs.logger()) + stream, err := link.StartAgent(ctx, "sess-quiet", runtime.WorkloadID("c1"), engine, testAgentEnv(), logs.logger()) if err != nil { t.Fatalf("StartAgent = %v", err) } @@ -446,7 +446,7 @@ func TestStopArmsTheStoppingDiscriminator(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-armed", runtime.ContainerID("c1"), engine, testAgentEnv(), discardLoggerRunner()) + stream, err := link.StartAgent(ctx, "sess-armed", runtime.WorkloadID("c1"), engine, testAgentEnv(), discardLoggerRunner()) if err != nil { t.Fatalf("StartAgent = %v", err) } @@ -483,7 +483,7 @@ func TestSelfExitReleasesTheDrainCtx(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - stream, err := link.StartAgent(ctx, "sess-selfexit", runtime.ContainerID("c1"), engine, testAgentEnv(), discardLoggerRunner()) + stream, err := link.StartAgent(ctx, "sess-selfexit", runtime.WorkloadID("c1"), engine, testAgentEnv(), discardLoggerRunner()) if err != nil { t.Fatalf("StartAgent = %v", err) } diff --git a/go/internal/runner/config_refresh_test.go b/go/internal/runner/config_refresh_test.go index fc0b13115..b7623fc60 100644 --- a/go/internal/runner/config_refresh_test.go +++ b/go/internal/runner/config_refresh_test.go @@ -26,7 +26,7 @@ import ( "github.com/RigelBuild/compass/go/internal/runtime" ) -// configFanoutRuntime is a ContainerRuntime for the RefreshConfig fan-out tests. +// configFanoutRuntime is a WorkloadRuntime for the RefreshConfig fan-out tests. // Create returns the container NAME as its engine id (per-container-unique, so a // per-container label and a per-container Reload count are distinguishable — // stubStreamingRuntime's fixed "fake-id" would alias every container onto one), @@ -50,15 +50,15 @@ func newConfigFanoutRuntime(t *testing.T) *configFanoutRuntime { } } -func (r *configFanoutRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (r *configFanoutRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { r.mu.Lock() r.calls = append(r.calls, "create") r.created = append(r.created, spec) r.mu.Unlock() - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } -func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.ContainerID) (string, error) { +func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.WorkloadID) (string, error) { r.mu.Lock() defer r.mu.Unlock() if err := r.labelErrs[string(id)]; err != nil { @@ -67,7 +67,7 @@ func (r *configFanoutRuntime) MountLabel(_ context.Context, id runtime.Container return r.labels[string(id)], nil } -func (r *configFanoutRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (r *configFanoutRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { r.mu.Lock() r.execByID[string(id)]++ r.mu.Unlock() diff --git a/go/internal/runner/e2e_vsock_gateway_microvm_test.go b/go/internal/runner/e2e_vsock_gateway_microvm_test.go index f4918ae51..582c21fb1 100644 --- a/go/internal/runner/e2e_vsock_gateway_microvm_test.go +++ b/go/internal/runner/e2e_vsock_gateway_microvm_test.go @@ -442,7 +442,7 @@ type probeResult struct { // single line `RESULT ` the host parses for the echoed call id. A // transport/exec error is a harness fault (t.Fatalf); a completed exec whose // body carries the result is the round-trip proof. -func runInGuestProbe(t *testing.T, m *runtime.MicroVMRuntime, id runtime.ContainerID, callID string) probeResult { +func runInGuestProbe(t *testing.T, m *runtime.MicroVMRuntime, id runtime.WorkloadID, callID string) probeResult { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), inGuestProbeTimeout) defer cancel() @@ -526,7 +526,7 @@ func parseProbeResult(t *testing.T, stdout string) probeResult { // dropped SYN hangs until the guest timeout fires (exit 124, unreachable); an // allowed host completes (exit 0, "connected"). Any exec/transport error is a // harness fault. -func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.ContainerID, ip string) bool { +func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.WorkloadID, ip string) bool { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), inGuestProbeTimeout) defer cancel() @@ -540,10 +540,10 @@ func inGuestCanReachIP(t *testing.T, m *runtime.MicroVMRuntime, id runtime.Conta return reached } -// resolveContainerID reads the engine ContainerID the registry bound for the +// resolveContainerID reads the engine WorkloadID the registry bound for the // provisioned name — the id in-guest execs address. White-box: the registry is // the runner's own, and the id is otherwise unexported from Provision's return. -func resolveContainerID(t *testing.T, h *agentHost, name string) runtime.ContainerID { +func resolveContainerID(t *testing.T, h *agentHost, name string) runtime.WorkloadID { t.Helper() handle, ok := h.registry.Resolve(name) if !ok { diff --git a/go/internal/runner/helpers_test.go b/go/internal/runner/helpers_test.go index 871bebb82..111ce8451 100644 --- a/go/internal/runner/helpers_test.go +++ b/go/internal/runner/helpers_test.go @@ -3,7 +3,7 @@ package runner // Shared scaffolding for the Runner-side seam tests: a pipe-backed fake -// ContainerRuntime (the existing runtime.fakeRuntime.ExecStreaming is a nil-pipe +// WorkloadRuntime (the existing runtime.fakeRuntime.ExecStreaming is a nil-pipe // stub — this one returns a StreamingExec whose IO.Stdout/IO.Stderr are // io.PipeReaders the test writes into), a recording fake runtime for the // Provision→Launch path, a capturing slog handler for the drain's log lines, a @@ -58,7 +58,7 @@ type nopWriteCloser struct{} func (nopWriteCloser) Write(p []byte) (int, error) { return len(p), nil } func (nopWriteCloser) Close() error { return nil } -// pipeRuntime is a ContainerRuntime whose ExecStreaming returns a StreamingExec +// pipeRuntime is a WorkloadRuntime whose ExecStreaming returns a StreamingExec // backed by real in-memory pipes: the test writes lines into stdoutW / stderrW // and the drains read them off IO.Stdout / IO.Stderr. Its // lifecycle methods (Create/Start/Exec/…) are recording no-ops so it also serves @@ -80,19 +80,19 @@ func newPipeRuntime() *pipeRuntime { return &pipeRuntime{stdoutW: outW, stderrW: errW, stdoutR: outR, stderrR: errR} } -func (f *pipeRuntime) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *pipeRuntime) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { f.record("create") - return runtime.ContainerID("fake-id"), nil + return runtime.WorkloadID("fake-id"), nil } -func (f *pipeRuntime) Start(context.Context, runtime.ContainerID) error { +func (f *pipeRuntime) Start(context.Context, runtime.WorkloadID) error { f.record("start") return nil } -func (f *pipeRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *pipeRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { f.record("exec") return runtime.ExecOutput{}, nil } -func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.ContainerID, _ runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.WorkloadID, _ runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { f.record("exec_streaming") if f.execErr != nil { return nil, f.execErr @@ -103,19 +103,19 @@ func (f *pipeRuntime) ExecStreaming(_ context.Context, _ runtime.ContainerID, _ // fake must not call AgentStream.Stop. }, nil } -func (f *pipeRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *pipeRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { f.record("stop") return nil } -func (f *pipeRuntime) Remove(context.Context, runtime.ContainerID) error { +func (f *pipeRuntime) Remove(context.Context, runtime.WorkloadID) error { f.record("remove") return nil } func (f *pipeRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *pipeRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *pipeRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *pipeRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *pipeRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } @@ -129,7 +129,7 @@ func (f *pipeRuntime) record(call string) { // exit / EOF). func (f *pipeRuntime) closeStdout() { _ = f.stdoutW.Close() } -// stubStreamingRuntime is a ContainerRuntime whose lifecycle methods are +// stubStreamingRuntime is a WorkloadRuntime whose lifecycle methods are // recording no-ops and whose ExecStreaming delegates to a real PodmanCLI driving // a shell stub — so it returns a StreamingExec with a REAL, terminatable // Process (the host's Reload / live-session Stop call Process.Terminate, which a @@ -142,14 +142,14 @@ type stubStreamingRuntime struct { calls []string execSpecs []runtime.StreamingExecSpec cli *runtime.PodmanCLI - stopErr error // when set, engine Stop fails — models a Teardown partial failure - stopErrByID map[runtime.ContainerID]error // per-container Stop error; overrides stopErr for the keyed id - stopGate chan struct{} // when non-nil, Stop blocks on it (after recording) — test-controlled teardown parking - stopEntered chan runtime.ContainerID // when non-nil, Stop sends id after recording, before parking — a real "reached Stop" event for a test to gate on - callsByID map[runtime.ContainerID][]string // per-container lifecycle calls (stop/remove), for fan-out isolation assertions - execGate chan struct{} // when non-nil, ExecStreaming blocks on it (after recording, ctx-escapable) — parks a Start/Reload relaunch so a concurrent-dispatch test can hold one lifecycle op in flight (docs/designs/infra/runtime/compass-runner-concurrent-dispatch/design.md) - execEntered chan runtime.ContainerID // when non-nil, ExecStreaming sends id after recording, before parking — the real "reached the agent launch" event a test gates on - created []runtime.ContainerSpec + stopErr error // when set, engine Stop fails — models a Teardown partial failure + stopErrByID map[runtime.WorkloadID]error // per-container Stop error; overrides stopErr for the keyed id + stopGate chan struct{} // when non-nil, Stop blocks on it (after recording) — test-controlled teardown parking + stopEntered chan runtime.WorkloadID // when non-nil, Stop sends id after recording, before parking — a real "reached Stop" event for a test to gate on + callsByID map[runtime.WorkloadID][]string // per-container lifecycle calls (stop/remove), for fan-out isolation assertions + execGate chan struct{} // when non-nil, ExecStreaming blocks on it (after recording, ctx-escapable) — parks a Start/Reload relaunch so a concurrent-dispatch test can hold one lifecycle op in flight (docs/designs/infra/runtime/compass-runner-concurrent-dispatch/design.md) + execEntered chan runtime.WorkloadID // when non-nil, ExecStreaming sends id after recording, before parking — the real "reached the agent launch" event a test gates on + created []runtime.WorkloadSpec } func newStubStreamingRuntime(t *testing.T) *stubStreamingRuntime { @@ -163,22 +163,22 @@ func newStubStreamingRuntime(t *testing.T) *stubStreamingRuntime { return &stubStreamingRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog)} } -func (f *stubStreamingRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *stubStreamingRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { f.mu.Lock() f.calls = append(f.calls, "create") f.created = append(f.created, spec) f.mu.Unlock() - return runtime.ContainerID("fake-id"), nil + return runtime.WorkloadID("fake-id"), nil } -func (f *stubStreamingRuntime) Start(context.Context, runtime.ContainerID) error { +func (f *stubStreamingRuntime) Start(context.Context, runtime.WorkloadID) error { f.record("start") return nil } -func (f *stubStreamingRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *stubStreamingRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { f.record("exec") return runtime.ExecOutput{}, nil } -func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { f.mu.Lock() f.calls = append(f.calls, "exec_streaming") f.execSpecs = append(f.execSpecs, spec) @@ -202,7 +202,7 @@ func (f *stubStreamingRuntime) ExecStreaming(ctx context.Context, id runtime.Con } return f.cli.ExecStreaming(ctx, id, spec) } -func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.ContainerID, _ time.Duration) error { +func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.WorkloadID, _ time.Duration) error { f.record("stop") f.recordForID(id, "stop") f.mu.Lock() @@ -226,16 +226,16 @@ func (f *stubStreamingRuntime) Stop(_ context.Context, id runtime.ContainerID, _ f.mu.Unlock() return err } -func (f *stubStreamingRuntime) Remove(_ context.Context, id runtime.ContainerID) error { +func (f *stubStreamingRuntime) Remove(_ context.Context, id runtime.WorkloadID) error { f.record("remove") f.recordForID(id, "remove") return nil } func (f *stubStreamingRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *stubStreamingRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *stubStreamingRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *stubStreamingRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *stubStreamingRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } @@ -248,11 +248,11 @@ func (f *stubStreamingRuntime) record(call string) { // recordForID records a lifecycle call against a specific container id, so a // fan-out isolation assertion can prove one container reached remove while // another aborted at stop. -func (f *stubStreamingRuntime) recordForID(id runtime.ContainerID, call string) { +func (f *stubStreamingRuntime) recordForID(id runtime.WorkloadID, call string) { f.mu.Lock() defer f.mu.Unlock() if f.callsByID == nil { - f.callsByID = map[runtime.ContainerID][]string{} + f.callsByID = map[runtime.WorkloadID][]string{} } f.callsByID[id] = append(f.callsByID[id], call) } @@ -260,7 +260,7 @@ func (f *stubStreamingRuntime) recordForID(id runtime.ContainerID, call string) // countCallForID reports how many times the named lifecycle call was recorded // for a specific container id, taken under the lock — mirrors countCall for the // per-container fan-out assertions. -func (f *stubStreamingRuntime) countCallForID(id runtime.ContainerID, call string) int { +func (f *stubStreamingRuntime) countCallForID(id runtime.WorkloadID, call string) int { f.mu.Lock() defer f.mu.Unlock() n := 0 @@ -306,10 +306,10 @@ func (f *stubStreamingRuntime) countCall(call string) int { // createdSpecs returns a copy of the ContainerSpecs the host has created // containers with so far, taken under the lock. -func (f *stubStreamingRuntime) createdSpecs() []runtime.ContainerSpec { +func (f *stubStreamingRuntime) createdSpecs() []runtime.WorkloadSpec { f.mu.Lock() defer f.mu.Unlock() - return append([]runtime.ContainerSpec(nil), f.created...) + return append([]runtime.WorkloadSpec(nil), f.created...) } // --- capturing PublishEvents server ------------------------------------------ diff --git a/go/internal/runner/host.go b/go/internal/runner/host.go index 24f6bdd5e..37e19c507 100644 --- a/go/internal/runner/host.go +++ b/go/internal/runner/host.go @@ -54,7 +54,7 @@ type SpecBuilder interface { // RefreshConfig type-assert h.engine against it to gate the microVM-specific // serving/refresh legs; podman and every test fake lack the method, so their // paths stay byte-identical (record §(c), Global Constraints — never a verb on -// the frozen ContainerRuntime interface). +// the frozen WorkloadRuntime interface). type vsockGatewayEngine interface { AgentGatewayEndpoint(name string) (endpoint string, ok bool) } @@ -65,7 +65,7 @@ type agentHost struct { link *ServerLink runtime *runtime.AgentRuntime registry *runtime.AgentRegistry - engine runtime.ContainerRuntime + engine runtime.WorkloadRuntime specs SpecBuilder log *slog.Logger runtimeDir string @@ -110,7 +110,7 @@ type agentHost struct { type liveSession struct { sessionID string containerName string - containerID runtime.ContainerID + containerID runtime.WorkloadID stream *AgentStream state compassv1.AgentSessionState // agentAccountID is the owned agent account this session belongs to, copied @@ -136,7 +136,7 @@ type AgentHostConfig struct { // runtime + registry (so a launched container resolves by name), the container // engine, the spec builder Provision derives its AgentSpec from, and the host's // own config. newID mints session ids; nil uses a monotonic counter. -func NewSessionHost(link *ServerLink, rt *runtime.AgentRuntime, registry *runtime.AgentRegistry, engine runtime.ContainerRuntime, specs SpecBuilder, cfg AgentHostConfig, log *slog.Logger, newID func() string) SessionHost { +func NewSessionHost(link *ServerLink, rt *runtime.AgentRuntime, registry *runtime.AgentRegistry, engine runtime.WorkloadRuntime, specs SpecBuilder, cfg AgentHostConfig, log *slog.Logger, newID func() string) SessionHost { if log == nil { log = slog.Default() } @@ -765,7 +765,7 @@ func (h *agentHost) RefreshConfig(ctx context.Context) error { type target struct { sessionID string containerName string - containerID runtime.ContainerID + containerID runtime.WorkloadID lastVersion string } h.mu.Lock() @@ -857,7 +857,7 @@ func (h *agentHost) teardownContainer(ctx context.Context, containerName string) // without aborting the fleet; the tracked version advances only after a // successful reload. Split out of RefreshConfig so the container lock scopes to // exactly one leg via defer. -func (h *agentHost) refreshOneContainer(ctx context.Context, sessionID, containerName string, containerID runtime.ContainerID, lastVersion string) error { +func (h *agentHost) refreshOneContainer(ctx context.Context, sessionID, containerName string, containerID runtime.WorkloadID, lastVersion string) error { unlock := h.lockContainer(containerName) defer unlock() diff --git a/go/internal/runner/host_concurrency_test.go b/go/internal/runner/host_concurrency_test.go index 40ae3a2fe..e958573f3 100644 --- a/go/internal/runner/host_concurrency_test.go +++ b/go/internal/runner/host_concurrency_test.go @@ -78,7 +78,7 @@ func TestStartSameContainerSerializesClosingTOCTOU(t *testing.T) { // launch never blocks the send). Released on every exit path so a failing // assertion cannot hang the suite. gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) engine.mu.Lock() engine.execGate = gate engine.execEntered = entered @@ -170,7 +170,7 @@ func TestStartDifferentContainersOverlap(t *testing.T) { } gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) engine.mu.Lock() engine.execGate = gate engine.execEntered = entered @@ -190,7 +190,7 @@ func TestStartDifferentContainersOverlap(t *testing.T) { // overlap, so both entry events fire while both are parked on the shared gate. // A GLOBAL lock would admit only one; the second event never fires and this // times out on the ceiling. - seen := map[runtime.ContainerID]bool{} + seen := map[runtime.WorkloadID]bool{} for range 2 { select { case id := <-entered: diff --git a/go/internal/runner/host_test.go b/go/internal/runner/host_test.go index a5507931d..97b7d10c9 100644 --- a/go/internal/runner/host_test.go +++ b/go/internal/runner/host_test.go @@ -322,7 +322,7 @@ func TestProvisionPerContainerConfigRootsAreDistinct(t *testing.T) { t.Fatalf("engine created %d containers, want 2", len(created)) } - configMount := func(spec runtime.ContainerSpec) *runtime.Mount { + configMount := func(spec runtime.WorkloadSpec) *runtime.Mount { for i := range spec.Mounts { if spec.Mounts[i].ContainerPath == agentConfigMountPath { return &spec.Mounts[i] @@ -675,11 +675,11 @@ func TestCloseIsBestEffortOnStopError(t *testing.T) { nameA := provisionAndStart(t, host, "a") nameB := provisionAndStart(t, host, "b") - // The engine keys container ids by name (Create returns ContainerID(spec.Name)), + // The engine keys container ids by name (Create returns WorkloadID(spec.Name)), // so fail exactly container A's Stop and leave B healthy. - idA := runtime.ContainerID(nameA) - idB := runtime.ContainerID(nameB) - engine.stopErrByID = map[runtime.ContainerID]error{idA: errors.New("engine stop failed")} + idA := runtime.WorkloadID(nameA) + idB := runtime.WorkloadID(nameB) + engine.stopErrByID = map[runtime.WorkloadID]error{idA: errors.New("engine stop failed")} host.Close(ctx) @@ -727,7 +727,7 @@ func TestCloseJoinsConcurrentTeardowns(t *testing.T) { // and then park without waiting for the test to read. The gate is released on // every exit path (including a failing assertion) so the suite can't hang. gate := make(chan struct{}) - entered := make(chan runtime.ContainerID, 2) + entered := make(chan runtime.WorkloadID, 2) var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(gate) }) } t.Cleanup(release) diff --git a/go/internal/runner/host_vsock_gateway_test.go b/go/internal/runner/host_vsock_gateway_test.go index 6ee080385..41423e8ba 100644 --- a/go/internal/runner/host_vsock_gateway_test.go +++ b/go/internal/runner/host_vsock_gateway_test.go @@ -30,7 +30,7 @@ import ( "github.com/RigelBuild/compass/go/internal/runtime" ) -// vsockGatewayFakeRuntime is a ContainerRuntime that ALSO implements the +// vsockGatewayFakeRuntime is a WorkloadRuntime that ALSO implements the // vsockGatewayEngine probe (AgentGatewayEndpoint), so agentHost drives its // microVM Provision leg. Create returns the container name as its engine id (so // each container is distinguishable), and AgentGatewayEndpoint hands back a real @@ -57,12 +57,12 @@ func newVsockGatewayFakeRuntime(t *testing.T) *vsockGatewayFakeRuntime { } } -func (r *vsockGatewayFakeRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (r *vsockGatewayFakeRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { r.mu.Lock() r.calls = append(r.calls, "create") r.created = append(r.created, spec) r.mu.Unlock() - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } func (r *vsockGatewayFakeRuntime) AgentGatewayEndpoint(name string) (string, bool) { diff --git a/go/internal/runner/runner.go b/go/internal/runner/runner.go index 1bbb3e318..89d32e466 100644 --- a/go/internal/runner/runner.go +++ b/go/internal/runner/runner.go @@ -41,7 +41,7 @@ type RunnerConfig struct { // Token is the per-Runner bearer token presented on every RPC. Token string // Engine is the container runtime seam the Runner hosts agents on. - Engine runtime.ContainerRuntime + Engine runtime.WorkloadRuntime // RuntimeDir is the Runner-owned base directory under which per-container // agent sockets live (RuntimeDir/containers//agent.sock, OQ-5). // Owner-only; the socket is a local hop that never touches the network. diff --git a/go/internal/runner/secrets_refresh_test.go b/go/internal/runner/secrets_refresh_test.go index f02b71d79..06d1d30fe 100644 --- a/go/internal/runner/secrets_refresh_test.go +++ b/go/internal/runner/secrets_refresh_test.go @@ -97,7 +97,7 @@ func TestRefreshSecretsMaterializesForBoundSession(t *testing.T) { } } -// recordingExecRuntime is a ContainerRuntime whose ExecStreaming delegates to a +// recordingExecRuntime is a WorkloadRuntime whose ExecStreaming delegates to a // real terminatable child (so Start's agent relay works) and whose one-shot Exec // records the spec (so a materialize's exec is observable). It composes the // stub-streaming child with an exec recorder. @@ -113,7 +113,7 @@ func newRecordingExecRuntime(t *testing.T) *recordingExecRuntime { return &recordingExecRuntime{stubStreamingRuntime: newStubStreamingRuntime(t)} } -func (r *recordingExecRuntime) Exec(_ context.Context, _ runtime.ContainerID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { +func (r *recordingExecRuntime) Exec(_ context.Context, _ runtime.WorkloadID, spec runtime.ExecSpec) (runtime.ExecOutput, error) { r.mu.Lock() r.execSpecsOneShot = append(r.execSpecsOneShot, spec) err := r.execErr diff --git a/go/internal/runnerhub/integration_pgtest_test.go b/go/internal/runnerhub/integration_pgtest_test.go index b4ffe129f..9f9490535 100644 --- a/go/internal/runnerhub/integration_pgtest_test.go +++ b/go/internal/runnerhub/integration_pgtest_test.go @@ -22,7 +22,7 @@ package runnerhub_test // Post under it through the SAME PostMessage handler a human takes — so it // commits a Message row to Postgres (observed by reading it back under the agent // account) AND fans MessagePosted onto the comms bus (observed on a live -// subscription). The container is a stub ContainerRuntime (no real compass-agent +// subscription). The container is a stub WorkloadRuntime (no real compass-agent // image exists in CI) whose ExecStreaming spawns a live, terminatable child so // host.Stop reaps a real process. This is the seam terminating a real // agent-initiated wire and committing to a real store — an external-package @@ -352,7 +352,7 @@ func (r *integResolver) resolve(_ context.Context, presented string, want store. return r.subj, nil } -// integStubRuntime is the fake ContainerRuntime backing the Runner: its +// integStubRuntime is the fake WorkloadRuntime backing the Runner: its // ExecStreaming spawns a real, terminatable child (a shell-stub `podman` // exec-ing `sleep`) so host.Stop's Terminate reaps a live process and // StartAgent's pipe drains end on that reap. Post-#16 nothing rides @@ -374,28 +374,28 @@ func newIntegStubRuntime(t *testing.T) *integStubRuntime { return &integStubRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog)} } -func (f *integStubRuntime) Create(context.Context, runtime.ContainerSpec) (runtime.ContainerID, error) { - return runtime.ContainerID("fake-id"), nil +func (f *integStubRuntime) Create(context.Context, runtime.WorkloadSpec) (runtime.WorkloadID, error) { + return runtime.WorkloadID("fake-id"), nil } -func (f *integStubRuntime) Start(context.Context, runtime.ContainerID) error { return nil } -func (f *integStubRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *integStubRuntime) Start(context.Context, runtime.WorkloadID) error { return nil } +func (f *integStubRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { return runtime.ExecOutput{}, nil } -func (f *integStubRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *integStubRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { // A real streaming exec against the shell stub: a live, terminatable Process // (host.Stop → Terminate) whose stdout/stderr pipes StartAgent drains. The // stub just sleeps, so the pipes stay empty until Terminate closes them. return f.cli.ExecStreaming(ctx, id, spec) } -func (f *integStubRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *integStubRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return nil } -func (f *integStubRuntime) Remove(context.Context, runtime.ContainerID) error { return nil } -func (f *integStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *integStubRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *integStubRuntime) Remove(context.Context, runtime.WorkloadID) error { return nil } +func (f *integStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } +func (f *integStubRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *integStubRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *integStubRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index e8aaf85f4..02025682f 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -60,12 +60,12 @@ type AgentSpec struct { // created from, so callers can exec as the agent user without re-deriving the // workspace. type AgentHandle struct { - id ContainerID + id WorkloadID spec AgentSpec } // ID returns the resolved container id. -func (h *AgentHandle) ID() ContainerID { return h.id } +func (h *AgentHandle) ID() WorkloadID { return h.id } // Name returns the stable container name. func (h *AgentHandle) Name() string { return h.spec.Name } @@ -107,16 +107,16 @@ func (e *StageError) Error() string { // Unwrap exposes the underlying runtime error for errors.Is/As. func (e *StageError) Unwrap() error { return e.Err } -// InContainerError is an in-container exec that ran but exited non-zero, tagged -// with the lifecycle stage and carrying the captured stderr. -type InContainerError struct { +// InWorkloadError is an exec inside the workload that ran but exited non-zero, +// tagged with the lifecycle stage and carrying the captured stderr. +type InWorkloadError struct { Stage string ExitCode int Stderr string } -func (e *InContainerError) Error() string { - return fmt.Sprintf("%s failed inside the container (exit %d): %s", e.Stage, e.ExitCode, e.Stderr) +func (e *InWorkloadError) Error() string { + return fmt.Sprintf("%s failed inside the workload (exit %d): %s", e.Stage, e.ExitCode, e.Stderr) } // InvalidConfigError is an agent configuration the lifecycle rejected before @@ -138,35 +138,35 @@ func atStage(stage string, err error) error { return &StageError{Stage: stage, Err: err} } -// requireSuccess turns a non-zero in-container exec into an InContainerError +// requireSuccess turns a non-zero in-workload exec into an InWorkloadError // tagged with the stage, surfacing its captured stderr. func requireSuccess(stage string, out ExecOutput) error { if out.Success() { return nil } - return &InContainerError{Stage: stage, ExitCode: out.ExitCode, Stderr: out.Stderr} + return &InWorkloadError{Stage: stage, ExitCode: out.ExitCode, Stderr: out.Stderr} } -// AgentRuntime drives the per-agent container lifecycle over a ContainerRuntime. +// AgentRuntime drives the per-agent workload lifecycle over a WorkloadRuntime. // // When constructed with an AgentRegistry via NewAgentRuntimeWithRegistry, a // successful Launch registers the handle and Teardown deregisters it, so the // Runner's session RPCs can resolve a launched container by name. type AgentRuntime struct { - runtime ContainerRuntime + runtime WorkloadRuntime registry *AgentRegistry } // NewAgentRuntime builds a lifecycle façade with no registry: Launch/Teardown // manage containers but register nothing. -func NewAgentRuntime(runtime ContainerRuntime) *AgentRuntime { +func NewAgentRuntime(runtime WorkloadRuntime) *AgentRuntime { return &AgentRuntime{runtime: runtime} } // NewAgentRuntimeWithRegistry builds a façade that registers each launched // handle in registry so StartAgentSession can resolve the container by name, and // deregisters it on teardown. -func NewAgentRuntimeWithRegistry(runtime ContainerRuntime, registry *AgentRegistry) *AgentRuntime { +func NewAgentRuntimeWithRegistry(runtime WorkloadRuntime, registry *AgentRegistry) *AgentRuntime { return &AgentRuntime{runtime: runtime, registry: registry} } @@ -244,7 +244,7 @@ func (r *AgentRuntime) Teardown(ctx context.Context, handle *AgentHandle) error // as the aggregate env file. The path components are positional args to a fixed // sh script, never interpolated into the script text, so a crafted path cannot // inject shell. -func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id ContainerID, uid uint32, homeDir, relPath, body string) error { +func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id WorkloadID, uid uint32, homeDir, relPath, body string) error { script := `set -eu; umask 077; dir=$(dirname "$1"); mkdir -p "$dir"; cat > "$1"; chmod 600 "$1"` spec := NewExecSpec("sh", "-c", script, "sh", filepath.Join(homeDir, relPath)). AsUser(strconv.FormatUint(uint64(uid), 10)). @@ -259,8 +259,8 @@ func (r *AgentRuntime) WriteAgentFile(ctx context.Context, id ContainerID, uid u // createAndStart creates then starts the container, cleaning up a created but // unstarted container so a retry with the same name starts clean. -func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (ContainerID, error) { - container := ContainerSpec{ +func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (WorkloadID, error) { + container := WorkloadSpec{ Image: spec.Image, Name: spec.Name, CapAdd: []string{capNetAdmin}, @@ -291,7 +291,7 @@ func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (Cont // inGuestEgressArmer is a backend that arms the egress firewall itself, inside // its isolation boundary (as guest root, before the exec gate opens), so the // host-side armEgress exec must be skipped. It is a marker, deliberately NOT a -// verb on the frozen ContainerRuntime interface (podman.go): AgentRuntime probes +// verb on the frozen WorkloadRuntime interface (podman.go): AgentRuntime probes // for it and skips arming when a backend self-arms (design §(c)). Only // MicroVMRuntime implements it; PodmanCLI and the test fakes do not, so the // host-side arm runs byte-identically for them. @@ -304,7 +304,7 @@ type inGuestEgressArmer interface { // backend that self-arms egress in-guest (inGuestEgressArmer, the microVM // backend) has already armed by Start, so the host-side armEgress exec — which // on that backend would run capability-less and fail — is skipped. -func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec AgentSpec) error { +func (r *AgentRuntime) provision(ctx context.Context, id WorkloadID, spec AgentSpec) error { if armer, ok := r.runtime.(inGuestEgressArmer); !ok || !armer.EgressArmedInGuest() { if err := r.armEgress(ctx, id, spec.Egress); err != nil { return err @@ -319,7 +319,7 @@ func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec Agent // armEgress arms the egress firewall as the image's default user (uid 1000) // with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with no // capabilities — cannot alter the ruleset. -func (r *AgentRuntime) armEgress(ctx context.Context, id ContainerID, egress EgressPolicy) error { +func (r *AgentRuntime) armEgress(ctx context.Context, id WorkloadID, egress EgressPolicy) error { out, err := r.runtime.Exec(ctx, id, NewExecSpec("sh", "-c", egress.NftScript())) if err != nil { return atStage("arm egress", err) @@ -329,7 +329,7 @@ func (r *AgentRuntime) armEgress(ctx context.Context, id ContainerID, egress Egr // installCredentials installs the scoped git credential helper into the agent's // $HOME, as the agent user. A no-op when the workspace has no credentials. -func (r *AgentRuntime) installCredentials(ctx context.Context, id ContainerID, workspace Workspace) error { +func (r *AgentRuntime) installCredentials(ctx context.Context, id WorkloadID, workspace Workspace) error { script, err := workspace.CredentialSetupScript() if err != nil { return &InvalidConfigError{Err: err} @@ -355,7 +355,7 @@ func (r *AgentRuntime) installCredentials(ctx context.Context, id ContainerID, w // user, so an agent that self-clones post-launch has an owned working dir. Run // as the agent uid (not root) so the directory is owned by the agent. Its // precondition: CheckoutDir's parent must be writable by the agent uid. -func (r *AgentRuntime) ensureCheckoutDir(ctx context.Context, id ContainerID, workspace Workspace) error { +func (r *AgentRuntime) ensureCheckoutDir(ctx context.Context, id WorkloadID, workspace Workspace) error { spec := NewExecSpec("mkdir", "-p", workspace.CheckoutDir). AsUser(strconv.FormatUint(uint64(workspace.UID), 10)) out, err := r.runtime.Exec(ctx, id, spec) diff --git a/go/internal/runtime/agent_test.go b/go/internal/runtime/agent_test.go index 18b277027..d3f1b9289 100644 --- a/go/internal/runtime/agent_test.go +++ b/go/internal/runtime/agent_test.go @@ -34,21 +34,21 @@ func newFakeRuntime(t *testing.T) *fakeRuntime { return &fakeRuntime{t: t} } -func (f *fakeRuntime) Create(_ context.Context, spec ContainerSpec) (ContainerID, error) { +func (f *fakeRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, error) { f.record("create:" + spec.Name) // The container must carry NET_ADMIN so the entrypoint can arm nft. if !slices.Contains(spec.CapAdd, "NET_ADMIN") { f.t.Errorf("Create spec.CapAdd = %v, must contain NET_ADMIN so the entrypoint can arm the firewall", spec.CapAdd) } - return ContainerID("fake-id"), nil + return WorkloadID("fake-id"), nil } -func (f *fakeRuntime) Start(_ context.Context, _ ContainerID) error { +func (f *fakeRuntime) Start(_ context.Context, _ WorkloadID) error { f.record("start") return nil } -func (f *fakeRuntime) Exec(_ context.Context, _ ContainerID, spec ExecSpec) (ExecOutput, error) { +func (f *fakeRuntime) Exec(_ context.Context, _ WorkloadID, spec ExecSpec) (ExecOutput, error) { joined := strings.Join(spec.Command, " ") f.mu.Lock() f.calls = append(f.calls, "exec:"+joined) @@ -61,21 +61,21 @@ func (f *fakeRuntime) Exec(_ context.Context, _ ContainerID, spec ExecSpec) (Exe return ExecOutput{}, nil } -func (f *fakeRuntime) ExecStreaming(_ context.Context, _ ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (f *fakeRuntime) ExecStreaming(_ context.Context, _ WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { f.record("exec_streaming:" + strings.Join(spec.Command, " ")) // No T6 lifecycle test drives a streaming exec. Rather than synthesize pipe // handles it can't honestly back (or spawn a real `sh -c cat` the way the - // Rust fake does), the fake refuses — keeping the ContainerRuntime interface + // Rust fake does), the fake refuses — keeping the WorkloadRuntime interface // satisfied without leaking a real process. return nil, errors.New("fakeRuntime does not support streaming exec") } -func (f *fakeRuntime) Stop(_ context.Context, _ ContainerID, _ time.Duration) error { +func (f *fakeRuntime) Stop(_ context.Context, _ WorkloadID, _ time.Duration) error { f.record("stop") return nil } -func (f *fakeRuntime) Remove(_ context.Context, _ ContainerID) error { +func (f *fakeRuntime) Remove(_ context.Context, _ WorkloadID) error { f.record("remove") return nil } @@ -84,11 +84,11 @@ func (f *fakeRuntime) Exists(_ context.Context, _ string) (bool, error) { return false, nil } -func (f *fakeRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { +func (f *fakeRuntime) MountLabel(_ context.Context, _ WorkloadID) (string, error) { return "", nil } -func (f *fakeRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (f *fakeRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return nil } @@ -260,12 +260,12 @@ func TestFailedProvisionRemovesThePartialContainer(t *testing.T) { _, err := rt.Launch(t.Context(), specWithCreds(true)) - var inContainer *InContainerError + var inContainer *InWorkloadError if !errors.As(err, &inContainer) { - t.Fatalf("Launch error = %v, want *InContainerError", err) + t.Fatalf("Launch error = %v, want *InWorkloadError", err) } if inContainer.Stage != "arm egress" { - t.Fatalf("InContainerError.Stage = %q, want %q", inContainer.Stage, "arm egress") + t.Fatalf("InWorkloadError.Stage = %q, want %q", inContainer.Stage, "arm egress") } if !slices.Contains(fake.callsSnapshot(), "remove") { t.Errorf("a failed launch must remove the partial container; calls = %v", fake.callsSnapshot()) @@ -338,11 +338,11 @@ func TestInGuestArmerSkipsHostArmEgress(t *testing.T) { } // TestCreateArgsIgnoresEgress pins the podman byte-identical constraint: setting -// ContainerSpec.Egress must not change the `podman create` argv at all. The +// WorkloadSpec.Egress must not change the `podman create` argv at all. The // podman backend arms via AgentRuntime.armEgress, never from the spec field, so // createArgs output for a spec with Egress set equals its output without. func TestCreateArgsIgnoresEgress(t *testing.T) { - base := ContainerSpec{Name: "c", Image: "img", UID: 1000, CapAdd: []string{capNetAdmin}} + base := WorkloadSpec{Name: "c", Image: "img", UID: 1000, CapAdd: []string{capNetAdmin}} withEgress := base withEgress.Egress = MustAllowEgress("github.com", "example.com") diff --git a/go/internal/runtime/contract_microvm_test.go b/go/internal/runtime/contract_microvm_test.go index 15e614cec..c0c002bfc 100644 --- a/go/internal/runtime/contract_microvm_test.go +++ b/go/internal/runtime/contract_microvm_test.go @@ -2,7 +2,7 @@ package runtime -// The microVM leg of the shared ContainerRuntime contract suite (record §U5): +// The microVM leg of the shared WorkloadRuntime contract suite (record §U5): // runs runContractSuite against a real MicroVMRuntime on live hardware, gated on // microvmtest.Require(t) (skip-on-absent-KVM, hard-fail under // COMPASS_REQUIRE_MICROVM=1). It supplies the microVM caps encoding all 6 @@ -26,7 +26,7 @@ import ( ) // TestContractSuite_MicroVM drives the shared contract rows against a live -// MicroVMRuntime through the ContainerRuntime interface. The factory builds a +// MicroVMRuntime through the WorkloadRuntime interface. The factory builds a // runtime from the resolved test env; sessions are created with a single // /workspace virtio-fs share and uid 1000. All divergence caps are ON, so the // microVM-specific rows (output cap, non-numeric user, empty MountLabel, ignored @@ -36,9 +36,9 @@ func TestContractSuite_MicroVM(t *testing.T) { caps := backendCaps{ name: "microvm", - makeSpec: func(t *testing.T, name string) ContainerSpec { + makeSpec: func(t *testing.T, name string) WorkloadSpec { t.Helper() - return ContainerSpec{ + return WorkloadSpec{ Name: name, UID: 1000, Mounts: []Mount{{HostPath: t.TempDir(), ContainerPath: "/workspace"}}, @@ -62,7 +62,7 @@ func TestContractSuite_MicroVM(t *testing.T) { }, } - runContractSuite(t, func(t *testing.T) ContainerRuntime { + runContractSuite(t, func(t *testing.T) WorkloadRuntime { t.Helper() return NewMicroVMRuntime(e2eConfig(t, env)) }, caps) @@ -80,7 +80,7 @@ func TestMicroVMQBudget(t *testing.T) { m := NewMicroVMRuntime(e2eConfig(t, env)) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "qbudget-agent", UID: 1000, Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, diff --git a/go/internal/runtime/contract_podman_test.go b/go/internal/runtime/contract_podman_test.go index b1dab4ce4..d54a0e68e 100644 --- a/go/internal/runtime/contract_podman_test.go +++ b/go/internal/runtime/contract_podman_test.go @@ -2,7 +2,7 @@ package runtime -// The podman leg of the shared ContainerRuntime contract suite (record §U5): +// The podman leg of the shared WorkloadRuntime contract suite (record §U5): // runs runContractSuite against a real rootless-podman PodmanCLI, gated on // podmanUsable() (skip-not-fail where podman is absent, the existing suite's // pattern, lifecycle_test.go:54-60). It supplies the podman caps: the @@ -18,7 +18,7 @@ import ( ) // TestContractSuite_Podman drives the shared contract rows against rootless -// podman through the ContainerRuntime interface. It builds the agent image once +// podman through the WorkloadRuntime interface. It builds the agent image once // (buildImage) and hands runContractSuite a factory minting a fresh PodmanCLI, // with containers created from that image running `sleep infinity` as uid 1000 // — the production keep-alive-plus-exec shape (lifecycle_test.go). @@ -30,9 +30,9 @@ func TestContractSuite_Podman(t *testing.T) { caps := backendCaps{ name: "podman", - makeSpec: func(t *testing.T, name string) ContainerSpec { + makeSpec: func(t *testing.T, name string) WorkloadSpec { t.Helper() - return ContainerSpec{ + return WorkloadSpec{ Image: imageTag, Name: name, Command: []string{"sleep", "infinity"}, @@ -62,7 +62,7 @@ func TestContractSuite_Podman(t *testing.T) { }, } - runContractSuite(t, func(t *testing.T) ContainerRuntime { + runContractSuite(t, func(t *testing.T) WorkloadRuntime { t.Helper() return NewPodmanCLI() }, caps) diff --git a/go/internal/runtime/contract_suite_test.go b/go/internal/runtime/contract_suite_test.go index 5cd718a05..9753caac2 100644 --- a/go/internal/runtime/contract_suite_test.go +++ b/go/internal/runtime/contract_suite_test.go @@ -1,8 +1,8 @@ package runtime -// The shared ContainerRuntime contract suite (record §U5, the V2b acceptance +// The shared WorkloadRuntime contract suite (record §U5, the V2b acceptance // gate): one table-driven body proving MicroVMRuntime and PodmanCLI behave -// identically through the runtime.ContainerRuntime interface, run against BOTH +// identically through the runtime.WorkloadRuntime interface, run against BOTH // backends. It is UNTAGGED (package runtime, no build tag) so it compiles on // every platform: it references ONLY untagged production symbols plus the // backendCaps descriptor, never a KVM/podman-only symbol (microvmtest, @@ -12,7 +12,7 @@ package runtime // //go:build microvm && unix) supply the factory + caps and gate on their // backend's availability. // -// The suite drives ContainerRuntime DIRECTLY — a different, lower layer than +// The suite drives WorkloadRuntime DIRECTLY — a different, lower layer than // TestPerAgentContainerLifecycle (lifecycle_test.go), which drives // AgentRuntime.Launch. The two do not overlap. // @@ -40,12 +40,12 @@ type backendCaps struct { // name identifies the backend under test, for subtest / failure messages. name string - // makeSpec builds a ContainerSpec for the backend: the podman leg bakes an + // makeSpec builds a WorkloadSpec for the backend: the podman leg bakes an // Image + a `sleep infinity` keep-alive Command, the microVM leg a // /workspace virtio-fs mount; both bake UID 1000. Backend-specific container // creation is encapsulated HERE, never in the shared body (record 122-123). // t supplies t.TempDir() for a per-session workspace. - makeSpec func(t *testing.T, name string) ContainerSpec + makeSpec func(t *testing.T, name string) WorkloadSpec // refusesRootExec: a uid-0 exec is refused with a host/guest error (microVM // §(b) uid enforcement, record 576). When false (podman) the equivalent @@ -115,7 +115,7 @@ var _ = runContractSuite // lifecycle rows (duplicate-name, idempotence, exists, stop-grace) each manage // their own so an identity/teardown row never perturbs another. A thin // dispatcher: each row is its own helper. -func runContractSuite(t *testing.T, newRuntime func(t *testing.T) ContainerRuntime, caps backendCaps) { +func runContractSuite(t *testing.T, newRuntime func(t *testing.T) WorkloadRuntime, caps backendCaps) { t.Helper() rt := newRuntime(t) primary := startRunning(t, rt, caps, "contract-primary") @@ -149,7 +149,7 @@ func runContractSuite(t *testing.T, newRuntime func(t *testing.T) ContainerRunti // echoed body; a non-zero exit is a SUCCESSFUL call returning the code, NEVER an // error. A regression that folded a non-zero exit into err would turn every // expected-failure probe (a denied firewall check) into a fatal. -func rowExecExitCodes(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowExecExitCodes(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "echo hello-body").AsUser("1000")) if err != nil { @@ -176,7 +176,7 @@ func rowExecExitCodes(t *testing.T, rt ContainerRuntime, primary ContainerID) { // rowExecStdin — row 2 (record 567-569, agent.go:238-246): the script-over-stdin // shape end to end (the secret-safe channel). `sh -s` reads the script from // stdin, so the body never appears in the argv / process list. -func rowExecStdin(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowExecStdin(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() out, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-s").WithStdin("echo from-stdin").AsUser("1000")) if err != nil { @@ -194,7 +194,7 @@ func rowExecStdin(t *testing.T, rt ContainerRuntime, primary ContainerID) { // Stdout, proving live bidirectional interleaving over the pipes; then // Terminate. stderr is drained in a goroutine so a full pipe never deadlocks the // terminate. -func rowStreamingStdio(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowStreamingStdio(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("cat").AsUser("1000")) if err != nil { @@ -225,7 +225,7 @@ func rowStreamingStdio(t *testing.T, rt ContainerRuntime, caps backendCaps, prim // portable *ExitStatusError, podman the byte-identical *exec.ExitError — both // prove a signalled exit isDeliberateKill accepts, so the podman byte-path stays // unregressed AND the microVM portable path works. -func rowKillWait(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowKillWait(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() stream, err := rt.ExecStreaming(t.Context(), primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) if err != nil { @@ -244,7 +244,7 @@ func rowKillWait(t *testing.T, rt ContainerRuntime, caps backendCaps, primary Co // no orphan survives a host-side cancel. Wait returning IS the reap signal (Wait // reaps). A bounded select fails loudly rather than hanging the suite if the // child is never reaped. -func rowCtxCancelReaps(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowCtxCancelReaps(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() cctx, cancel := context.WithCancel(t.Context()) stream, err := rt.ExecStreaming(cctx, primary, NewStreamingExecSpec("sleep", "300").AsUser("1000")) @@ -268,7 +268,7 @@ func rowCtxCancelReaps(t *testing.T, rt ContainerRuntime, primary ContainerID) { // exec is refused on the microVM backend; the podman row asserts its equivalent // posture — a directed unprivileged exec runs as the requested uid, never // silently escalated to root. -func rowUIDEnforcement(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowUIDEnforcement(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() if caps.refusesRootExec { if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("0")); err == nil { @@ -288,7 +288,7 @@ func rowUIDEnforcement(t *testing.T, rt ContainerRuntime, caps backendCaps, prim // rowResize — row 11 (record 577-578): Resize returns ErrResizeNotImplemented on // both backends until C3. The S1-frozen verb must refuse legibly, never fake a // limit change that never happened. -func rowResize(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowResize(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() if err := rt.Resize(t.Context(), primary, ResourceLimits{CPUShares: 512}); !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) @@ -298,7 +298,7 @@ func rowResize(t *testing.T, rt ContainerRuntime, primary ContainerID) { // rowMountLabel — row 12: "" on microVM (record 587, capability-gated); podman // returns its real label, which may legitimately be "" on a non-SELinux host, so // there the row asserts only a no-error read. -func rowMountLabel(t *testing.T, rt ContainerRuntime, caps backendCaps, primary ContainerID) { +func rowMountLabel(t *testing.T, rt WorkloadRuntime, caps backendCaps, primary WorkloadID) { t.Helper() label, err := rt.MountLabel(t.Context(), primary) if err != nil { @@ -312,7 +312,7 @@ func rowMountLabel(t *testing.T, rt ContainerRuntime, caps backendCaps, primary // rowNonNumericUser — divergence 2 (microVM only, record 585-587): a non-numeric // ExecSpec.User is a host-side error. Asserting the refusal fails a backend that // started resolving names (silently widening). -func rowNonNumericUser(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowNonNumericUser(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() if _, err := rt.Exec(t.Context(), primary, NewExecSpec("id", "-u").AsUser("not-a-number")); err == nil { t.Fatal("a non-numeric ExecSpec.User must be a host-side error on this backend; got no error") @@ -323,7 +323,7 @@ func rowNonNumericUser(t *testing.T, rt ContainerRuntime, primary ContainerID) { // one-shot exec returns the truncation error, not a clipped tail. A backend that // started truncating silently (widening) would pass a caller a partial output as // if whole — this row fails that. -func rowOutputCap(t *testing.T, rt ContainerRuntime, primary ContainerID) { +func rowOutputCap(t *testing.T, rt WorkloadRuntime, primary WorkloadID) { t.Helper() // 9 MiB > the 8 MiB cap; content is irrelevant, only the byte count. if _, err := rt.Exec(t.Context(), primary, NewExecSpec("sh", "-c", "head -c 9437184 /dev/zero").AsUser("1000")); err == nil { @@ -336,7 +336,7 @@ func rowOutputCap(t *testing.T, rt ContainerRuntime, primary ContainerID) { // Command and an added capability still boots (Command is not the keep-alive), // still execs, and the workload has an EMPTY capability set (CapAdd granted // nothing). A backend that started honoring either would widen the divergence. -func rowCommandCapAddIgnored(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowCommandCapAddIgnored(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() spec := caps.makeSpec(t, "contract-cmd-capadd") spec.Command = []string{"/nonexistent-entrypoint-must-be-ignored"} @@ -372,7 +372,7 @@ func rowCommandCapAddIgnored(t *testing.T, rt ContainerRuntime, caps backendCaps // refused with the backend's typed collision error keyed on spec.Name. The // second Create of a live name must fail, and with the expected type (gated via // caps). -func rowDuplicateName(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowDuplicateName(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() const name = "contract-dup" id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) @@ -390,7 +390,7 @@ func rowDuplicateName(t *testing.T, rt ContainerRuntime, caps backendCaps) { // rowStopRemoveIdempotence — row 8 (record 577): a double Stop is not an error, a // Remove of an already-removed id is nil, and a Remove of a never-created id is // nil. -func rowStopRemoveIdempotence(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowStopRemoveIdempotence(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() id := startRunning(t, rt, caps, "contract-idem") if err := rt.Stop(t.Context(), id, 5*time.Second); err != nil { @@ -405,14 +405,14 @@ func rowStopRemoveIdempotence(t *testing.T, rt ContainerRuntime, caps backendCap if err := rt.Remove(t.Context(), id); err != nil { t.Fatalf("Remove of an already-removed id must be nil: %v", err) } - if err := rt.Remove(t.Context(), ContainerID("contract-never-created")); err != nil { + if err := rt.Remove(t.Context(), WorkloadID("contract-never-created")); err != nil { t.Fatalf("Remove of a never-created id must be nil: %v", err) } } // rowExistsBeforeAfterRemove — row 10 (record 587-590 lineage): Exists is true // after Create, false after Remove, keyed on spec.Name. -func rowExistsBeforeAfterRemove(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowExistsBeforeAfterRemove(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() const name = "contract-exists" id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) @@ -444,7 +444,7 @@ func rowExistsBeforeAfterRemove(t *testing.T, rt ContainerRuntime, caps backendC // escalation, proving the graceful preamble is not dead weight that always burns // the full timeout. Observable through the interface as Stop completing far under // its grace. -func rowStopGrace(t *testing.T, rt ContainerRuntime, caps backendCaps) { +func rowStopGrace(t *testing.T, rt WorkloadRuntime, caps backendCaps) { t.Helper() id := startRunning(t, rt, caps, "contract-stopgrace") const grace = 30 * time.Second @@ -462,7 +462,7 @@ func rowStopGrace(t *testing.T, rt ContainerRuntime, caps backendCaps) { // startRunning Creates + Starts a container from caps.makeSpec and registers a // Remove backstop, the shared happy-path setup for the exec/stream rows. A Create // or Start failure is fatal (the row cannot run). -func startRunning(t *testing.T, rt ContainerRuntime, caps backendCaps, name string) ContainerID { +func startRunning(t *testing.T, rt WorkloadRuntime, caps backendCaps, name string) WorkloadID { t.Helper() id, err := rt.Create(t.Context(), caps.makeSpec(t, name)) if err != nil { @@ -480,7 +480,7 @@ func startRunning(t *testing.T, rt ContainerRuntime, caps backendCaps, name stri // the test's own ctx is cancelled (t.Context() is cancelled before cleanups run) // — a leaked container would collide with the next run's name (the existing e2e // pattern, brief §Go house rules). -func registerRemove(t *testing.T, rt ContainerRuntime, id ContainerID, label string) { +func registerRemove(t *testing.T, rt WorkloadRuntime, id WorkloadID, label string) { t.Helper() t.Cleanup(func() { if err := rt.Remove(context.WithoutCancel(t.Context()), id); err != nil { diff --git a/go/internal/runtime/egress_inguest_microvm_test.go b/go/internal/runtime/egress_inguest_microvm_test.go index 558aecf5e..77e972cac 100644 --- a/go/internal/runtime/egress_inguest_microvm_test.go +++ b/go/internal/runtime/egress_inguest_microvm_test.go @@ -78,13 +78,13 @@ const ( // execs. It registers teardown. The session is armed in-guest by guestd during // Start (the Provision RPC carries the recorded nft_script), so by the time this // returns the firewall is live. -func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroVMRuntime, ContainerID) { +func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroVMRuntime, WorkloadID) { t.Helper() env := microvmtest.Require(t) m := NewMicroVMRuntime(e2eConfig(t, env)) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Egress: egress, @@ -119,7 +119,7 @@ func startEgressSession(t *testing.T, egress EgressPolicy, name string) (*MicroV // unreachable; an allowed host completes (exit 0, "connected"). A non-zero exit // with no "connected" is unreachable; any transport/exec error fails the test // (that is a harness fault, not a firewall verdict). -func canReachIPv4(t *testing.T, m *MicroVMRuntime, id ContainerID, ip string) bool { +func canReachIPv4(t *testing.T, m *MicroVMRuntime, id WorkloadID, ip string) bool { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), egressProbeTimeout) defer cancel() @@ -190,7 +190,7 @@ func TestInGuestEgressAgentCannotAlterRuleset(t *testing.T) { // TestInGuestEgressAlwaysArmedDefaultDeny is W3(4) / the §(e)/OQ-3 always-arm // verification: a session created with the ZERO-VALUE egress policy still boots // armed default-deny, so external egress is blocked even though no allowlist was -// set. This is the load-bearing always-arm claim — every ContainerSpec-created +// set. This is the load-bearing always-arm claim — every WorkloadSpec-created // microVM session is firewalled at Start whether or not a caller set Egress — // proven live. A regression that skipped the arm on an empty policy (a silent // open-egress VM) fails here: the deniedIP would become reachable. diff --git a/go/internal/runtime/egress_integrity_podman_test.go b/go/internal/runtime/egress_integrity_podman_test.go index 6877c35fa..3f6aa6967 100644 --- a/go/internal/runtime/egress_integrity_podman_test.go +++ b/go/internal/runtime/egress_integrity_podman_test.go @@ -37,7 +37,7 @@ import ( // ExecSpec identity and returns the hex effective-capability mask (the token // after "CapEff:"). It drives the real PodmanCLI.Exec so the --user plumbing // under test is the one exercised in production. -func capEffOf(t *testing.T, ctx context.Context, cli *PodmanCLI, id ContainerID, spec ExecSpec) string { +func capEffOf(t *testing.T, ctx context.Context, cli *PodmanCLI, id WorkloadID, spec ExecSpec) string { t.Helper() out, err := cli.Exec(ctx, id, spec) if err != nil { @@ -73,7 +73,7 @@ func TestAgentExecDropsNetAdminInNetAdminContainer(t *testing.T) { // A NET_ADMIN container remapped to the baked agent uid, mirroring a real // agent container's create (agent.go createAndStart). const agentUID uint32 = agentuid.AgentUID - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-egress-integrity-" + strconv.Itoa(os.Getpid()), UID: agentUID, diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index 9a5735aa1..8933ed28c 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -1,6 +1,6 @@ package runtime -// microvm.go is the microVM ContainerRuntime backend seam: the operator config, +// microvm.go is the microVM WorkloadRuntime backend seam: the operator config, // the MicroVMRuntime type + its per-session state table, and the config-driven // backend selection the Runner startup uses to choose between the microVM and // podman backends. The lifecycle method bodies — which boot a VMM, wire the @@ -76,7 +76,7 @@ type MicroVMConfig struct { VolumeRoot string } -// BackendConfig selects and configures the container runtime backend. Backend +// BackendConfig selects and configures the workload runtime backend. Backend // is the chosen backend name ("podman" or "microvm"); MicroVM carries the // microVM-specific wiring, consulted only when Backend selects it. type BackendConfig struct { @@ -87,20 +87,20 @@ type BackendConfig struct { MicroVM MicroVMConfig } -// MicroVMRuntime is a ContainerRuntime that isolates each agent in its own +// MicroVMRuntime is a WorkloadRuntime that isolates each agent in its own // microVM instead of a rootless container. It holds the operator wiring plus a -// per-session state table (keyed by the ContainerID Create mints), guarded by +// per-session state table (keyed by the WorkloadID Create mints), guarded by // mu against concurrent lifecycle calls. Its method bodies live in // microvm_lifecycle.go (//go:build unix); the microvmSession type they operate // on is declared there too. type MicroVMRuntime struct { config MicroVMConfig mu sync.Mutex - // sessions maps each live ContainerID to its session state. Every read and + // sessions maps each live WorkloadID to its session state. Every read and // write is guarded by mu. Name lookups (Exists, duplicate-name refusal) scan // this map for a matching spec.Name — a scan is cheap at one-VM-per-session // scale and keeps a single source of truth. - sessions map[ContainerID]*microvmSession + sessions map[WorkloadID]*microvmSession // launchFunc boots a session guest behind the guestVM seam; newGuestClient // dials the guest control plane. Both default to the real microvm // implementations (installSeamDefaults, //go:build unix) and are overridden @@ -115,13 +115,13 @@ type MicroVMRuntime struct { func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { m := &MicroVMRuntime{ config: cfg, - sessions: make(map[ContainerID]*microvmSession), + sessions: make(map[WorkloadID]*microvmSession), } m.installSeamDefaults() return m } -// SelectBackend chooses the container runtime backend from cfg. An empty or +// SelectBackend chooses the workload runtime backend from cfg. An empty or // "podman" backend returns the podman CLI runtime; "microvm" returns the // microVM runtime; any other value is an error naming the unknown backend and // the accepted values. @@ -133,7 +133,7 @@ func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { // collapses to microVM guarded by a VerifyMicroVMSupport hard gate at startup — // a legible refusal when the host cannot run microVMs, with no fallback to the // container path. -func SelectBackend(cfg BackendConfig) (ContainerRuntime, error) { +func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error) { switch strings.TrimSpace(cfg.Backend) { case "", "podman": return NewPodmanCLI(), nil diff --git a/go/internal/runtime/microvm_isolation_microvm_test.go b/go/internal/runtime/microvm_isolation_microvm_test.go index d265e6441..c4807815d 100644 --- a/go/internal/runtime/microvm_isolation_microvm_test.go +++ b/go/internal/runtime/microvm_isolation_microvm_test.go @@ -82,14 +82,14 @@ const ( // failed assertion still tears the VM down. The volume is a t.TempDir() child so // it is removed with the test; the SHORT runroot comes from e2eConfig (the // AF_UNIX sun_path budget, microvm_lifecycle_microvm_test.go). -func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMRuntime, ContainerID, string) { +func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMRuntime, WorkloadID, string) { t.Helper() m := NewMicroVMRuntime(e2eConfig(t, env)) volume := filepath.Join(t.TempDir(), "volume") if err := os.MkdirAll(volume, 0o700); err != nil { t.Fatalf("creating session volume %s: %v", volume, err) } - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: volume, ContainerPath: workspaceMountPath}}, @@ -112,7 +112,7 @@ func isolationSession(t *testing.T, env microvmtest.Env, name string) (*MicroVMR // combined stdout+stderr and exit code. A transport/refusal error is fatal; a // NON-ZERO EXIT IS NOT — a denied escape attempt is expected to exit non-zero, // and that is the outcome under test (mirrors rowExecExitCodes' posture). -func guestSh(t *testing.T, m *MicroVMRuntime, id ContainerID, script string) (string, int) { +func guestSh(t *testing.T, m *MicroVMRuntime, id WorkloadID, script string) (string, int) { t.Helper() out, err := m.Exec(t.Context(), id, NewExecSpec("sh", "-s").WithStdin(script).AsUser(strconv.Itoa(int(agentuid.AgentUID)))) @@ -680,7 +680,7 @@ func TestMicroVMVolumeQuotaEnforcedInGuest(t *testing.T) { } m := NewMicroVMRuntime(e2eConfig(t, env)) - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "iso-quota", UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: volume, ContainerPath: workspaceMountPath}}, diff --git a/go/internal/runtime/microvm_lifecycle.go b/go/internal/runtime/microvm_lifecycle.go index f2a7e276b..e5df43281 100644 --- a/go/internal/runtime/microvm_lifecycle.go +++ b/go/internal/runtime/microvm_lifecycle.go @@ -3,7 +3,7 @@ package runtime // microvm_lifecycle.go fills the eight MicroVMRuntime lifecycle verbs behind the -// frozen ContainerRuntime signatures (microvm.go holds the type + config + +// frozen WorkloadRuntime signatures (microvm.go holds the type + config + // SelectBackend). It is //go:build unix because the microvm package it drives // (Launch/GuestExec/VM/GuestClient, all //go:build unix) is unix-only; keeping // the bodies here lets the untagged runtime package still type-check backend @@ -143,8 +143,8 @@ func (m *MicroVMRuntime) installSeamDefaults() { // Start, and dropped by Remove. All fields are read/written under // MicroVMRuntime.mu. type microvmSession struct { - // id is the ContainerID Create minted (also the runtime-dir leaf name). - id ContainerID + // id is the WorkloadID Create minted (also the runtime-dir leaf name). + id WorkloadID // name is spec.Name — the Runner's stable handle, answered by Exists and // used to refuse a duplicate-name Create (matching podman's engine). name string @@ -159,7 +159,7 @@ type microvmSession struct { nonce []byte // nftScript is the egress ruleset delivered to guestd on Start's Provision // RPC (as ProvisionRequest.nft_script). Recorded at Create from - // spec.Egress.NftScript(); NEVER empty for a ContainerSpec-created session, + // spec.Egress.NftScript(); NEVER empty for a WorkloadSpec-created session, // since the zero-value EgressPolicy still emits the full default-deny base // ruleset (design §(e), egress.go). guestd arms it as guest root before the // exec gate opens. @@ -207,7 +207,7 @@ func (e *UnsupportedMountError) Error() string { // keep-alive is the VMM + guestd PID 1, not a sleep-loop entrypoint, and // CAP_NET_ADMIN is never granted to the workload boundary (record §(c)). No VM // is booted here; Start does that. -func (m *MicroVMRuntime) Create(_ context.Context, spec ContainerSpec) (ContainerID, error) { +func (m *MicroVMRuntime) Create(_ context.Context, spec WorkloadSpec) (WorkloadID, error) { shared, err := workspaceShare(spec.Mounts) if err != nil { return "", err @@ -235,7 +235,7 @@ func (m *MicroVMRuntime) Create(_ context.Context, spec ContainerSpec) (Containe env: spec.Env, nonce: nonce, // Never empty: the zero-value EgressPolicy still emits the default-deny - // base ruleset, so every ContainerSpec-created session boots armed (§(e)). + // base ruleset, so every WorkloadSpec-created session boots armed (§(e)). nftScript: spec.Egress.NftScript(), runtimeDir: runtimeDir, } @@ -333,15 +333,15 @@ func workspaceShare(mounts []Mount) (Mount, error) { } } -// mintSessionID mints a random 16-byte hex session id used as the ContainerID +// mintSessionID mints a random 16-byte hex session id used as the WorkloadID // and the runtime-dir leaf. There is no engine to print an id, so the backend // generates one; hex keeps it filesystem-safe. -func mintSessionID() (ContainerID, error) { +func mintSessionID() (WorkloadID, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("microvm: minting session id: %w", err) } - return ContainerID(hex.EncodeToString(b[:])), nil + return WorkloadID(hex.EncodeToString(b[:])), nil } // mintNonce mints a random 16-byte boot nonce (raw bytes; the cmdline carries @@ -362,7 +362,7 @@ func mintNonce() ([]byte, error) { // before returning — on this backend the boot IS Start, so Start cleans its own // partial boot and Remove stays idempotent (record §(c)). On success the VM // handle + GuestExec are stored on the session under the lock. -func (m *MicroVMRuntime) Start(ctx context.Context, id ContainerID) error { +func (m *MicroVMRuntime) Start(ctx context.Context, id WorkloadID) error { session, err := m.session(id) if err != nil { return err @@ -424,7 +424,7 @@ func (m *MicroVMRuntime) Start(ctx context.Context, id ContainerID) error { // before the exec gate opens (§(b)/(c)). AgentRuntime.provision probes for this // marker (the unexported inGuestEgressArmer, agent.go) and skips its host-side // armEgress exec — which on this backend would run capability-less and fail. -// Deliberately NOT a verb on the frozen ContainerRuntime interface (podman.go). +// Deliberately NOT a verb on the frozen WorkloadRuntime interface (podman.go). func (m *MicroVMRuntime) EgressArmedInGuest() bool { return true } // awaitHealthy polls the guest's Health until it reports net_provisioned && @@ -477,7 +477,7 @@ func bootPollContext(ctx context.Context) (context.Context, context.CancelFunc) // refusal or transport failure is an error, and a host-side timeout is mapped // to a *runtime.TimeoutError so requireSuccess/atStage callers behave // identically to the podman path (record §(c)). -func (m *MicroVMRuntime) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) { +func (m *MicroVMRuntime) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { guestExec, err := m.startedExec(id) if err != nil { return ExecOutput{}, err @@ -567,7 +567,7 @@ func exitError(st microvm.ExitStatus) error { // blocking teardown), and waitFunc maps the guest exit onto nil / a // *runtime.ExitStatusError so the runner's isDeliberateKill recognizes a // signalled exit as a deliberate kill (OQ-G/U3b, record §(c)). -func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { guestExec, err := m.startedExec(id) if err != nil { return nil, err @@ -607,7 +607,7 @@ func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id ContainerID, spec // real VMM exit up to timeout. Past the timeout it kills the VMM outright via // vm.Shutdown (which also reaps the daemons and removes the sockets). A session // that never started (no VM handle) is a no-op success (record §(d)). -func (m *MicroVMRuntime) Stop(ctx context.Context, id ContainerID, timeout time.Duration) error { +func (m *MicroVMRuntime) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { session, err := m.session(id) if err != nil { return err @@ -657,7 +657,7 @@ func stopGuest(ctx context.Context, client compassv1internalconnect.GuestControl // the session-table entry. It is idempotent: a Remove of an unknown or // already-removed id is not an error (matching `podman rm --force`), and a // session that never started is torn down to just its dir + entry (record §(d)). -func (m *MicroVMRuntime) Remove(ctx context.Context, id ContainerID) error { +func (m *MicroVMRuntime) Remove(ctx context.Context, id WorkloadID) error { m.mu.Lock() session, ok := m.sessions[id] if !ok { @@ -699,7 +699,7 @@ func (m *MicroVMRuntime) Exists(_ context.Context, name string) (bool, error) { // the session's own vsock socket base and the fixed gateway port (record // §(b)/§(c)/§(e)). An unknown name returns ("", false). It keys on spec.Name // like Exists, so the Runner's stable handle resolves. Deliberately NOT a verb -// on the frozen ContainerRuntime interface: agentHost probes for it via an +// on the frozen WorkloadRuntime interface: agentHost probes for it via an // unexported single-method assertion, so the podman backend (which lacks it) is // unaffected (record §(c), Global Constraints). func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool) { @@ -718,7 +718,7 @@ func (m *MicroVMRuntime) AgentGatewayEndpoint(name string) (string, bool) { // relabeled bind mount), and the config materializer treats an empty label as // skip-chcon (the parent's Q-mountlabel deferral, record §(c)). An unknown id // is not distinguished — the empty answer is correct for it too. -func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { +func (m *MicroVMRuntime) MountLabel(_ context.Context, _ WorkloadID) (string, error) { return "", nil } @@ -726,13 +726,13 @@ func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, e // sentinel until C3 fills in resize-in-place behind the S1-frozen seam (the // C3/D5 deferral, record §(c)). It is not a microVM-specific unimplemented // verb, so it shares the podman backend's sentinel. -func (m *MicroVMRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (m *MicroVMRuntime) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return ErrResizeNotImplemented } // session looks up a session by id under the lock, returning a stage-agnostic // error if it is absent. -func (m *MicroVMRuntime) session(id ContainerID) (*microvmSession, error) { +func (m *MicroVMRuntime) session(id WorkloadID) (*microvmSession, error) { m.mu.Lock() defer m.mu.Unlock() session, ok := m.sessions[id] @@ -745,7 +745,7 @@ func (m *MicroVMRuntime) session(id ContainerID) (*microvmSession, error) { // startedExec looks up a session's GuestExec client under the lock, erroring if // the session is absent or not yet started (Exec/ExecStreaming both require a // booted, provisioned guest). -func (m *MicroVMRuntime) startedExec(id ContainerID) (*microvm.GuestExec, error) { +func (m *MicroVMRuntime) startedExec(id WorkloadID) (*microvm.GuestExec, error) { m.mu.Lock() defer m.mu.Unlock() session, ok := m.sessions[id] @@ -758,4 +758,4 @@ func (m *MicroVMRuntime) startedExec(id ContainerID) (*microvm.GuestExec, error) return session.guestExec, nil } -var _ ContainerRuntime = (*MicroVMRuntime)(nil) +var _ WorkloadRuntime = (*MicroVMRuntime)(nil) diff --git a/go/internal/runtime/microvm_lifecycle_microvm_test.go b/go/internal/runtime/microvm_lifecycle_microvm_test.go index 163579144..e93783d8b 100644 --- a/go/internal/runtime/microvm_lifecycle_microvm_test.go +++ b/go/internal/runtime/microvm_lifecycle_microvm_test.go @@ -82,7 +82,7 @@ func TestMicroVMStartFailureLeavesNoState(t *testing.T) { m := NewMicroVMRuntime(cfg) workspace := t.TempDir() - id, err := m.Create(t.Context(), ContainerSpec{ + id, err := m.Create(t.Context(), WorkloadSpec{ Name: "e2e-badboot", UID: 1000, Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, diff --git a/go/internal/runtime/microvm_lifecycle_test.go b/go/internal/runtime/microvm_lifecycle_test.go index 58555a6ab..b5a461967 100644 --- a/go/internal/runtime/microvm_lifecycle_test.go +++ b/go/internal/runtime/microvm_lifecycle_test.go @@ -84,7 +84,7 @@ func TestBootConfigAssembly(t *testing.T) { // booting (no VM handle, no exec client), and the returned id resolves. func TestCreateAllocatesWithoutBoot(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -107,10 +107,10 @@ func TestCreateAllocatesWithoutBoot(t *testing.T) { // table is refused with a typed DuplicateNameError naming the collision. func TestCreateRefusesDuplicateName(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}); err != nil { + if _, err := m.Create(context.Background(), WorkloadSpec{Name: "dup", UID: 1000}); err != nil { t.Fatalf("first Create: %v", err) } - _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}) + _, err := m.Create(context.Background(), WorkloadSpec{Name: "dup", UID: 1000}) var dupErr *DuplicateNameError if !errors.As(err, &dupErr) { t.Fatalf("second Create err = %v, want *DuplicateNameError", err) @@ -151,7 +151,7 @@ func TestCreateRefusesInexpressibleMount(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := m.Create(context.Background(), ContainerSpec{Name: tt.name, UID: 1000, Mounts: tt.mounts}) + _, err := m.Create(context.Background(), WorkloadSpec{Name: tt.name, UID: 1000, Mounts: tt.mounts}) var mountErr *UnsupportedMountError if !errors.As(err, &mountErr) { t.Fatalf("Create err = %v, want *UnsupportedMountError", err) @@ -266,7 +266,7 @@ func TestParseUID(t *testing.T) { // session id is NOT a name match. func TestExistsByName(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-x", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-x", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -290,11 +290,11 @@ func TestExistsByName(t *testing.T) { // of a never-started session tears down its dir + table entry without error. func TestRemoveIdempotent(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Remove(context.Background(), ContainerID("never-existed")); err != nil { + if err := m.Remove(context.Background(), WorkloadID("never-existed")); err != nil { t.Fatalf("Remove(unknown) = %v, want nil", err) } - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-r", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-r", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -314,7 +314,7 @@ func TestRemoveIdempotent(t *testing.T) { // id, per the parent's Q-mountlabel deferral. func TestMountLabelEmpty(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - label, err := m.MountLabel(context.Background(), ContainerID("anything")) + label, err := m.MountLabel(context.Background(), WorkloadID("anything")) if err != nil || label != "" { t.Fatalf("MountLabel = (%q, %v), want (\"\", nil)", label, err) } @@ -324,7 +324,7 @@ func TestMountLabelEmpty(t *testing.T) { // sentinel, matching PodmanCLI.Resize (the C3/D5 deferral). func TestResizeNotImplemented(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Resize(context.Background(), ContainerID("c"), ResourceLimits{}); !errors.Is(err, ErrResizeNotImplemented) { + if err := m.Resize(context.Background(), WorkloadID("c"), ResourceLimits{}); !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) } } @@ -333,7 +333,7 @@ func TestResizeNotImplemented(t *testing.T) { // booting. func TestStartUnknownSession(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - if err := m.Start(context.Background(), ContainerID("ghost")); err == nil { + if err := m.Start(context.Background(), WorkloadID("ghost")); err == nil { t.Fatal("Start(unknown) err = nil, want a no-session error") } } @@ -342,7 +342,7 @@ func TestStartUnknownSession(t *testing.T) { // (no exec client yet) rather than panicking. func TestExecUnstartedSession(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-e", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-e", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -390,7 +390,7 @@ func TestExitErrorMapping(t *testing.T) { // agentHost's vsock leg relies on (record §(b)/§(c)/§(e)). func TestAgentGatewayEndpoint(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) - id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -460,7 +460,7 @@ func TestCreateRejectsOverLongGatewaySocketPath(t *testing.T) { t.Fatalf("creating runroot: %v", err) } m := NewMicroVMRuntime(MicroVMConfig{RunRoot: runRoot}) - _, createErr := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + _, createErr := m.Create(context.Background(), WorkloadSpec{Name: "agent-1", UID: 1000}) if tt.wantReject { if createErr == nil { t.Fatal("Create succeeded; want the pre-boot budget error") diff --git a/go/internal/runtime/microvm_preflight.go b/go/internal/runtime/microvm_preflight.go index ba9c7455c..eb4b29e21 100644 --- a/go/internal/runtime/microvm_preflight.go +++ b/go/internal/runtime/microvm_preflight.go @@ -410,7 +410,7 @@ func (m *MicroVMRuntime) BootCanary(ctx context.Context) (report CanaryReport, e } }() - id, err := m.Create(ctx, ContainerSpec{ + id, err := m.Create(ctx, WorkloadSpec{ Name: name, UID: agentuid.AgentUID, Mounts: []Mount{{HostPath: workspace, ContainerPath: workspaceMountPath}}, diff --git a/go/internal/runtime/microvm_start_test.go b/go/internal/runtime/microvm_start_test.go index fbb596d0a..7cb5dc4ef 100644 --- a/go/internal/runtime/microvm_start_test.go +++ b/go/internal/runtime/microvm_start_test.go @@ -120,7 +120,7 @@ var _ compassv1internalconnect.GuestControlClient = (*fakeGuestClient)(nil) // and creates one session, returning the runtime, the created id, and the fakes. // The Create records the zero-value default-deny script (unless spec overrides), // which Start must then deliver verbatim. -func seamStart(t *testing.T, spec ContainerSpec, provErr error) (*MicroVMRuntime, ContainerID, *fakeGuestVM, *fakeGuestClient) { +func seamStart(t *testing.T, spec WorkloadSpec, provErr error) (*MicroVMRuntime, WorkloadID, *fakeGuestVM, *fakeGuestClient) { t.Helper() m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) id, err := m.Create(t.Context(), spec) @@ -161,10 +161,10 @@ func shortRunRoot(t *testing.T) string { // TestCreateRecordsDefaultDenyScript pins §(e): Create records the zero-value // EgressPolicy's full default-deny base ruleset on the session (never empty), so -// every ContainerSpec-created session boots armed even with no allowlist set. +// every WorkloadSpec-created session boots armed even with no allowlist set. func TestCreateRecordsDefaultDenyScript(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) - id, err := m.Create(t.Context(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(t.Context(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } @@ -184,7 +184,7 @@ func TestCreateRecordsDefaultDenyScript(t *testing.T) { // Start's Provision RPC carries the exact script Create recorded — the host→guest // egress delivery contract (§(a)), hermetic with no real VMM or vsock dial. func TestStartDeliversScriptVerbatim(t *testing.T) { - spec := ContainerSpec{Name: "agent-1", UID: 1000, Egress: MustAllowEgress("github.com")} + spec := WorkloadSpec{Name: "agent-1", UID: 1000, Egress: MustAllowEgress("github.com")} m, id, vm, client := seamStart(t, spec, nil) want := spec.Egress.NftScript() @@ -220,7 +220,7 @@ func TestStartDeliversScriptVerbatim(t *testing.T) { // booted by launchFunc must not be left running when the arm/provision fails. func TestStartProvisionErrorFailsAndTearsDown(t *testing.T) { provErr := connect.NewError(connect.CodeInternal, errors.New("arm failed")) - spec := ContainerSpec{Name: "agent-1", UID: 1000} + spec := WorkloadSpec{Name: "agent-1", UID: 1000} m, id, vm, _ := seamStart(t, spec, provErr) err := m.Start(t.Context(), id) @@ -247,7 +247,7 @@ func TestStartProvisionErrorFailsAndTearsDown(t *testing.T) { // so this guards only against a broken test seam, but it must fail loud. func TestStartNilGuestHandleFailsClosed(t *testing.T) { m := NewMicroVMRuntime(MicroVMConfig{RunRoot: shortRunRoot(t)}) - id, err := m.Create(t.Context(), ContainerSpec{Name: "agent-1", UID: 1000}) + id, err := m.Create(t.Context(), WorkloadSpec{Name: "agent-1", UID: 1000}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/go/internal/runtime/podman.go b/go/internal/runtime/podman.go index fc1fcffa4..64af58235 100644 --- a/go/internal/runtime/podman.go +++ b/go/internal/runtime/podman.go @@ -8,7 +8,7 @@ // container, so neither the image build nor the clone is the Runner's job. // // The layering, bottom to top: -// - podman.go — a thin ContainerRuntime over the podman CLI: the only place a +// - podman.go — a thin WorkloadRuntime over the podman CLI: the only place a // subprocess is spawned. Everything above depends on the interface, so a // libpod-REST backend can replace it without touching a caller. // - egress.go — the default-deny + allowlist firewall applied inside the @@ -19,7 +19,7 @@ // - registry.go — AgentRegistry, the Runner's live-container handle cache the // session RPCs resolve a launched container by name through. // -// This file is the container-runtime seam: a ContainerRuntime interface plus +// This file is the workload-runtime seam: a WorkloadRuntime interface plus // PodmanCLI, its rootless-podman-CLI implementation. Rootless is a hard // requirement (design: architecture-lineage): no daemon, no root, no rootful fallback. // Containers run with --userns=keep-id:uid=,gid= so the @@ -50,12 +50,13 @@ import ( "time" ) -// ContainerID is a running (or created) container, identified by the full id -// podman prints. -type ContainerID string +// WorkloadID identifies a running (or created) workload — a container, a +// microVM guest, or a host process group — as the backend that made it names +// it. The podman backend uses the full id podman prints. +type WorkloadID string -// String returns the raw container id. -func (c ContainerID) String() string { return string(c) } +// String returns the raw workload id. +func (c WorkloadID) String() string { return string(c) } // Mount is a host→container bind mount. ReadOnly maps to :ro and every mount // gets SELinux relabelling (:Z) so the substrate works on enforcing hosts. @@ -82,10 +83,10 @@ type ResourceLimits struct { MemoryBytes int64 } -// ContainerSpec is everything needed to create one agent container. Kept -// engine-agnostic: the podman-specific argv is assembled in PodmanCLI.Create, +// WorkloadSpec is everything needed to create one agent workload. Kept +// backend-agnostic: the podman-specific argv is assembled in PodmanCLI.Create, // not here. -type ContainerSpec struct { +type WorkloadSpec struct { // Image reference in local container storage (e.g. compass-agent:latest — // one shared base image, not a per-repo tag). Image string @@ -340,67 +341,71 @@ func (e *TimeoutError) Error() string { return fmt.Sprintf("%q timed out after %ds", e.Summary, int(e.Timeout.Seconds())) } -// ContainerRuntime is the container engine seam. Every operation is a subprocess -// (or, later, a socket round-trip), so each threads the caller's context for -// cancellation and carries the per-command timeout the implementation applies. -// An interface so the Runner can hold a ContainerRuntime and tests can -// substitute a fake. -type ContainerRuntime interface { - // Create makes a container from spec without starting it, returning its id. - Create(ctx context.Context, spec ContainerSpec) (ContainerID, error) +// WorkloadRuntime is the execution-backend seam: the interface every runtime +// backend implements, whatever it actually runs — a podman container, a microVM +// guest, or a direct host process. Every operation is a subprocess (or, later, a +// socket round-trip), so each threads the caller's context for cancellation and +// carries the per-command timeout the implementation applies. An interface so +// the Runner can hold a WorkloadRuntime and tests can substitute a fake. +type WorkloadRuntime interface { + // Create makes a workload from spec without starting it, returning its id. + Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error) - // Start starts a created container. - Start(ctx context.Context, id ContainerID) error + // Start starts a created workload. + Start(ctx context.Context, id WorkloadID) error - // Exec runs a command in a running container, capturing its output. A + // Exec runs a command in a running workload, capturing its output. A // non-zero exit is a successful runtime call returning a failed command // (ExecOutput.ExitCode), not an error; only a spawn failure or timeout is an // error. - Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) + Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) // ExecStreaming starts a long-lived streaming command in a running - // container, returning its live stdio pipes plus a kill/wait handle rather + // workload, returning its live stdio pipes plus a kill/wait handle rather // than awaiting completion. The transport for the long-running agent // process: its stdout/stderr stay open and are drained as diagnostics for - // the process's life (the agent's protocol rides the per-container socket). + // the process's life (the agent's protocol rides the per-workload socket). // Unlike Exec there is no wall-clock timeout — the process is meant to run // indefinitely — but the exec is still bound to ctx, so cancelling it // terminates the process. - ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) + ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) - // Stop stops a running container, allowing timeout for graceful exit before - // podman kills it. - Stop(ctx context.Context, id ContainerID, timeout time.Duration) error + // Stop stops a running workload, allowing timeout for graceful exit before + // the backend kills it. + Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error - // Remove removes a container (force-kills if still running). - Remove(ctx context.Context, id ContainerID) error + // Remove removes a workload (force-kills if still running). + Remove(ctx context.Context, id WorkloadID) error - // Exists reports whether a container with name currently exists (any state). + // Exists reports whether a workload with name currently exists (any state). Exists(ctx context.Context, name string) (bool, error) - // MountLabel reports the container's SELinux mount label (its private MCS - // category), read from `podman inspect`. The config-update path relabels a - // freshly materialized version dir into this category so a confined agent - // can read it (agentHost.RefreshConfig -> ConfigMaterializer relabel). - MountLabel(ctx context.Context, id ContainerID) (string, error) - - // Resize changes a live container's cgroup resource limits in place (a - // `podman update`-class operation) — the resize-in-place elastic-compute - // path (C3). The verb is frozen into the interface at S1 (additively + // MountLabel reports the workload's SELinux mount label (its private MCS + // category); the podman backend reads it from `podman inspect`. The + // config-update path relabels a freshly materialized version dir into this + // category so a confined agent can read it (agentHost.RefreshConfig -> + // ConfigMaterializer relabel). A backend with no SELinux confinement + // reports an empty label. + MountLabel(ctx context.Context, id WorkloadID) (string, error) + + // Resize changes a live workload's cgroup resource limits in place (a + // `podman update`-class operation on the container backend) — the + // resize-in-place elastic-compute path (C3). The verb is frozen into the + // interface at S1 (additively // reserved, the same discipline as ExecStreaming) so every backend and fake // carries the full surface from the start and no interface change lands // after S1; the resize BEHAVIOR — actually applying and later restoring the // limits around a heavy op — is C3's to fill in behind this signature. // PodmanCLI.Resize therefore returns ErrResizeNotImplemented until C3, and // no caller invokes it yet, so the existing session path is unchanged. - Resize(ctx context.Context, id ContainerID, limits ResourceLimits) error + Resize(ctx context.Context, id WorkloadID, limits ResourceLimits) error } -// ContainerRuntime is frozen (the Resize reservation above): a backend that +// WorkloadRuntime is frozen (the Resize reservation above): a backend that // self-arms egress does NOT grow a verb here. Instead MicroVMRuntime carries an // off-interface marker method, EgressArmedInGuest(), and AgentRuntime.provision // type-asserts the unexported inGuestEgressArmer (agent.go) to skip armEgress on -// such a backend (design §(c)). A future backend — or any ContainerRuntime +// such a backend (design §(c)). A future backend — or any WorkloadRuntime // decorator, which would otherwise swallow the marker and silently re-enable // armEgress on the microVM backend — must re-expose EgressArmedInGuest to keep // the probe working. @@ -418,7 +423,7 @@ const ( argFormat = "--format" ) -// PodmanCLI is a ContainerRuntime over the podman CLI. +// PodmanCLI is a WorkloadRuntime over the podman CLI. type PodmanCLI struct { program string timeout time.Duration @@ -444,18 +449,18 @@ func (p *PodmanCLI) WithTimeout(timeout time.Duration) *PodmanCLI { } // Create assembles and runs `podman create`, returning the new container id. -func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID, error) { +func (p *PodmanCLI) Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error) { stdout, err := p.run(ctx, "podman create", createArgs(spec)) if err != nil { return "", err } - return ContainerID(strings.TrimSpace(string(stdout))), nil + return WorkloadID(strings.TrimSpace(string(stdout))), nil } // createArgs assembles the argv for `podman create`. Split out so the argv // assembly is unit-testable without spawning podman, mirroring // execStreamingArgs. -func createArgs(spec ContainerSpec) []string { +func createArgs(spec WorkloadSpec) []string { // Preallocate: 4 fixed tokens (create, --name+value, --userns) + 2 per // cap/mount/env pair + image + command tokens, so the appends below don't // reallocate. @@ -541,7 +546,7 @@ func parsePodmanVersion(s string) (major, minor int, err error) { } // Start starts a created container. -func (p *PodmanCLI) Start(ctx context.Context, id ContainerID) error { +func (p *PodmanCLI) Start(ctx context.Context, id WorkloadID) error { _, err := p.run(ctx, "podman start", []string{"start", id.String()}) return err } @@ -549,7 +554,7 @@ func (p *PodmanCLI) Start(ctx context.Context, id ContainerID) error { // Exec runs a command in a running container, capturing its output. A non-zero // exit is captured in ExecOutput, not folded into an error (a denied firewall // probe is an expected non-zero); a spawn failure or timeout is an error. -func (p *PodmanCLI) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) { +func (p *PodmanCLI) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { args := []string{argExec} // Forward stdin only when there's input to feed, so `sh -s` reads the script // from the pipe rather than the argv. @@ -584,7 +589,7 @@ func (p *PodmanCLI) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (Ex // Cancel SIGKILLs the process and WaitDelay bounds the reap, so cancelling the // parent context or calling ChildHandle.Kill terminates the in-container agent // even without a Go Drop. -func (p *PodmanCLI) ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { +func (p *PodmanCLI) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { execCtx, cancel := context.WithCancel(ctx) //nolint:gosec // G204: the container-engine seam — see spawnCapture. The // engine binary is operator-set and the exec argv is Runner-assembled. @@ -632,7 +637,7 @@ func stopGraceSeconds(timeout time.Duration) int64 { } // Stop stops a running container, allowing timeout for graceful exit. -func (p *PodmanCLI) Stop(ctx context.Context, id ContainerID, timeout time.Duration) error { +func (p *PodmanCLI) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { // podman's --time is whole seconds; the interface takes a Duration for idiom // and callsite clarity, converted at this CLI boundary. _, err := p.run(ctx, "podman stop", []string{ @@ -644,7 +649,7 @@ func (p *PodmanCLI) Stop(ctx context.Context, id ContainerID, timeout time.Durat } // Remove removes a container (force-kills if still running). -func (p *PodmanCLI) Remove(ctx context.Context, id ContainerID) error { +func (p *PodmanCLI) Remove(ctx context.Context, id WorkloadID) error { _, err := p.run(ctx, "podman rm", removeArgs(id)) return err } @@ -655,7 +660,7 @@ func (p *PodmanCLI) Remove(ctx context.Context, id ContainerID) error { // exhaust podman's num_locks and wedge the host. Harmless when the container // has none. Sister argv in internal/pgtest (removeContainerArgs); the two are // deliberately independent (no prod->test-harness dependency) — keep in sync. -func removeArgs(id ContainerID) []string { +func removeArgs(id WorkloadID) []string { return []string{"rm", "--force", "--volumes", id.String()} } @@ -665,14 +670,14 @@ func removeArgs(id ContainerID) []string { // lands no interface change); the podman `container update` wiring is C3's, so // calling it today is a programming error the sentinel names explicitly rather // than a silent no-op that would fake a limit change that never happened. -var ErrResizeNotImplemented = errors.New("runtime: ContainerRuntime.Resize is reserved at S1 and implemented in C3") +var ErrResizeNotImplemented = errors.New("runtime: WorkloadRuntime.Resize is reserved at S1 and implemented in C3") // Resize is the S1-frozen resize-in-place verb, unimplemented until C3. It // returns ErrResizeNotImplemented rather than silently succeeding: a no-op that // reported success would let a future caller believe a container was resized // when its cgroup limits never moved. C3 replaces this body with the real // `podman update`-class limit change. -func (p *PodmanCLI) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { +func (p *PodmanCLI) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { return ErrResizeNotImplemented } @@ -732,7 +737,7 @@ func (p *PodmanCLI) ImageExists(ctx context.Context, image string) (bool, error) // MountLabel reads the container's SELinux mount label via `podman inspect`, // trimming the trailing newline the CLI prints. A one-shot fire-and-check like // Start/Remove: a non-zero exit becomes a CommandError through run. -func (p *PodmanCLI) MountLabel(ctx context.Context, id ContainerID) (string, error) { +func (p *PodmanCLI) MountLabel(ctx context.Context, id WorkloadID) (string, error) { out, err := p.run(ctx, "podman inspect", inspectMountLabelArgs(id)) if err != nil { return "", err @@ -815,7 +820,7 @@ func (p *PodmanCLI) run(ctx context.Context, summary string, args []string) ([]b // --interactive keeps stdin open for the process's life; there is deliberately // no --tty (the agent is a headless process draining diagnostic pipes, not a // terminal session). -func execStreamingArgs(id ContainerID, spec StreamingExecSpec) []string { +func execStreamingArgs(id WorkloadID, spec StreamingExecSpec) []string { args := []string{argExec, argInteractive} if spec.User != nil { args = append(args, "--user", *spec.User) @@ -834,7 +839,7 @@ func execStreamingArgs(id ContainerID, spec StreamingExecSpec) []string { // inspectMountLabelArgs assembles the argv for reading a container's SELinux // mount label. Split out so the argv assembly is unit-testable without spawning // podman, mirroring execStreamingArgs. -func inspectMountLabelArgs(id ContainerID) []string { +func inspectMountLabelArgs(id WorkloadID) []string { return []string{"inspect", argFormat, "{{.MountLabel}}", id.String()} } diff --git a/go/internal/runtime/podman_test.go b/go/internal/runtime/podman_test.go index 97f8fa209..94034df49 100644 --- a/go/internal/runtime/podman_test.go +++ b/go/internal/runtime/podman_test.go @@ -76,7 +76,7 @@ func TestExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { spec.Env["COMPASS_WORKDIR"] = "/work" spec.Env["COMPASS_MODEL"] = "test-model" - args := execStreamingArgs(ContainerID("ctr123"), spec) + args := execStreamingArgs(WorkloadID("ctr123"), spec) want := []string{ "exec", "--interactive", @@ -97,7 +97,7 @@ func TestExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { // --userns=keep-id token silently reintroduces the arbitrary-host-uid defect // (the agent ends up as the host uid, not 1000, and cannot own /nix). func TestCreateArgsRemapsUserns(t *testing.T) { - args := createArgs(ContainerSpec{Name: "c", Image: "img", UID: 1000}) + args := createArgs(WorkloadSpec{Name: "c", Image: "img", UID: 1000}) if !slices.Contains(args, "--userns=keep-id:uid=1000,gid=1000") { t.Fatalf("createArgs = %q, want it to contain %q", args, "--userns=keep-id:uid=1000,gid=1000") } @@ -145,7 +145,7 @@ func TestParsePodmanVersion(t *testing.T) { func TestExecStreamingArgsMinimalOmitsUserAndWorkdir(t *testing.T) { spec := NewStreamingExecSpec("compass-agent") - args := execStreamingArgs(ContainerID("c"), spec) + args := execStreamingArgs(WorkloadID("c"), spec) want := []string{"exec", "--interactive", "c", "compass-agent"} if !slices.Equal(args, want) { @@ -158,7 +158,7 @@ func TestExecStreamingArgsMinimalOmitsUserAndWorkdir(t *testing.T) { // wrong Go template would silently read the wrong field (or the whole inspect // JSON), so the relabel would target the wrong MCS category. func TestInspectMountLabelArgsPinsFormat(t *testing.T) { - args := inspectMountLabelArgs(ContainerID("ctr123")) + args := inspectMountLabelArgs(WorkloadID("ctr123")) want := []string{"inspect", "--format", "{{.MountLabel}}", "ctr123"} if !slices.Equal(args, want) { @@ -172,7 +172,7 @@ func TestInspectMountLabelArgsPinsFormat(t *testing.T) { // wedge the host. A dropped --volumes silently reintroduces that leak on the // production removal path, which has no other guard. func TestRemoveArgsCarriesVolumes(t *testing.T) { - args := removeArgs(ContainerID("ctr123")) + args := removeArgs(WorkloadID("ctr123")) want := []string{"rm", "--force", "--volumes", "ctr123"} if !slices.Equal(args, want) { @@ -180,7 +180,7 @@ func TestRemoveArgsCarriesVolumes(t *testing.T) { } } -// Resize is frozen into the ContainerRuntime seam at S1 but its behavior is +// Resize is frozen into the WorkloadRuntime seam at S1 but its behavior is // C3's: PodmanCLI.Resize must return ErrResizeNotImplemented, never a silent // nil. A no-op success would let a future caller believe a container's cgroup // limits were raised when they never moved — the exact false-positive the @@ -188,7 +188,7 @@ func TestRemoveArgsCarriesVolumes(t *testing.T) { // so C3 (which replaces the body with the real `podman update` change) // deliberately deletes this test rather than silently regressing past it. func TestResizeReservedUntilC3(t *testing.T) { - err := NewPodmanCLI().Resize(context.Background(), ContainerID("ctr123"), ResourceLimits{CPUShares: 512, MemoryBytes: 1 << 30}) + err := NewPodmanCLI().Resize(context.Background(), WorkloadID("ctr123"), ResourceLimits{CPUShares: 512, MemoryBytes: 1 << 30}) if !errors.Is(err, ErrResizeNotImplemented) { t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) } @@ -201,7 +201,7 @@ func TestExecStreamingArgsCarriesInlineEnvNotEnvFile(t *testing.T) { spec := NewStreamingExecSpec("compass-agent").AsUser("1000").InDir("/work") spec.Env["HOME"] = "/home/agent" - args := execStreamingArgs(ContainerID("ctr123"), spec) + args := execStreamingArgs(WorkloadID("ctr123"), spec) want := []string{ "exec", "--interactive", @@ -265,7 +265,7 @@ func TestSpawnCaptureWaitDelayBoundsLeakedPipeHang(t *testing.T) { // goroutine once the test has already failed on the safety deadline. done := make(chan error, 1) go func() { - _, err := cli.Exec(ctx, ContainerID("c"), NewExecSpec("true")) + _, err := cli.Exec(ctx, WorkloadID("c"), NewExecSpec("true")) done <- err }() @@ -340,7 +340,7 @@ func TestChildHandleTerminateKillsAndReaps(t *testing.T) { cli := NewPodmanCLI().WithProgram(prog) ctx := t.Context() - se, err := cli.ExecStreaming(ctx, ContainerID("c"), NewStreamingExecSpec("compass-agent")) + se, err := cli.ExecStreaming(ctx, WorkloadID("c"), NewStreamingExecSpec("compass-agent")) if err != nil { t.Fatalf("ExecStreaming: %v", err) } diff --git a/go/internal/runtime/secrets_materialize.go b/go/internal/runtime/secrets_materialize.go index 778d8ea28..44e20c0b6 100644 --- a/go/internal/runtime/secrets_materialize.go +++ b/go/internal/runtime/secrets_materialize.go @@ -142,13 +142,13 @@ func (e SecretEnv) GoString() string { return e.String() } // RIG-1327 T5, driven from the SecretsVersion dispatch hook (initial materialize // and rotation ride the same signal path). type SecretMaterializer struct { - runtime ContainerRuntime + runtime WorkloadRuntime log *slog.Logger } // NewSecretMaterializer builds a materializer over the container engine. A nil // log falls back to slog.Default. -func NewSecretMaterializer(runtime ContainerRuntime, log *slog.Logger) *SecretMaterializer { +func NewSecretMaterializer(runtime WorkloadRuntime, log *slog.Logger) *SecretMaterializer { if log == nil { log = slog.Default() } @@ -358,7 +358,7 @@ func EnvFileScript(homeDir string, envs []SecretEnv) (string, error) { // install per host, and generic file-delivery secrets write to // $HOME/.compass/secrets/. Each setup script is fed to `sh -s` over stdin // as the agent uid in the agent's $HOME, the git-credential posture. -func (m *SecretMaterializer) Install(ctx context.Context, id ContainerID, homeDir string, uid uint32, resolved []secrets.ResolvedSecret) error { +func (m *SecretMaterializer) Install(ctx context.Context, id WorkloadID, homeDir string, uid uint32, resolved []secrets.ResolvedSecret) error { seed := ProviderSeed{Entries: map[string]ProviderSeedEntry{}} var files []SecretFile var ghCreds []GHCredentials @@ -431,7 +431,7 @@ func (m *SecretMaterializer) Install(ctx context.Context, id ContainerID, homeDi // runScript feeds one setup script to `sh -s` over stdin as the agent uid in the // agent's $HOME — never `sh -c`, never argv (the secret is in the script body, // and argv is visible in the container's process list while stdin is not). -func (m *SecretMaterializer) runScript(ctx context.Context, id ContainerID, homeDir string, uid uint32, stage, script string) error { +func (m *SecretMaterializer) runScript(ctx context.Context, id WorkloadID, homeDir string, uid uint32, stage, script string) error { spec := NewExecSpec("sh", "-s"). AsUser(strconv.FormatUint(uint64(uid), 10)). InDir(homeDir). diff --git a/go/internal/runtime/secrets_materialize_test.go b/go/internal/runtime/secrets_materialize_test.go index 65b9bf21e..0d96a0a99 100644 --- a/go/internal/runtime/secrets_materialize_test.go +++ b/go/internal/runtime/secrets_materialize_test.go @@ -29,7 +29,7 @@ import ( "github.com/RigelBuild/compass/go/internal/secrets" ) -// scriptRunner is a ContainerRuntime whose Exec actually runs the setup script +// scriptRunner is a WorkloadRuntime whose Exec actually runs the setup script // through /bin/sh over stdin (as the real container would run `sh -s`), against // the host filesystem — so the files a script writes are real and inspectable. // AsUser is ignored (a test can't setuid); every other effect is genuine. The @@ -39,12 +39,12 @@ type scriptRunner struct { specs []ExecSpec } -func (r *scriptRunner) Create(context.Context, ContainerSpec) (ContainerID, error) { - return ContainerID("fake"), nil +func (r *scriptRunner) Create(context.Context, WorkloadSpec) (WorkloadID, error) { + return WorkloadID("fake"), nil } -func (r *scriptRunner) Start(context.Context, ContainerID) error { return nil } +func (r *scriptRunner) Start(context.Context, WorkloadID) error { return nil } -func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) (ExecOutput, error) { +func (r *scriptRunner) Exec(ctx context.Context, _ WorkloadID, spec ExecSpec) (ExecOutput, error) { r.mu.Lock() r.specs = append(r.specs, spec) r.mu.Unlock() @@ -58,7 +58,7 @@ func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) ( err := cmd.Run() if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { // A non-zero script exit is a successful runtime call returning a failed - // command (the ContainerRuntime contract), never a spawn error. + // command (the WorkloadRuntime contract), never a spawn error. return ExecOutput{Stderr: stderr.String(), ExitCode: exitErr.ExitCode()}, nil } if err != nil { @@ -67,16 +67,16 @@ func (r *scriptRunner) Exec(ctx context.Context, _ ContainerID, spec ExecSpec) ( return ExecOutput{}, nil } -func (r *scriptRunner) ExecStreaming(context.Context, ContainerID, StreamingExecSpec) (*StreamingExec, error) { +func (r *scriptRunner) ExecStreaming(context.Context, WorkloadID, StreamingExecSpec) (*StreamingExec, error) { return nil, errors.New("scriptRunner does not support streaming exec") } -func (r *scriptRunner) Stop(context.Context, ContainerID, time.Duration) error { return nil } -func (r *scriptRunner) Remove(context.Context, ContainerID) error { return nil } -func (r *scriptRunner) Exists(context.Context, string) (bool, error) { return false, nil } -func (r *scriptRunner) MountLabel(context.Context, ContainerID) (string, error) { +func (r *scriptRunner) Stop(context.Context, WorkloadID, time.Duration) error { return nil } +func (r *scriptRunner) Remove(context.Context, WorkloadID) error { return nil } +func (r *scriptRunner) Exists(context.Context, string) (bool, error) { return false, nil } +func (r *scriptRunner) MountLabel(context.Context, WorkloadID) (string, error) { return "", nil } -func (r *scriptRunner) Resize(context.Context, ContainerID, ResourceLimits) error { +func (r *scriptRunner) Resize(context.Context, WorkloadID, ResourceLimits) error { return nil } @@ -224,7 +224,7 @@ func TestInstallRejectsBadGenericName(t *testing.T) { home := t.TempDir() rt := &scriptRunner{} m := NewSecretMaterializer(rt, discardLog()) - err := m.Install(context.Background(), ContainerID("c"), home, 1000, []secrets.ResolvedSecret{ + err := m.Install(context.Background(), WorkloadID("c"), home, 1000, []secrets.ResolvedSecret{ {Name: "../evil", Value: "v", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, }) if err == nil { @@ -272,7 +272,7 @@ func TestInstallRoutesByKind(t *testing.T) { {Name: "GH", Value: "gho_token", Kind: secrets.SecretGH, Host: "github.com", Delivery: secrets.DeliveryFile}, {Name: "DB_URL", Value: "postgres://db", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -338,7 +338,7 @@ func TestInstallWritesEnvDeliveryToEnvFile(t *testing.T) { {Name: "ENV_ONLY", Value: "env-secret", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryEnv}, {Name: "FILE_ONE", Value: "file-secret", Kind: secrets.SecretGeneric, Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install with an env secret = %v, want nil", err) } @@ -405,7 +405,7 @@ func TestInstallMultipleGHHostsAllLand(t *testing.T) { {Name: "GH_DOTCOM", Value: "gho_dotcom", Kind: secrets.SecretGH, Host: "github.com", Delivery: secrets.DeliveryFile}, {Name: "GH_ENTERPRISE", Value: "gho_ghe", Kind: secrets.SecretGH, Host: "ghe.example.com", Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -507,7 +507,7 @@ func TestInstallMultipleProvidersAllLand(t *testing.T) { {Name: "OPENAI", Value: "sk-openai", Kind: secrets.SecretProvider, Provider: "openai", Delivery: secrets.DeliveryFile}, {Name: "ANTHROPIC", Value: "sk-anthropic", Kind: secrets.SecretProvider, Provider: "anthropic", Delivery: secrets.DeliveryFile}, } - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, resolved); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, resolved); err != nil { t.Fatalf("Install = %v, want nil", err) } @@ -530,7 +530,7 @@ func TestInstallEmptySetWritesOnlyTheEmptyEnvFile(t *testing.T) { rt := &scriptRunner{} m := NewSecretMaterializer(rt, discardLog()) - if err := m.Install(context.Background(), ContainerID("c"), home, 1000, nil); err != nil { + if err := m.Install(context.Background(), WorkloadID("c"), home, 1000, nil); err != nil { t.Fatalf("Install(empty) = %v, want nil", err) } // Exactly one exec: the env-file write. diff --git a/go/internal/runtime/userns_remap_test.go b/go/internal/runtime/userns_remap_test.go index 97b823588..9780e1d2c 100644 --- a/go/internal/runtime/userns_remap_test.go +++ b/go/internal/runtime/userns_remap_test.go @@ -44,7 +44,7 @@ func agentRemapImageExists() bool { // createStartExec creates + starts a container from spec, execs command inside // it (as the container's default user), and returns the trimmed stdout. It // registers teardown so a leaked container never collides with the next run. -func createStartExec(t *testing.T, ctx context.Context, cli *PodmanCLI, spec ContainerSpec, command ...string) ExecOutput { +func createStartExec(t *testing.T, ctx context.Context, cli *PodmanCLI, spec WorkloadSpec, command ...string) ExecOutput { t.Helper() // Force-remove any leftover from a crashed run so the name is free, then @@ -90,7 +90,7 @@ func TestKeepIDRemapMapsHostUIDToSpecUID(t *testing.T) { t.Fatalf("target uid %d must differ from the host uid %d for the mapping to be observable", targetUID, hostUID) } - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-usernsremap-map-" + strconv.Itoa(os.Getpid()), UID: targetUID, @@ -118,7 +118,7 @@ func TestKeepIDRemapBindMountRoundTrip(t *testing.T) { ctx := context.Background() dir := t.TempDir() - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: "docker.io/library/alpine:latest", Name: "compass-usernsremap-mount-" + strconv.Itoa(os.Getpid()), UID: 2000, @@ -156,7 +156,7 @@ func TestKeepIDRemapAgentOwnsNix(t *testing.T) { } ctx := context.Background() - spec := ContainerSpec{ + spec := WorkloadSpec{ Image: agentRemapImage, Name: "compass-usernsremap-nix-" + strconv.Itoa(os.Getpid()), UID: 1000, diff --git a/go/server/lifecycle_e2e_pgtest_test.go b/go/server/lifecycle_e2e_pgtest_test.go index 55baad005..c2d90d427 100644 --- a/go/server/lifecycle_e2e_pgtest_test.go +++ b/go/server/lifecycle_e2e_pgtest_test.go @@ -648,7 +648,7 @@ func (r *e2eResolver) resolve(_ context.Context, presented string, want store.Su return r.subj, nil } -// e2eStubRuntime is the fake ContainerRuntime backing the Runner: ExecStreaming +// e2eStubRuntime is the fake WorkloadRuntime backing the Runner: ExecStreaming // spawns a real, terminatable child (a shell-stub `podman` exec-ing `sleep`) so // the session's exec reaps on ctx cancel / Stop and StartAgent's pipe drains end // on that reap. Post-#16 nothing rides stdout/stderr — the agent's protocol @@ -682,36 +682,36 @@ func newE2EStubRuntime(t *testing.T) *e2eStubRuntime { return &e2eStubRuntime{cli: runtime.NewPodmanCLI().WithProgram(prog), removed: map[string]bool{}} } -func (f *e2eStubRuntime) Create(_ context.Context, spec runtime.ContainerSpec) (runtime.ContainerID, error) { +func (f *e2eStubRuntime) Create(_ context.Context, spec runtime.WorkloadSpec) (runtime.WorkloadID, error) { // Per-call-unique engine id: the container name (NamePrefix+accountID), which // already differs per account — see the type doc for why a fixed id collides. - return runtime.ContainerID(spec.Name), nil + return runtime.WorkloadID(spec.Name), nil } -func (f *e2eStubRuntime) Start(context.Context, runtime.ContainerID) error { return nil } -func (f *e2eStubRuntime) Exec(context.Context, runtime.ContainerID, runtime.ExecSpec) (runtime.ExecOutput, error) { +func (f *e2eStubRuntime) Start(context.Context, runtime.WorkloadID) error { return nil } +func (f *e2eStubRuntime) Exec(context.Context, runtime.WorkloadID, runtime.ExecSpec) (runtime.ExecOutput, error) { return runtime.ExecOutput{}, nil } -func (f *e2eStubRuntime) ExecStreaming(ctx context.Context, id runtime.ContainerID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { +func (f *e2eStubRuntime) ExecStreaming(ctx context.Context, id runtime.WorkloadID, spec runtime.StreamingExecSpec) (*runtime.StreamingExec, error) { // A real streaming exec against the shell stub: a live, terminatable Process // whose stdout/stderr pipes StartAgent drains. The stub just sleeps, so the // pipes stay empty until the exec's context cancels (loop teardown) or Stop // terminates it (despawn/Stop). return f.cli.ExecStreaming(ctx, id, spec) } -func (f *e2eStubRuntime) Stop(context.Context, runtime.ContainerID, time.Duration) error { +func (f *e2eStubRuntime) Stop(context.Context, runtime.WorkloadID, time.Duration) error { return nil } -func (f *e2eStubRuntime) Remove(_ context.Context, id runtime.ContainerID) error { +func (f *e2eStubRuntime) Remove(_ context.Context, id runtime.WorkloadID) error { f.mu.Lock() defer f.mu.Unlock() f.removed[string(id)] = true return nil } func (f *e2eStubRuntime) Exists(context.Context, string) (bool, error) { return false, nil } -func (f *e2eStubRuntime) MountLabel(context.Context, runtime.ContainerID) (string, error) { +func (f *e2eStubRuntime) MountLabel(context.Context, runtime.WorkloadID) (string, error) { return "", nil } -func (f *e2eStubRuntime) Resize(context.Context, runtime.ContainerID, runtime.ResourceLimits) error { +func (f *e2eStubRuntime) Resize(context.Context, runtime.WorkloadID, runtime.ResourceLimits) error { return nil } diff --git a/proto/compass/v1/guest_control.proto b/proto/compass/v1/guest_control.proto index 196493808..40ea5fb55 100644 --- a/proto/compass/v1/guest_control.proto +++ b/proto/compass/v1/guest_control.proto @@ -182,10 +182,10 @@ message ProvisionRequest { // skips the arm and is a hermetic test seam (the host production path always // sends a non-empty default-deny ruleset). string nft_script = 1; - // default_exec_uid is the session's agent uid (ContainerSpec.UID), the + // default_exec_uid is the session's agent uid (WorkloadSpec.UID), the // default for an exec with no uid. Validated non-zero. uint32 default_exec_uid = 2; - // base_env is the base environment every exec inherits (ContainerSpec.Env). + // base_env is the base environment every exec inherits (WorkloadSpec.Env). map base_env = 3; }