Skip to content

WorkerPools resolve their SandboxConfig and protect it from deletion - #2393

Merged
Dmitry Berkovich (dberkov) merged 4 commits into
agent-substrate:mainfrom
zoez7:workerpool-sandbox-config-resolve
Oct 12, 2026
Merged

Dmitry Berkovich (dberkov) merged 4 commits into
agent-substrate:mainfrom
zoez7:workerpool-sandbox-config-resolve

Conversation

@zoez7

@zoez7 Zoe Zhao (zoez7) commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  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.

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.

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
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:

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}}'.

  • Tests pass

  • Appropriate changes to documentation are included in the PR

@zoez7
Zoe Zhao (zoez7) force-pushed the workerpool-sandbox-config-resolve branch 4 times, most recently from 816c0e1 to 116b4ec Compare October 9, 2026 16:53
@zoez7 Zoe Zhao (zoez7) changed the title WorkerPools resolve their SandboxConfig and keep configs in use from being deleted WorkerPools resolve their default SandboxConfig and keep configs in use from being deleted Oct 9, 2026
@zoez7
Zoe Zhao (zoez7) force-pushed the workerpool-sandbox-config-resolve branch 4 times, most recently from 7b825b2 to 5a1f9fe Compare October 11, 2026 04:59
@zoez7
Zoe Zhao (zoez7) marked this pull request as ready for review October 11, 2026 05:49
@zoez7
Zoe Zhao (zoez7) force-pushed the workerpool-sandbox-config-resolve branch from 85b5075 to 73f78cf Compare October 11, 2026 05:58
@zoez7 Zoe Zhao (zoez7) changed the title WorkerPools resolve their default SandboxConfig and keep configs in use from being deleted WorkerPools resolve their SandboxConfig and protect it from deletion Oct 12, 2026
@zoez7
Zoe Zhao (zoez7) force-pushed the workerpool-sandbox-config-resolve branch from 73f78cf to 9ceebd4 Compare October 12, 2026 01:22
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"`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to status.sandboxClasses.sandboxConfig in a5b1804. The comment now points at sandboxConfigsInUse for what the index covers.

Comment thread docs/api-guide.md Outdated
| :--- | :--- | :--- |
| `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. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added pendingConfigRef.name to the row in a5b1804, marked transient, with a link to the Deletion protection section.

return err
}
}
r.recordSandboxConfigEvents(wp, classes)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@zoez7
Zoe Zhao (zoez7) force-pushed the workerpool-sandbox-config-resolve branch from 9ceebd4 to a5b1804 Compare October 12, 2026 03:38
@dberkov
Dmitry Berkovich (dberkov) added this pull request to the merge queue Oct 12, 2026
Merged via the queue into agent-substrate:main with commit 9b3f861 Oct 12, 2026
14 checks passed
Eitan Yarmush (EItanya) added a commit to kagent-dev/substrate that referenced this pull request Oct 13, 2026
* 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]>
a4-a4s1 Bot pushed a commit to AlexBulankou/substrate that referenced this pull request Oct 14, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants