Repository navigation
atelet, ateworker: probe CPU features and watch sandbox-runtimes.json for dynamic worker registration - #2660
Conversation
…to sandbox-runtimes Rename the per-worker file from sandbox-versions.json to sandbox-runtimes.json (and internal/sandboxversions to internal/sandboxruntimes), and add nodepath.AteomSandboxRuntimesPath(podUID) so ateom can locate its runtimes file.
…ty attributes
A gVisor checkpoint only restores on a host that has every CPU feature the
sandbox saw, and a microVM snapshot only on the same CPU vendor and model, so
the runtime stamp on a snapshot needs more than architecture to pick a worker.
Add three attributes to internal/hardware: cpu_vendor and cpu_model, probed
from /proc/cpuinfo on amd64 and arm64, matched for equality; and cpu_features,
the names runsc cpu-features prints, encoded as a canonical hex bitmask that
MatchesCompat checks as a subset of the worker's. The mask is a fixed-width
array so the scheduler's per-worker check allocates nothing.
A name's bit is its index in a per-architecture table, and stored snapshots
carry those bits, so the table is schema. Generate it with tools/cpufeatures
from gVisor's own features_{amd64,arm64}.go, committed verbatim under
hack/third_party/gvisor with VERSION as the single pin and SHA256SUMS guarding
the copies. The generator only appends: a name gVisor drops stays at its bit
once marked retired. hack/update/cpufeatures.sh regenerates offline and
documents the bump; hack/verify/cpufeatures.sh checks it in CI.
Nothing reports these attributes yet; atelet's versions file gains them next.
…ntimes.json Each runtime entry in sandbox-runtimes.json previously reported only architecture and sandbox_runtime_hash, which is not enough to tell whether a worker can restore a snapshot whose sandbox class requires matching host CPU identity (microVM) or a superset of the checkpointed runsc binary's CPU features (gVisor). Extend workerSandboxSyncer to populate the class-specific hardware attributes for every version in the worker's SandboxConfig: - Carry the config's sandboxClass on sandboxPlan. - For microvm configs, probe the node's cpu_vendor and cpu_model once via hardware.ProbeCPUIdentity (retrying on transient probe errors) and include them alongside architecture and sandbox_runtime_hash. - For gvisor configs, fetch each version's assets into the local cache via ensureSandboxAssets, run <runsc> cpu-features (memoized by the runsc asset's SHA256 digest so each binary is fetched and executed for probing at most once), encode the output with hardware.EncodeCPUFeatures, and include cpu_features in the version's runtime attributes. - Deduplicate runtime hashes before probing, and treat permanent failures on a restorable version (such as an unpinned pause image or a broken runsc binary) by logging a warning and omitting that version from the file, while failing the sync on default-version or transient failures so the existing file stays in place until a retry succeeds.
Replace the one-shot hardware.ProbeHost report in internal/ateom with a reconciler that reads sandbox-runtimes.json from the worker directory, maps DefaultRuntime and RestorableRuntimes into VersionedSandboxRuntime entries on RegisterWorkerRequest, and watches the parent directory via ateom.WatchFile so atomic rewrites trigger re-registration whenever the effective runtime set changes. To stay resilient on dense nodes where fs.inotify.max_user_instances is shared across containers running as host UID 0, WatchFile logs a warning and falls back to periodic polling when fsnotify initialization fails, and the reconciler runs a periodic resync tick alongside watch events. Deduplicate identical consecutive requests with proto.Equal, retry transient read or RPC failures with capped exponential backoff, and drop permanent InvalidArgument rejections until the file changes. Reuse ateom.WatchFile in ateom-gvisor's flagStore so both watchers share the same atomic-rename and polling-fallback behavior.
…TODOs Update VersionedSandboxCompat comments in ateapi.proto, atelet.proto, and docs/api-guide.md to document that cpu_features is matched as a hexadecimal bitmask subset rather than by exact string equality, and replace the stale gvisor_asset_hash example with sandbox_runtime_hash. Add TODOs on workflow_resume.go noting how ROOTFS snapshots will behave once gVisor supports ROOTFS restore.
bea97eb to
26fd662
Compare
Zoe Zhao (zoez7)
left a comment
There was a problem hiding this comment.
nit – The PR title and description still reference the old ateom names and paths (internal/ateom/register.go, ateom.WatchFile, AteomSupport.RegisterWorker, cmd/ateom-gvisor, cmd/ateom-microvm, /var/lib/ate/ateoms/<pod-uid>/sandbox-runtimes.json, and /var/lib/ate/ateom/sandbox-runtimes.json). Please update the PR title and description to match ateworker before merging so the squash commit message is accurate.
The cpu_features table mapped names to bits by their position in gVisor's allFeatures map literal, maintained by a generator over vendored gVisor sources with an append-only merge, a verify script, and a CI step. The whole mapping is an interim measure: it exists only because runsc prints feature names, and goes away once runsc can print the bitmask itself. Key the table by the value of gVisor's Feature constant instead, which on amd64 is 32*block + the feature's CPUID bit and so never moves between gVisor versions, and commit it as a plain file in internal/hardware. Its header records the gVisor version it was produced from, how each entry was derived, how to add features from a newer gVisor by hand, and the TODO to remove it. Widen the mask to 6 words for bits up to 261 and reject a decoded mask that sets a bit with no name. Remove tools/cpufeatures, hack/third_party/gvisor, and the update, verify, and CI steps that fed them. Masks recorded under the old numbering do not compare meaningfully against workers built from this change; no compatibility is guaranteed before v1.0.0, so there is no migration.
…ents and logs still said 'sandbox versions'
64e5860 to
4448a1d
Compare
| continue | ||
| } | ||
| if seen[h] { | ||
| slog.WarnContext(ctx, "Leaving a sandbox version out of the worker sandbox runtimes file", slog.Any("err", err)) |
There was a problem hiding this comment.
When does this get retried and re-updated?
There was a problem hiding this comment.
If it's a transient failure (like a download error), we now write the file with the versions that worked and keep retrying with backoff (errIncomplete) until the restorable version succeeds and updates the file.
If it's a permanent failure (errPermanent, like an unpinned pauseImage or an unknown CPU feature), retrying the same binary won't change anything, so it gets re-synced when the SandboxConfig or WorkerPool is updated, or when atelet restarts.
224fa77 to
d2294ff
Compare
8719986 to
4a99c97
Compare
…ailures without a cap Only resolver failures waiting on a WorkerPool or SandboxConfig informer event have a watch event to re-enqueue the pod once the state clears; mark those with errUnresolved and cap only their retries at syncMaxRetries so a transient asset fetch or runsc probe failure on the default version keeps retrying until it recovers. Also add FuzzCPUFeatures and update the restorable_runtimes TODO in workerAssignmentFrom.
Follow-up to agent-substrate#2660 and agent-substrate#2755. Sandbox runtime and version selection live on the `WorkerPool`: its `sandboxClasses` entry resolves a `SandboxConfig`, atelet reports the enabled versions on `WorkerStatus` as `defaultRuntime`/`restorableRuntimes`, and the chosen runtime is stamped onto `WorkerAssignment` and `Snapshot` and sent to atelet on Run and Restore. The `ActorTemplate` no longer has a say in which sandbox an actor runs in, so its `sandbox_config` field goes. **Breaking:** `ActorTemplate.sandbox_config` and the `SandboxConfig` message are removed from the Control API. ate-api-server rejects unknown fields, so manifests or clients still sending `sandboxConfig` fail with `InvalidArgument`. Stored templates are unaffected (proto bytes; the old field is carried as unknown). `kubectl ate get actortemplates` loses its `SANDBOX CLASS` column. **API** - Remove `sandbox_config` and the `SandboxConfig` message from `ActorTemplate` in `ateapi.proto`, and its validation from ate-api-server. - Remove `sandboxConfig` from `kubectl-ate` output, e2e fixtures, demo and benchmark `ActorTemplate` manifests (sweperf included), and the docs. **ateapi** - `sandbox_assets` on Run/Restore is unchanged on the wire; ateapi now resolves it from the assigned worker's pool instead of the template: `WorkerAssignment` → `WorkerPool` → the controller-confirmed `SandboxConfig` (`WorkerPool.ResolvedSandboxConfig()`, new) → default version. `ActorWorkflow` takes a `WorkerPoolLister` for this; the `sandboxconfigs` RBAC stays. Marked TODO: this lookup, both listers and the RBAC go once atelet resolves the version from `sandbox_runtime` and `sandbox_assets` is dropped (the follow-up noted in agent-substrate#2755). - Scheduler: `Constraints.SandboxClass` is no longer taken from the template. It is the class of the snapshot the actor restores from (local, else latest durable, golden tags included), empty on a cold boot — so a worker of another class is never a candidate, not even as the volumes-only fallback when `RequiredRuntime` is relaxed. A cold-booting actor gets the class of the pools its `workerSelector` matches. - Repointing `actor_template` on a SUSPENDED actor no longer requires the same SandboxConfig; the snapshot's class and runtime checks on resume cover compatibility. - Metrics: `ate.sandbox.class` on lifecycle and scheduler operations is derived from the actor's assignment or snapshot runtime rather than the template, and omitted when neither is known yet; registry updated. **Behavior change to flag:** a template with no `workerSelector` whose actor has no snapshot can be placed on a pool of any class. Previously `sandboxConfig.sandboxClass` alone pinned the class; now the selector has to. **Tests** - `TestResolveSandboxAssets` covers pool-based resolution (resolved, unresolved pool, missing pool, missing config, no default version). - Scheduler `Applies` cases for the snapshot-class pin; `assignWorkerAttempt` proves a microvm worker is never the fallback for a gvisor snapshot at MEMORY or VOLUMES fidelity. - Wire-request tests seed the pool lister; the "missing SandboxConfig" cases now go through the pool. - Functional tests write the pool's `status.sandboxClasses[].configRef` via `UpdateStatus` (status subresource) and wait for it in the informer. - `ReleaseWindowWorkflows` makes the worker ineligible via `workerSelector` instead of a sandbox-class mismatch. No atelet changes beyond two comments. - [x] Tests pass - [x] Appropriate changes to documentation are included in the PR
…lans it (agent-substrate#2741) > **Rebased onto main after agent-substrate#2731 merged.** Three commits: the change, then two rounds of review fixes kept separate so each thread's change is visible. Since agent-substrate#2660 the syncer fetches a gVisor version's runsc to probe its CPU features; that fetch now goes through the same asset cache and is bounded by the prewarmer's attempt timeout. Step 4 of agent-substrate#2675: the default runtime of each worker on the node is prewarmed by the syncer that plans the worker, through the runtime index, and the cluster-wide prewarmer goes away. **Before.** `sandbox_prewarm.go` watched every SandboxConfig in the cluster, queued by config name, and fetched each config's default version, gated only by whether the node had `/dev/kvm`. Most of what it fetched no worker on the node would ever run, and nothing related it to what the node's workers advertise. **After.** The worker sandbox syncer already plans each local worker pod, registers its runtimes in the index, and writes its versions file. It now also hands the worker's default runtime hash to a `runtimePrewarmer` right after registering the worker's hashes in the index, before the build probes the versions: the build fetches and probes every restorable version serially, longest on a fresh node, and the file it ends with is what lets the worker take its first actor, so the probes are the prewarm's head start and the file never waits on a download. The prewarmer's queue is keyed by hash, and the prewarmer keeps the hashes whose prewarm succeeded and skips them, so the workers of one pool, every re-plan and every retry of a sync cost one prewarm of a runtime, not one per pod; a failed prewarm is queued again. Its single worker takes the record from the index, stages it into a scratch directory under the base path through the same `ensureSandboxAssets` Run uses, removes the scratch directory, and pulls the pause image alongside. Eight retries with exponential backoff, a ten-minute bound per attempt (a field on the prewarmer, like the hook a test uses to wait for an attempt), and an unknown hash is dropped with an error log since the syncer registers before it enqueues. The syncer's runsc probe keeps its own five-minute backstop rather than borrowing the prewarmer's. Prewarm stays a latency optimization: every failure is logged and left to the on-demand fetch. **Removed.** `sandbox_prewarm.go`, its tests, and `microvmNodeCapable`, which only it used. Locality comes from the pods on the node: a micro-VM pod only lands where it can run. **Scope.** Only default runtimes are prewarmed. The first restore of a non-default version a worker advertises pays its download. **Tests.** `TestRuntimePrewarmProcess` (retry until the cap, then forget; unknown hash forgotten at once), `TestRuntimePrewarmFillsBothCaches` (asset in the cache, scratch dir empty, pause image in the image cache), `TestRuntimePrewarmAssetFailureStillPullsImage`, `TestRuntimePrewarmAttemptTimeout`, `TestSyncPodPrewarmsDefaultRuntime` (exactly the default hash of a multi-version config, handed over before the build fetches for a probe, and still handed over when the build then fails), `TestRuntimePrewarmOnce` (a prewarmed hash is skipped by later enqueues, a failed one is queued again), and `TestDefaultRuntimePrewarmThroughSyncer`, the end-to-end run through fake clientsets and informers that the old download test did. The old enqueue-filter, class-gate, and KVM tests go with the behavior they covered. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…nt-substrate#2880) Now that gVisor supports `SNAPSHOT_FIDELITY_ROOTFS` alongside micro-VM (agent-substrate#2673), a snapshot's rootfs upper layer depends only on the container image (`ActorTemplate`) and not on the worker's sandbox runtime version or host CPU features. - **`requiredSnapshotRuntime`**: Only impose a `RequiredRuntime` placement constraint when restoring `SNAPSHOT_FIDELITY_MEMORY` snapshots; `SNAPSHOT_FIDELITY_ROOTFS` and `SNAPSHOT_FIDELITY_VOLUMES` snapshots can schedule onto any eligible worker of the same sandbox class without runtime matching. - **`ensureAteletRestored`**: When a durable `MEMORY` snapshot falls back onto a runtime-incompatible worker, downgrade the restore fidelity to `SNAPSHOT_FIDELITY_ROOTFS` (preserving both rootfs writes and durable volumes) instead of `SNAPSHOT_FIDELITY_VOLUMES`. Snapshots whose `ActorTemplate` was replaced continue to downgrade to `SNAPSHOT_FIDELITY_VOLUMES`. - **Tests & Docs**: Update `workflow_resume_test.go`, `functionaltest/actor_test.go`, and `docs/api-guide.md` to reflect `ROOTFS` fallback semantics. Follow-up to agent-substrate#2673 and agent-substrate#2660. <img width="1364" height="1688" alt="image" src="https://github.com/user-attachments/assets/7a105b64-2c30-440f-8679-5afbeb591f7d" /> - [ ] Tests pass - [ ] Appropriate changes to documentation are included in the PR
Part of #587. Follows #2521.
#2521 added the initial
ateletsyncer that wrotesandbox_runtime_hashper enabledSandboxConfigversion to each local worker pod's directory.This PR completes the runtime and hardware compatibility pipeline from
ateletthrough the worker intoWorkerService.RegisterWorker:sandbox-versions.jsontosandbox-runtimes.json(internal/sandboxruntimes):ateletnow writes the complete set ofVersionedSandboxCompatattributes fordefaultRuntimeandrestorableRuntimes(architecture,sandbox_runtime_hash,cpu_featuresforgvisor, andcpu_vendor+cpu_modelformicrovm).cpu_featuresinatelet:ateletowns all hardware and runtime probing on the node — reading/proc/cpuinfofor microVM CPU vendor/model and running<runsc> cpu-features(memoized by asset SHA-256) for each enabled gVisor version, encoded as a canonical lowercase hexadecimal bitmask whose bit numbers are gVisor's ownFeatureconstants (internal/hardware).sandbox-runtimes.jsonin the worker:ateworker-gvisorandateworker-microvmwatch/var/lib/ate/workers/<pod-uid>/sandbox-runtimes.jsonviaregister.WatchFileand keepdefault_runtimeandrestorable_runtimesregistered throughWorkerSupport.RegisterWorker(internal/ateworker/register).End-to-End Flow
flowchart LR subgraph Node["Kubernetes Node"] direction TB SC["SandboxConfig CRD"] --> Atelet["atelet (workerSandboxSyncer)"] CPU["Host /proc/cpuinfo\n(microVM vendor & model)"] --> Atelet Runsc["Cached runsc binary\n(<runsc> cpu-features)"] --> Atelet Atelet -->|"Atomic rename"| File["/var/lib/ate/workers/<pod-uid>/\nsandbox-runtimes.json"] File -->|"register.WatchFile (fsnotify)"| Worker["ateworker-gvisor / ateworker-microvm"] Worker -->|"WorkerSupport.RegisterWorker"| Atelet end Atelet -->|"WorkerService.RegisterWorker"| AteAPI["ateapi (Control Plane)"]How it works
sequenceDiagram participant Atelet as atelet participant FS as /var/lib/ate/workers/<pod-uid>/ participant Watcher as register.WatchFile (fsnotify / poll fallback) participant Reconciler as reconciler.run / sync participant Support as WorkerSupport.RegisterWorker Note over Watcher,FS: Watches parent dir for Create/Write on sandbox-runtimes.json Reconciler->>FS: Initial sync(): Read sandbox-runtimes.json alt File not written yet FS-->>Reconciler: ErrNotExist (wait for watch event or 5m resync) end Atelet->>FS: Probe hardware/runsc & atomic rename -> sandbox-runtimes.json FS-->>Watcher: fsnotify Create/Write event Watcher->>Reconciler: signal on changes channel Reconciler->>FS: Read sandbox-runtimes.json Reconciler->>Reconciler: proto.Equal(req, registered)? alt Changed (or first registration) Reconciler->>Support: RegisterWorker(capacity, default_runtime, restorable_runtimes) alt RPC succeeds Support-->>Reconciler: OK -> record registered = req else InvalidArgument (malformed runtime attributes) Support-->>Reconciler: Log Error, do not retry until file changes else Transient RPC error (e.g. Worker record not in ateapi yet) Support-->>Reconciler: Retry with exponential backoff (500ms..30s) end endateletowns all probing:gvisorworkers,ateletfetches the version'sgvisorasset viaensureSandboxAssets, executes<runsc> cpu-features(memoized in-memory by the asset's SHA-256 digest), and encodes the reported names viahardware.EncodeCPUFeaturesinto a compact lowercase hex bitmask alongsidearchitectureandsandbox_runtime_hash.microvmworkers,ateletreads/proc/cpuinfoonce (memoized after the first successful read) to recordcpu_vendorandcpu_modelalongsidearchitectureandsandbox_runtime_hash.cpu_featuresbit layout:Featureconstant: on amd64,32*block + bitin that block's CPUID register (X86FeatureAVX2 = 2*32 + 5 = 69); on arm64, the value gVisor declares. Intel/AMD never reassign a CPUID bit, so a bit never moves when gVisor adds, reorders, or renames a feature, and a bit with no entry is one the hardware reserves.internal/hardware/cpufeatures_table.gois a committed table, produced once from gVisor'spkg/cpuidsources atgvisor.dev/[email protected]: for everyFeatureconstant with an entry inallFeatures, the constant's value keyed to the display namerunsc cpu-featuresprints. Each entry's comment names the constant and, on amd64, its block and bit. The file header records this and how to add features from a newer gVisor by hand; because a bit is the hardware's, existing entries never change.runsccan print its feature set as this bitmask directly,ateletrecords that value as printed and the table,EncodeCPUFeatures, andDecodeCPUFeaturesgo away (TODO in the table header). The mask format and subset matching stay. No gVisor sources or generator are vendored for it.hardware.MatchesCompatdecodescpu_featuresinto a stack-allocated[6]uint64mask (384 bits; amd64 bits reach 261 today, with room for gVisor blocks through 11) and checks bitwise subset inclusion (DecodeCPUFeaturesrejects a mask that sets a bit with no name.register.WatchFilewatches the parent directory forCreate/Writeevents on the target file (reused by bothregister.Reportandateworker-gvisor'sflagStore), and falls back to periodic polling (5s) with a warning iffsnotifyinitialization fails (e.g. whenfs.inotify.max_user_instancesis exhausted on dense nodes).reconcilerruns a5mperiodic resync tick alongside watch events, deduplicates consecutive registrations viaproto.Equal, retries transient read/RPC failures with capped exponential backoff (500ms..30s), and drops permanentInvalidArgumentrejections until the file changes.Compatibility
cpu_featuresvalues recorded before this PR used a different bit numbering and a narrower mask. They do not compare meaningfully against workers built from this PR: any dev-cluster snapshot carrying one should be retaken rather than restored. No compatibility is guaranteed before v1.0.0, so there is no migration.go test -race ./internal/hardware/... ./internal/sandboxruntimes/... ./internal/ateworker/register/... ./cmd/atelet/... ./cmd/ateworker-gvisor/... ./cmd/ateworker-microvm/...)