Repository navigation
WorkerPools resolve their SandboxConfig and protect it from deletion - #2393
Conversation
816c0e1 to
116b4ec
Compare
7b825b2 to
5a1f9fe
Compare
85b5075 to
73f78cf
Compare
73f78cf to
9ceebd4
Compare
atecontroller now resolves every entry in spec.sandboxClasses to a SandboxConfig. If the entry has a configRef, that config is used. If it has no configRef, the newest config of that class with the annotation sandboxconfig.ate.dev/is-class-default: "true" is used. If two configs were created at the same time, the one whose name sorts first alphabetically wins. For each class, status.sandboxClasses records which config the class uses and a Resolved condition. When no config can be resolved, the condition is False with one of these reasons: SandboxConfigNotFound, SandboxConfigDeleting, SandboxClassMismatch, or DefaultConfigNotFound. The status entry keeps the config it resolved last time, so a running pool never loses its config. A SandboxConfig that is being deleted is never resolved for a WorkerPool that is not already using it. A WorkerPool that is already using it keeps using it, but its Resolved condition becomes False with reason SandboxConfigDeleting so the problem is visible. The controller creates a pool's worker Deployment only after every class has a config. If the Deployment already exists, it is applied before any SandboxConfig work, so scaling and updates keep working even when resolution or the status write fails. This is safe because worker pods do not read anything from the SandboxConfig yet. kubectl get workerpool gains two columns: the config in use and the status of the Resolved condition. The names in spec.sandboxClasses are now immutable, so a status entry can never refer to a class the pool no longer has. The counter demo no longer pins its pool to gvisor-default. Without a configRef the pool follows the class default, which is gvisor-default as installed, and moves to a newer default when one appears. The fake-workersync benchmark controller now compares WorkerPool status with DeepEqual, because status has slice fields and == no longer compiles.
When a WorkerPool resolves a SandboxConfig, the controller adds the finalizer sandboxconfig.ate.dev/workerpool-protection to that config before recording it in the pool's status. Nothing else adds this finalizer, and it stays on the config until the config is deleted. A new SandboxConfigProtectionReconciler removes the finalizer. When a config with the finalizer is marked for deletion, the reconciler checks whether any WorkerPool's status still references the config. It checks its cache first, then confirms with a live read from the API server. Only when no WorkerPool references the config does it remove the finalizer, which lets the API server finish the delete. This is a separate reconciler rather than part of the WorkerPool reconciler because WorkerPools have no finalizer of their own. A deleted WorkerPool disappears immediately, so only a loop that watches WorkerPools can notice that a config has lost its last user. A config that no WorkerPool has ever used has no finalizer and is deleted immediately. The WorkerPool controller reads SandboxConfigs from its cache, and a config can be deleted between that read and the pool's status write. If the config already carried the finalizer, the protection reconciler could release it in that window, because it cannot yet see this pool's status. The pool would then record a config that no longer exists. To close this window, a class that switches to a new config updates status in two steps. First the controller writes the new config into status.sandboxClasses[].pendingConfigRef, while configRef still points at the config in use. The protection reconciler treats a pendingConfigRef the same as a configRef, so the new config is protected from this point on. Then the controller reads the new config from the API server, not from the cache. If the config exists, belongs to the class, and is not being deleted, configRef moves to it. Otherwise the class keeps the config in use and the Resolved condition becomes False. The read happens after the claim is written, which is what makes this safe. The protection reconciler only releases a config that is already being deleted, and only after listing the WorkerPools. If it released the config, it listed the pools before the claim was written. The config was already being deleted then, so the read sees the deletion and refuses the config. As a result, a config is never released while any pool's configRef points at it, and a new pool gets its Deployment only after the switch is confirmed. A config in use that is being deleted is kept only while it still carries the finalizer. One without the finalizer is about to disappear, so the next reconcile moves the pool off it. ate-setup's DeleteAteSystem removes the finalizer after atecontroller is gone, so a leftover WorkerPool cannot block deleting the SandboxConfig CRD. install-microvm-deps.sh --delete leaves the microvm config in place while a WorkerPool still uses it, instead of waiting forever for the delete to finish.
A WorkerPool now emits two kinds of events while resolving its SandboxConfigs. A class that fails to resolve gets a Warning event whose reason is the Resolved condition's reason. The event is emitted when the failure appears or changes, not on every reconcile, so a pool that stays unresolved does not flood its events. Each time a class's status configRef changes, the pool gets a Normal event with reason SandboxConfigSwitched. When the class resolves for the first time, the event names the config. When the class switches, the event names both the old and the new config. As a result, kubectl events --for workerpool/NAME lists the configs the pool has used, in order. Events are emitted after the status write that records the change. A switch's claim write triggers another reconcile, which often reads a stale copy of the pool and sees the switch again. Its status write fails on the resourceVersion before any event is emitted, so each switch is reported once. The new e2e suite internal/e2e/suites/sandboxconfig creates a pool with no configRef and no workers, and checks that it follows the class default. The pool resolves to the installed default, moves to a newer default when one is created, moves back when that config loses the annotation, and moves back again when the config is deleted while in use. The deleted config must then be released by the protection finalizer. At the end the pool's events must list every move exactly once.
| // +optional | ||
| // +listType=map | ||
| // +listMapKey=type | ||
| Conditions []metav1.Condition `json:"conditions,omitempty"` |
There was a problem hiding this comment.
🤖 nit 🟢 – Resolved lives only inside status.sandboxClasses[], and the pool has no top-level status.conditions. kubectl wait --for=condition=Resolved workerpool/x cannot see a nested condition, and generic tooling that summarizes a resource from status.conditions sees none. Consider a top-level Resolved (or Ready) condition that summarizes the per-class ones, per the API conventions' advice to have "a common top-level condition which summarizes more detailed conditions".
There was a problem hiding this comment.
Keeping the condition per class for now since each workerpool has exactly one sandbox class today (maxItems: 1), so a top-level condition would duplicate the per-class one, and the Resolved print column already surfaces it in kubectl get workerpool. When a second class lands and there is something to summarize, a top-level condition can come with it.
|
|
||
| // workerPoolSandboxConfigIndex indexes WorkerPools by the SandboxConfigs their | ||
| // status records as in use. | ||
| const workerPoolSandboxConfigIndex = "status.sandboxClasses.configRef.name" |
There was a problem hiding this comment.
🤖 nit 🟢 – The index name says configRef.name, but sandboxConfigsInUse also indexes pendingConfigRef. A reader checking why a claimed-but-unconfirmed config is "in use" will not find it under this name. Something like status.sandboxClasses.sandboxConfig avoids the mismatch.
There was a problem hiding this comment.
Renamed to status.sandboxClasses.sandboxConfig in a5b1804. The comment now points at sandboxConfigsInUse for what the index covers.
| | :--- | :--- | :--- | | ||
| | `replicas` | `int32` | Total number of worker pods, mirrored from the managed Deployment. | | ||
| | `selector` | `string` | Label selector for the worker pods. | | ||
| | `sandboxClasses` | `[]WorkerPoolSandboxClassStatus` | One entry per sandbox class: `name`, `configRef.name` (the `SandboxConfig` in use), and `conditions`. The `Resolved` condition is `True` (reason `Resolved`) when the class's spec resolves. Otherwise it is `False` with reason `SandboxConfigNotFound`, `SandboxConfigDeleting`, `SandboxClassMismatch`, or `DefaultConfigNotFound`, the controller emits a Warning event, and `configRef` keeps the last resolved config; it is omitted if the class has never resolved. The controller creates the worker Deployment only once every class has a config, and keeps updating an existing Deployment while a class does not resolve. | |
There was a problem hiding this comment.
🤖 nit 🟢 – This row lists name, configRef.name, and conditions but not pendingConfigRef, which is part of the same entry and is only explained further down under "Deletion protection". Mention it here, even as "transient, see Deletion protection", so the table matches the schema.
There was a problem hiding this comment.
Added pendingConfigRef.name to the row in a5b1804, marked transient, with a link to the Deletion protection section.
| return err | ||
| } | ||
| } | ||
| r.recordSandboxConfigEvents(wp, classes) |
There was a problem hiding this comment.
🤖 nit 🟢 – These events are emitted before the status write below lands. If that write fails (for example a conflict with a concurrent spec edit), the next reconcile sees the old configRef in status, takes the same switch again, and emits the same SandboxConfigSwitched event a second time. The Warning dedupe has the same gap. Snapshot the previous status before this call and emit after syncStatus succeeds, so an event means the switch was recorded.
There was a problem hiding this comment.
Fixed in b9439d0. The reconcile snapshots status.sandboxClasses before the claim write and emits after the final syncStatus succeeds, diffing against that snapshot. A reconcile whose write is rejected emits nothing, so a retried switch is reported once. TestReconcileReportsSwitchOnceWritten covers one to three conflicts before the write lands, and the e2e test checks that each move shows up as exactly one event with no series.
| // recordSandboxConfigEvents emits an event for every class in classes whose | ||
| // state differs from wp's status. A class whose configRef changes gets a | ||
| // Normal event naming the old and new config, so the pool's events keep the | ||
| // history of the configs it has used; it runs once the switch stands, after |
There was a problem hiding this comment.
🤖 nit 🟢 – Events are not a durable history. The API server drops them after its event TTL (one hour by default), so kubectl events shows only recent switches. Worth saying "recent" here and in the api-guide sentence that promises "the configs the pool has used", or pointing readers at their logging backend for the full trail.
There was a problem hiding this comment.
Reworded in a5b1804. The comment on recordSandboxConfigEvents and the api-guide sentence now say "recent", note the one-hour default event TTL, and point at a logging backend that stores events for the full trail.
- Rename the WorkerPool cache index to status.sandboxClasses.sandboxConfig. It indexes pendingConfigRef as well as configRef, so a name that mentions only configRef misleads a reader checking why a claimed config is in use. - List pendingConfigRef in the api-guide's status table, so the row matches the schema instead of leaving the field to the Deletion protection section. - Say that the pool's events are its recent history. The API server drops events after their TTL, one hour by default, so only a logging backend that stores events has the full trail.
9ceebd4 to
a5b1804
Compare
* cmd/atenet: Remove the router's "all" mode (agent-substrate#2441) All of our in-tree deployments explicitly deploy the router in either ingress or egress mode. Attempting to one run router instance that handles both ingress and egress seems pretty confusing, so we should get rid of the flag that lets us do so. In the future, we might want to split the ingress router and the egress router into separate atenet subcommands (or even separate binaries). * test: Fix macos unit test failures (agent-substrate#2429) # Description `make test` failed on macOS for two host-specific reasons. The DNS relay referenced Linux-only network namespace APIs from an otherwise portable file, and the object storage tests inherited exported `AWS_PROFILE` and `AWS_DEFAULT_PROFILE` values from the developer's shell. Move only the namespace-serving method behind a Linux build tag so portable DNS tests still run on macOS. Clear both AWS profile selectors in the object storage test so local AWS configuration cannot affect it. - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR --------- Signed-off-by: timflannagan <[email protected]> * Update x/net to v0.60.0 to fix govulncheck failures (agent-substrate#2401) Both modules in the post-merge govulncheck matrix resolve golang.org/x/net v0.58.0 and fail on five newly published HTTP/2 vulnerabilities. Update x/net to v0.60.0 in the root module and the GCP Secret Manager plugin, include its required transitive dependency updates, and regenerate vendor. Go also updates the required x/crypto, x/sync, x/sys, x/term, and x/text versions. All first-party changes are in the two go.mod/go.sum pairs; the other changes are generated vendor updates. Affected advisories: GO-2026-6603, GO-2026-6610, GO-2026-6611, GO-2026-6612, and [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617). The [latest failing main run](https://github.com/agent-substrate/substrate/actions/runs/37911293778) already selects Go 1.27.2; its remaining failures are all in x/net. The earlier standard-library findings were resolved by the workflow selecting the patched stable toolchain. This change preserves the current Go version requirement. Validation after merging main `59072f7b`: - Linux/amd64 with Go 1.27.1: `go test -race -count=1 ./...` passed with PostgreSQL/envtest integration enabled (`CI=true`). - All checks in `bash hack/verify-all.sh` passed, including generated-code and vendor consistency, and the checkout remained clean. - GCP Secret Manager plugin race tests passed. - govulncheck v1.8.0 with Go 1.27.2: both root and plugin modules passed with exit 0, zero vulnerable imported packages, and zero reachable findings. Both report GO-2026-5932 only at module level in unimported x/crypto code. The merge retains main's direct x/net dependency classification at v0.60.0. Canonical v0.60.0 DNS parser content was restored in vendor after the generation check found that the automatic merge had retained three older statements. Verification ran as an unprivileged user. Root-gated mount tests and real cluster E2E were not run for this head. AI assistance was used to resolve the merge conflicts and run verification. * ROOTFS snapshot fidelity: micro-VM capture and restore, API opened for micro-VM templates (agent-substrate#2448) Step III of agent-substrate#2102, tracked in agent-substrate#2447. Step I (agent-substrate#2309) removed `on_pause`; step II (agent-substrate#2364) introduced the `SnapshotFidelity` ladder with ROOTFS defined but rejected everywhere. **ROOTFS becomes real on micro-VM.** A ROOTFS snapshot holds the durable volumes plus each container's root-filesystem changes, without guest memory: the actor cold-boots on resume but keeps what it wrote to its rootfs. gVisor keeps rootfs changes inside its memory checkpoint and cannot serve it yet; it now declares that explicitly and a gVisor template that asks for ROOTFS is rejected at admission. Four commits, meant to be read in order. Each builds and passes tests on its own, and ROOTFS stays unreachable from the public API until the third. ## 1. atelet: forward ROOTFS and stop narrowing paused uploads Atelet accepts every level of the ladder and forwards it; which levels a runtime serves is the runtime's knowledge. The paused-upload path still converted a MEMORY pause into a VOLUMES upload, a leftover from the days of separate pause and commit scopes. Pause and suspend now capture one fidelity, a paused actor's template cannot change, and golden actors are never paused, so captured and requested fidelity are always equal; the conversion is replaced by an equality check that refuses any mismatch as drift. ## 2. ateom-microvm: capture and restore ROOTFS Every piece already existed: MEMORY checkpoints tar the host-side rootfs upper per container alongside the Cloud Hypervisor snapshot, and MEMORY restores untar those uppers before assembling the overlays. ROOTFS is the same checkpoint without the VM snapshot, and the upper restore followed by the VOLUMES cold-boot path with a new `keepRootfsUpper` boot parameter so the cold boot does not wipe the restored upper (safe across its retry). Only a MEMORY restore resumes the guest's CPU counters; ROOTFS counts from zero like VOLUMES. `resources.ValidateSnapshotFidelity` now takes the runtime's supported set: micro-VM passes all three, gVisor passes VOLUMES and MEMORY and rejects ROOTFS before taking the actor lock, naming the levels it serves. ## 3. ateapi: allow ROOTFS on micro-VM templates The field-level rejection moves to the template-level cross-field hook: ROOTFS requires `sandbox_config.sandbox_class: SANDBOX_CLASS_MICROVM`. An actor can only be repointed to a template with an identical sandbox config, so a ROOTFS template never ends up under a gVisor actor. The control plane needs no logic change: a ROOTFS snapshot restores at ROOTFS, and a repointed actor drops to VOLUMES as it does for MEMORY, since a rootfs upper is only valid over the image it was written on. ## 4. e2e: prove it on the counter demo The counter app gains a third counter in a file on its root filesystem, printed in the response, so a ROOTFS snapshot is distinguishable from VOLUMES. All lifecycle rows state their rootfs expectation (MEMORY and ROOTFS keep it, VOLUMES resets it). New micro-VM-only ROOTFS rows cover pause, suspend, suspend from PAUSED, and the template repoint and revert. On the gVisor CI lane the ROOTFS rows skip; the new rootfs assertions on MEMORY and VOLUMES rows run there as a side check. ## Behavior changes - **API**: `SNAPSHOT_FIDELITY_ROOTFS` is accepted on micro-VM templates and rejected on gVisor ones with a message naming the requirement. No field or wire changes; the `preferred_fidelity` field-level custom validation tag is removed. - **Atelet**: a paused-upload whose desired fidelity differs from the captured one is now `FailedPrecondition`; previously a MEMORY capture could be downgraded to VOLUMES. Unreachable in practice, as explained in commit 1. - Unchanged: MEMORY and VOLUMES capture and restore, the golden-actor MEMORY override, metric labels (`rootfs` was already a member). - No backward compatibility is kept, per the pre-v1.0 policy. ## Not in this PR `required_fidelity`, `ResumeActorResponse.resumed_fidelity`, the resume fallback rule, gVisor ROOTFS (pending the gVisor team's separable rootfs capture), and restoring a MEMORY snapshot as a ROOTFS cold boot (the artifacts already allow it; atelet would need the rootfs file subset in the manifest to skip the memory image). ## Verification - Unit tests for atelet, ateom-microvm, ateom-gvisor, resources, ateattr, ateomphaselog, ateapi (validation, controlapi, functional), kubectl-ate, and apitool pass; the ateom tests are linux-tagged and were type-checked for linux locally. - golangci-lint, kube-api-linter, python-protos, boilerplate pass; codegen shows no drift. - The micro-VM counter demo was run manually on a GKE cluster with a nested-virtualization node pool and behaved as expected. The ROOTFS e2e rows need a micro-VM lane; the gVisor lane runs the rest. - [x] Breaking change - [x] Not backward compatible 🤖 Generated with [Claude Code](https://claude.com/claude-code) * fix(kubectl-ate): validate atespace flags before connecting (agent-substrate#2464) `get actors` and `get actor-template` now reject missing or conflicting atespace flags before loading kubeconfig or connecting to the API. Previously, a kubeconfig or connection error could hide the argument error. The tests use a missing kubeconfig to check that invalid flags are rejected first and valid queries still reach client initialization. Refs agent-substrate#2463 - [x] Tests pass: `go test -race ./cmd/kubectl-ate/...` - [x] Full Go suite passes: `env -u NO_COLOR go test -race -p 1 ./...` - [x] Lint passes: `hack/verify/golangci-lint.sh` The first full-suite run failed in the CPU timing test and in color-output tests with `NO_COLOR=1`. The serial run above passed. The Python license and proto checks could not finish because `pypi.org` DNS lookup failed. The remaining repository verifiers passed; metrics and shellcheck used local binaries at the versions pinned by the scripts. * Allow ActorTemplates to disable golden snapshot creation (agent-substrate#2328) ActorTemplates can opt out of automated golden snapshot creation with `snapshotConfig.goldenSnapshotConfig.mode: GOLDEN_SNAPSHOT_MODE_DISABLED`, avoiding unused golden actors and snapshots. Omitting the configuration or leaving the mode `UNSPECIFIED` defaults to `ENABLED`. The mode is an enum with declarative validation, following the existing snapshot fidelity pattern. Disabled templates are skipped during queue resync and reconciliation. Their regular actors can cold boot and suspend/resume using their own snapshots. `kubectl ate get actor-templates` displays `DISABLED` in the `GOLDEN TAG` column. The API guide, field table, glossary, and CLI documentation describe the opt-out. The functional regression starts a real `ActorTemplateReconciler`. An enabled control template proves it is making progress while the disabled template's golden actor and tag both remain absent. The test also verifies the disabled template's actor cold-boots, creates its own durable snapshot on suspend, and restores that snapshot on resume. The disabled reconciliation case is part of the existing table-driven unit test. Implements the golden snapshot opt-out feature discussed in agent-substrate#2068. AI assistance: Codex helped implement and test these changes. ### Validation - macOS arm64: race and coverage tests passed for defaulting, API validation, control-plane unit tests, and CLI printing. - The CLI table regression failed before adding the disabled state and passed after the change. - Linux amd64, Go 1.27.1: `go test -race ./...` passed for all 147 packages with tests. - With `CI=true`, fresh race tests for control-plane functional tests and PostgreSQL storage passed: 612 tests/subtests, zero failures and zero skips. - Removing both disabled-mode guards made the lifecycle regression fail because it found the disabled template's golden actor. The complete implementation passed. - `hack/verify-all.sh`: all 14 verifiers passed with unmodified settings. The first attempt hit a linter package-loading timeout; a longer standalone lint run reported `0 issues`, and the standard verifier run then passed after the cache warmed. - Linux builds of `ateapi` and `kubectl-ate` passed. Functional tests use real PostgreSQL and an envtest Kubernetes API with a fake atelet. Real gVisor E2E was not run. - [x] Appropriate changes to documentation are included in the PR. - [x] Race tests, repository verifiers, and affected binaries were checked. --------- Co-authored-by: cloudonly <[email protected]> * atunnel: strip X-Ate-Assignment-Stale from upstream actor responses (agent-substrate#2410) ### Motivation `atunnel` defines `StaleAssignmentHeader = "X-Ate-Assignment-Stale"` to distinguish an `atunnel` infrastructure-level routing rejection (HTTP 421 `Misdirected Request`, returned when an actor is no longer active on that worker) from an HTTP 421 returned by the guest actor application itself (`internal/atunnel/ingress.go#L45-L47`). While `newActorProxy` already strips internal Substrate headers (`ate-target-port`, `ate-target-actor`) on the request leg into the sandbox, it lacked response sanitization on the return leg. As a result, an untrusted actor workload running inside the container could return `X-Ate-Assignment-Stale: true` and spoof an infrastructure routing rejection to downstream routers or clients. ### Changes 1. **`internal/atunnel/ingress.go`**: Added a `ModifyResponse` hook to `newActorProxy()` that removes `X-Ate-Assignment-Stale` from any response received from the actor upstream. 2. **`internal/atunnel/ingress_test.go`**: Added unit test `TestServeHTTPStripsStaleAssignmentHeaderFromActor` asserting that even if the actor application returns `X-Ate-Assignment-Stale: true`, the header is stripped before the response reaches the client. ### Testing - `go test -race -count=1 ./internal/atunnel/...` - `./hack/verify/boilerplate.sh` - `./hack/verify/gofmt.sh` - `./hack/verify/golangci-lint.sh` > The PR title becomes the release note. Write it for users: what changed for them, not how the code changed. AI was used to assist in the development of these changes. - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR * WorkerPools resolve their SandboxConfig and protect it from deletion (agent-substrate#2393) Part of agent-substrate#587. Step 3 of the SandboxConfig management design, after agent-substrate#2332 and agent-substrate#2333. Each WorkerPool now resolves one SandboxConfig per sandbox class, reports it in status, and protects it from being deleted while in use. Workers do not read the resolved config yet; that is the next step. ## How to review The PR is three commits. Each one builds and passes its tests on its own, so they can be reviewed in order. 1. **Resolve each WorkerPool's SandboxConfig into status.** The API change, the resolution rules, and the `Resolved` condition. No events, no finalizer. 2. **Protect SandboxConfigs in use with a finalizer.** The finalizer, the reconciler that releases it, the two-step status write for a switch, and the `ate-setup` and `install-microvm-deps.sh` changes that deal with the finalizer. 3. **Emit events for a WorkerPool's SandboxConfig and add e2e tests.** The Warning and `SandboxConfigSwitched` events, and an e2e suite that walks a pool through every move. ## Resolution Without `configRef`: the pool uses the class default. ```yaml apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: name: counter-pool namespace: ate-demo generation: 1 spec: replicas: 2 workerImage: ateom-gvisor:v1 sandboxClasses: - name: gvisor # no configRef status: replicas: 2 readyReplicas: 2 selector: ate.dev/worker-pool=counter-pool sandboxClasses: - name: gvisor configRef: name: gvisor-default # newest gvisor config annotated is-class-default conditions: - type: Resolved status: "True" reason: Resolved message: resolved to SandboxConfig "gvisor-default" observedGeneration: 1 ``` With `configRef` pointing at a config that does not exist: the pool keeps the config it resolved last time. ```yaml apiVersion: ate.dev/v1alpha1 kind: WorkerPool metadata: name: pinned-pool namespace: ate-demo generation: 2 spec: replicas: 2 workerImage: ateom-gvisor:v1 sandboxClasses: - name: gvisor configRef: name: gvisor-release2 # does not exist status: replicas: 2 readyReplicas: 2 selector: ate.dev/worker-pool=pinned-pool sandboxClasses: - name: gvisor configRef: name: gvisor-release1 # still in use, still protected conditions: - type: Resolved status: "False" reason: SandboxConfigNotFound message: SandboxConfig "gvisor-release2" not found; keeping "gvisor-release1" observedGeneration: 2 ``` - With `configRef`: that config is used. It must exist, belong to the same class, and not be in the process of being deleted. - Without `configRef`: the newest config of the class that is annotated `sandboxconfig.ate.dev/is-class-default: "true"` and is not being deleted is used. - If no config can be resolved, the status `configRef` keeps the config resolved last time, and the `Resolved` condition is `False` with reason `SandboxConfigNotFound`, `SandboxClassMismatch`, `SandboxConfigDeleting`, or `DefaultConfigNotFound`. A Warning event is emitted when the reason changes. - `kubectl get workerpool` gains `SANDBOXCONFIG` and `RESOLVED` columns. - The counter demo no longer pins its pool to `gvisor-default`. It follows the class default. ## Switching configs When a class switches to a different config, the controller writes status twice. First it records the new config in `status.sandboxClasses[].pendingConfigRef`, which protects the new config while the current one stays in `configRef`. Then it reads the new config from the API server. If the config exists, belongs to the class, and is not being deleted, `configRef` moves to it. Otherwise the class keeps the current config and `Resolved` becomes `False`. This prevents a pool from recording a config that was deleted between the cache read and the status write. ## Events Each time a class's `configRef` changes, the controller emits a Normal event with reason `SandboxConfigSwitched`. The event is emitted after the status write that records the change, so each switch shows up once. A reconcile that leaves the config unchanged emits nothing, so the pool's events show the configs it has used, in order. A class that fails to resolve emits a Warning event with the condition reason. To see the history for one pool: ``` kubectl -n ate-demo events --for workerpool/counter-pool ``` Example for the counter pool, which has no `configRef` and so follows the class default. It first resolved to `gvisor-default`. Then a newer config `gvisor-default-v2` annotated `is-class-default: "true"` was created, and the pool switched to it on its own. Then `gvisor-default-v2` lost the annotation, got it back, and was finally deleted. A config that is being deleted is no longer a class default, so the pool moved back to `gvisor-default`, and the finalizer on `gvisor-default-v2` was released once no pool used it. ``` $ kubectl -n ate-demo-counter events --for workerpool/counter LAST SEEN TYPE REASON OBJECT MESSAGE 61s Normal SandboxConfigSwitched WorkerPool/counter sandbox class "gvisor" uses SandboxConfig "gvisor-default" 53s Normal SandboxConfigSwitched WorkerPool/counter sandbox class "gvisor" switched from SandboxConfig "gvisor-default" to "gvisor-default-v2" 46s Normal SandboxConfigSwitched WorkerPool/counter sandbox class "gvisor" switched from SandboxConfig "gvisor-default-v2" to "gvisor-default" 39s Normal SandboxConfigSwitched WorkerPool/counter sandbox class "gvisor" switched from SandboxConfig "gvisor-default" to "gvisor-default-v2" 33s Normal SandboxConfigSwitched WorkerPool/counter sandbox class "gvisor" switched from SandboxConfig "gvisor-default-v2" to "gvisor-default" ``` ## Deletion protection - Before recording a config in status, the WorkerPool controller adds the finalizer `sandboxconfig.ate.dev/workerpool-protection` to it. - A new `SandboxConfigProtectionReconciler` removes the finalizer once no WorkerPool status references the config. It checks the cache first, then confirms with a live list from the API server before removing it. - A SandboxConfig that is being deleted is never resolved for a new WorkerPool and is no longer treated as the class default. WorkerPools that already use it keep using it, and because of the finalizer the config is not actually deleted until every such WorkerPool has moved to another config or has been deleted. This is what the bundled `gvisor-default` looks like once a WorkerPool has resolved it. Nothing else in the config changes. ``` kubectl get sandboxconfig gvisor-default -o yaml ``` ```yaml apiVersion: ate.dev/v1alpha1 kind: SandboxConfig metadata: name: gvisor-default annotations: sandboxconfig.ate.dev/is-class-default: "true" finalizers: - sandboxconfig.ate.dev/workerpool-protection # added by the WorkerPool controller spec: sandboxClass: gvisor defaultVersion: v1 versions: - name: v1 state: Enabled ... ``` After `kubectl delete sandboxconfig gvisor-default` while a WorkerPool still uses it, the delete does not finish. The config gets a `deletionTimestamp` and stays in the cluster with the finalizer until the last WorkerPool moves off it: ```yaml apiVersion: ate.dev/v1alpha1 kind: SandboxConfig metadata: name: gvisor-default annotations: sandboxconfig.ate.dev/is-class-default: "true" deletionTimestamp: "2026-10-11T18:42:07Z" finalizers: - sandboxconfig.ate.dev/workerpool-protection # kept while a WorkerPool status references the config spec: ... ``` To find out which WorkerPools are holding it: ``` kubectl get workerpool -A -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,SANDBOXCONFIG:.status.sandboxClasses[*].configRef.name ``` ## Testing - Unit tests cover resolution, the status write, the finalizer, the two-step switch, and the events, including that a switch is reported once when the first status write is rejected. - The new e2e suite `internal/e2e/suites/sandboxconfig` creates a pool with no `configRef` and no workers and walks it through the sequence in the events example above. Run it with `hack/run-e2e.sh ./internal/e2e/suites/sandboxconfig/...`. ## Breaking changes - A new WorkerPool gets no Deployment until every sandbox class has a resolved config. Before upgrading, make sure each class has a config annotated `is-class-default: "true"`, or set `configRef` on the pool. The bundled `gvisor-default` and the micro-VM config are already annotated. Existing pools keep their Deployment. - `spec.sandboxClasses[].name` is now immutable. To change the class, create a new pool. - Rolling atecontroller back to a version before this PR leaves the finalizer on SandboxConfigs. Remove it with `kubectl patch sandboxconfig <name> --type=merge -p '{"metadata":{"finalizers":null}}'`. - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR * Simplify Snapshot API for Actor Lifecycle v2 (agent-substrate#2449) The initial API developed in agent-substrate#2289 had a repeated SnasphotStorage structure keyed by Durability (RESIDENT, LOCAL and DURABLE). However this was overly descriptive. RESIDENT and LOCAL storage details are local to atelet, and the promotion of snapshots from RESIDENT -> LOCAL -> DURABLE is also driven by atelet. Putting the information in Snapshot API meant that every action triggered by atelet has to invoke ateapi first to record the transition. Instead, this PR simplifies the sturcture by having a single SnapshotStorage to contain the durable storage details. Locality details will not be recorded in the Snapshot API. We only record that a snapshot also has locality on a certain node. #### Breaking change LocalSnapshot is removed and replaced by a Snapshot.locality field. Part of agent-substrate#2069 - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR --------- Signed-off-by: timflannagan <[email protected]> Signed-off-by: Eitan Yarmush <[email protected]> Co-authored-by: Taahir Ahmed <[email protected]> Co-authored-by: Tim Flannagan <[email protected]> Co-authored-by: cloudonly <[email protected]> Co-authored-by: Dmitry Berkovich <[email protected]> Co-authored-by: ktsoator <[email protected]> Co-authored-by: cloudonly <[email protected]> Co-authored-by: Pancho Trujillo <[email protected]> Co-authored-by: Zoe Zhao <[email protected]> Co-authored-by: Michelle Au <[email protected]>
…gent-substrate#2521) Part of agent-substrate#587. This is the first part of the [worker sandbox versions design](https://github.com/zoez7/substrate/blob/d72bfc5d48dab71fe19fef31eb586a5c82ba7076/docs/workerpool-sandbox-assets-refresh-en.md), after agent-substrate#2393. agent-substrate#2393 made each WorkerPool record the SandboxConfig it uses in `status.sandboxClasses[].configRef`. This PR is the atelet side of getting that config to the workers. For every worker pod on its node, atelet computes the runtime hash of every enabled version of the pool's config, creates the worker's directory, and writes a versions file in it. atelet keeps the mapping from each hash back to the SandboxConfig version. The worker side is not in this PR. No ateom reads the file yet. Reading it at startup and registering again when the file changes come in the next PR. ## How the syncer works Each worker pod on the node is a key in one workqueue, drained by a single worker. The sections below explain the parts of the diagram that need a reason. ```mermaid flowchart TD subgraph Events["Informer events"] P["Pod Add / Delete"] WP["WorkerPool Add<br/>Update: configRef changed"] SC["SandboxConfig Add<br/>Update: Generation changed"] D["Startup scan of ateoms/ directories"] end P -->|"Add(uid)"| Q[("workqueue<br/>key = pod UID")] WP -->|"Add immediately / Update random delay 0-30s<br/>the pool's local pods"| Q SC -->|"random delay 0-30s<br/>local pods of pools using the config"| Q D -->|"Add, or AddAfter(until 1 minute old)"| Q W["run: wait for Pod, WorkerPool<br/>and SandboxConfig caches to sync"] --> D W --> G Q --> G["run: queue.Get()<br/>single worker"] G --> L{"pod found<br/>in cache?"} L -->|"no"| R["removeDir(uid)"] L -->|"DeletionTimestamp set"| N["skip<br/>wait for Delete event"] L -->|"yes"| S["syncPod"] S --> S1["resolve + plan<br/>pool -> configRef -> versions for this arch"] S1 --> S2["build<br/>hash each version's asset and pause image digests"] S2 --> S3["write<br/>atomically write sandbox-versions.json if changed"] R --> F{"finish(err)"} N --> F S3 --> F S1 -. "error" .-> F S2 -. "default version error" .-> F F -->|"success / errPermanent"| FG["Forget"] F -->|"fewer than 8 retries"| RL["AddRateLimited<br/>doubling from 1s, max 5 min"] F -->|"8 retries reached"| FG RL --> Q ``` ## A worker pod is scheduled to a node atelet watches the worker pods on its own node, the WorkerPools, and the SandboxConfigs. When a worker pod is scheduled to the node, atelet: - Finds the pod's WorkerPool and the config in its `configRef`. It never uses `pendingConfigRef`, which the atecontroller has not confirmed yet. - Picks the config's enabled versions that have assets for the node's architecture. - Computes each version's `sandbox_runtime_hash`: a sha256 over one sorted `key=value` line per asset digest and the pause image digest. Version names and URLs are left out, so the same binaries under another name or in another bucket are the same runtime. The hashes come from the SandboxConfig spec alone, so this downloads nothing. - Creates the worker directory `/var/lib/ate/ateoms/<pod-uid>/` and writes `sandbox-versions.json` in it. For a gVisor pool on an amd64 node using the bundled `gvisor-default`: ``` cat /var/lib/ate/ateoms/<pod-uid>/sandbox-versions.json ``` ```json { "defaultRuntime": { "sandbox_runtime_hash": "sha256:bd071f1eff6bc2b6b6598aaec03540a7c0f86163586282da2e4400a6de072b5d" } } ``` - `defaultRuntime` comes from the config's default version. It is what new and cold-booted actors start under. - `restorableRuntimes` lists the other Enabled versions in spec order. The worker can only restore snapshots under them. It is omitted when there are none. - `RegisterWorkerRequest` reports these two separately, which is why the file keeps them apart instead of a flat list of hashes. - Each runtime is listed once. A version whose hash equals an earlier one's is left out. - A non-default version with no assets for the node's architecture, or with a pause image not pinned by digest, is left out. If the default version has either problem, no file is written, since no worker of the pool could start actors on this node. The file does not promise that a runtime's assets are on the node. The existing prewarmer still downloads each config's default version, and Run/Restore still fetch any version on demand. Fetching through `cmd/atelet/internal/filecache` comes in a follow-up PR (see the TODOs in the code). ## The SandboxConfig changes, or the pool switches config When an operator edits the SandboxConfig's spec, or the pool's `configRef` moves to another config, atelet rewrites the file of every affected worker on the node. Every node sees the change within about a second, and each rewrite will make the worker register again. So that every worker in the fleet does not hit ateapi at once, each atelet waits a random delay of up to 30 seconds before it rewrites. A newly scheduled worker pod is enqueued without that delay, since only its own node acts on it and the worker is waiting for the file. Changes that do not affect the file, such as a SandboxConfig update that leaves its spec unchanged, a `pendingConfigRef`, or a replica count change, do not trigger a rewrite. ## A sync fails - If the pool has no resolved config yet, or the pool or config is not in the cache, atelet keeps the existing file and retries. The 8 retries take about 4 minutes. After that it gives up until the pool's config changes or atelet restarts. - `errPermanent` covers a pod with no worker pool label, and a config whose default version has no assets for the node or has a pause image not pinned by digest. Retrying cannot fix them, so they are not retried. ## atelet starts, the worker pod goes away, or atelet restarts - The syncer never holds up atelet startup. It waits for the Pod, WorkerPool and SandboxConfig caches to sync before it scans the worker directories or drains the queue. Before then, every pod looks gone and no pod's config resolves. While a list/watch fails (e.g. Forbidden while an RBAC rollout lags the binary), files wait and existing ones stay as they are. - When atelet restarts, the informer's initial list replays every pod as an Add, so every worker's file is rewritten from current state. - The startup scan removes directories left by pods that went away while atelet was down. It waits until a directory is a minute old, because its ateom may have created it before the pod reached atelet's cache. - The ateom no longer removes its directory on shutdown, only its own socket. The directory and the file now survive a container restart. ## Other changes - atelet's ClusterRole can now list and watch WorkerPools. - atelet watches only the worker pods on its own node (`spec.nodeName` field selector), so the list/watch cost scales with the pods on the node, not the cluster. - `hardware.AttrSandboxRuntimeHash` (`sandbox_runtime_hash`) replaces the `gvisor_asset_hash` TODO. ## Not in this PR - The ateom reading the file when it starts, and waiting for it if atelet has not written it yet. - The ateom watching the file and registering again when it changes. - Fetching assets through `cmd/atelet/internal/filecache` and removing the prewarmer. - [x] Tests pass - [ ] Appropriate changes to documentation are included in the PR
Part of #587. Step 3 of the SandboxConfig management design, after #2332 and #2333.
Each WorkerPool now resolves one SandboxConfig per sandbox class, reports it in status, and protects it from being deleted while in use. Workers do not read the resolved config yet; that is the next step.
How to review
The PR is three commits. Each one builds and passes its tests on its own, so they can be reviewed in order.
Resolvedcondition. No events, no finalizer.ate-setupandinstall-microvm-deps.shchanges that deal with the finalizer.SandboxConfigSwitchedevents, and an e2e suite that walks a pool through every move.Resolution
Without
configRef: the pool uses the class default.With
configRefpointing at a config that does not exist: the pool keeps the config it resolved last time.configRef: that config is used. It must exist, belong to the same class, and not be in the process of being deleted.configRef: the newest config of the class that is annotatedsandboxconfig.ate.dev/is-class-default: "true"and is not being deleted is used.configRefkeeps the config resolved last time, and theResolvedcondition isFalsewith reasonSandboxConfigNotFound,SandboxClassMismatch,SandboxConfigDeleting, orDefaultConfigNotFound. A Warning event is emitted when the reason changes.kubectl get workerpoolgainsSANDBOXCONFIGandRESOLVEDcolumns.gvisor-default. It follows the class default.Switching configs
When a class switches to a different config, the controller writes status twice. First it records the new config in
status.sandboxClasses[].pendingConfigRef, which protects the new config while the current one stays inconfigRef. Then it reads the new config from the API server. If the config exists, belongs to the class, and is not being deleted,configRefmoves to it. Otherwise the class keeps the current config andResolvedbecomesFalse. This prevents a pool from recording a config that was deleted between the cache read and the status write.Events
Each time a class's
configRefchanges, the controller emits a Normal event with reasonSandboxConfigSwitched. The event is emitted after the status write that records the change, so each switch shows up once. A reconcile that leaves the config unchanged emits nothing, so the pool's events show the configs it has used, in order. A class that fails to resolve emits a Warning event with the condition reason.To see the history for one pool:
Example for the counter pool, which has no
configRefand so follows the class default. It first resolved togvisor-default. Then a newer configgvisor-default-v2annotatedis-class-default: "true"was created, and the pool switched to it on its own. Thengvisor-default-v2lost the annotation, got it back, and was finally deleted. A config that is being deleted is no longer a class default, so the pool moved back togvisor-default, and the finalizer ongvisor-default-v2was released once no pool used it.Deletion protection
sandboxconfig.ate.dev/workerpool-protectionto it.SandboxConfigProtectionReconcilerremoves the finalizer once no WorkerPool status references the config. It checks the cache first, then confirms with a live list from the API server before removing it.This is what the bundled
gvisor-defaultlooks like once a WorkerPool has resolved it. Nothing else in the config changes.After
kubectl delete sandboxconfig gvisor-defaultwhile a WorkerPool still uses it, the delete does not finish. The config gets adeletionTimestampand stays in the cluster with the finalizer until the last WorkerPool moves off it:To find out which WorkerPools are holding it:
Testing
internal/e2e/suites/sandboxconfigcreates a pool with noconfigRefand no workers and walks it through the sequence in the events example above. Run it withhack/run-e2e.sh ./internal/e2e/suites/sandboxconfig/....Breaking changes
A new WorkerPool gets no Deployment until every sandbox class has a resolved config. Before upgrading, make sure each class has a config annotated
is-class-default: "true", or setconfigRefon the pool. The bundledgvisor-defaultand the micro-VM config are already annotated. Existing pools keep their Deployment.spec.sandboxClasses[].nameis now immutable. To change the class, create a new pool.Rolling atecontroller back to a version before this PR leaves the finalizer on SandboxConfigs. Remove it with
kubectl patch sandboxconfig <name> --type=merge -p '{"metadata":{"finalizers":null}}'.Tests pass
Appropriate changes to documentation are included in the PR