Skip to content

Sync upstream snapshot storage and sandbox lifecycle updates - #91

Merged
EItanya merged 39 commits into
mainfrom
sync/upstream-main-20261019
Oct 20, 2026
Merged

EItanya merged 39 commits into
mainfrom
sync/upstream-main-20261019

Conversation

@EItanya

@EItanya EItanya commented Oct 19, 2026 •

Copy link
Copy Markdown

Merge upstream main through 34bde5827 (38 upstream commits) in one merge commit, preserving both histories and the fork's agentgateway configuration.

This brings in cluster snapshot storage defaults, per-actor credential exchange, sandbox crash reporting, serialized lifecycle operations, image-cache fixes, and the updated worker protocol. The chart follows the new snapshot, identity, telemetry, and network settings. Conflict resolutions retain the fork's workflow deadline and local TLS options.

The previous E2E failures came from agentgateway decoding the old protobuf field numbers: the API returned a worker assignment, but the gateway discarded the actor status. The updated image includes matching control API bindings, including actor JWT requests and actor states.

Upgrade clients and components together; the protobuf changes are not wire compatible. Remove DeleteActorRequest.any_state / --any-state and the unused actorIdentityJWTProvider setting. External-storage Helm installations must set ateApi.snapshotLocation; bundled RustFS derives it from its bucket. Per-template overrides require PerTemplateSnapshotLocation preview. See the chart upgrade notes.

Validation: all 244 root Go packages with race detection, API-tool race tests, 36 Helm tests, chart lint, manifest rendering, CRD parity, and repository verification passed locally. The gateway fix passes 3 wire-format regression tests, 30 Substrate unit tests, and 92 integration tests. The regression tests fail against the old bindings. All five CI checks pass on 24a6ef49d: unit tests and verification, default and preview gVisor/microVM E2E, Helm E2E, and Helm verification. Local Kind E2E was not run.

E2E totals (tests/skips): default gVisor 160/8 and microVM 160/7; preview 161/2 in each runtime. Helm passed gVisor 156/1, microVM 32/1, nine credential-injection tests, and one trust-rotation test.

Merge with a merge commit to retain upstream ancestry.

msau42 and others added 30 commits October 18, 2026 20:51
atelet can upload a paused actor's snapshot and detach the actor before
ateapi records the upload, since it reports the upload best-effort.
ateapi then restored the actor without snapshot.object, atelet failed
with NotFound, and the revert that followed deleted the uploaded
snapshot as PENDING.

ateapi now names the upload location on every local Restore, whether or
not it has recorded the upload, and atelet requires it. atelet reads it
only when it holds no usable local copy; the manifest, uploaded last,
tells a complete upload from a partial one.

Part of agent-substrate#2069 and
agent-substrate#2571

#### Breaking change
atelet requires snapshot.object on Restore


- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…-substrate#2817)

CreateTag on a paused actor whose snapshot is still only on its node now
uploads that snapshot first, then tags it, instead of failing with
FAILED_PRECONDITION. The call can take as long as the upload.

- CreateTag asks the atelet on the snapshot's node to upload it with
PromoteSnapshot. atelet keeps its local copy, so the actor still resumes
on that node.
- CreateTag checks that atelet reports the requested snapshot as
durable, then records the upload on the actor.
- A failed upload fails the tag with no pending tag left behind, and
leaves the actor as it was.
- The actor lease is held for the whole tag process, so the actor cannot
be resumed or suspended during the upload.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…g its default runtime (agent-substrate#2756)

Step 5 of agent-substrate#2675: the sandbox asset cache gets an eviction pass with a
budget, and each worker's default runtime is kept in the cache by links
under the worker's own directory.

**Commit 1, filecache.** A pass never evicts an entry a consumer still
hard-links. A link is how a consumer declares an entry in use; retiring
such an entry frees nothing while the link lasts and makes the next
caller download a second copy beside it. Linked entries are skipped and
counted as `SkippedLinked`, and every retired entry frees its full size,
so the stats need no `PendingBytes`. The listing runs lock-free, so the
retire step re-checks the links under the hit lock as it re-checks the
last-use clock, and a link gained since the listing vetoes the entry
even when the hit's best-effort touch did not land. The contract is that
eviction never takes what a caller holds and its only cost is a
re-download.

**Commit 2, atelet.**

*Design.* The cache has one protection rule, the link rule above, and
both things that must survive a pass use it the same way. An actor
protects its runtime through the links in its own directory for as long
as it lives on the node. A worker protects its default runtime through
links in its own directory for as long as the pod exists. There is no
root set the pass consults, no reference count across pods, no prune,
and nothing to reconcile after a restart: the directory of a pod still
on the node keeps protecting, and the startup sweep that already removes
the directories of pods that are gone releases their links with them.
Several pods of one pool each hold their own links to the same entries;
the filesystem counts them, and the entry becomes evictable when the
last pod's directory goes.

*Directory layout.* Under each worker pod's directory, `<base
path>/workers/<pod UID>/`, which the syncer already owns for the pod's
sandbox runtimes file:

```
workers/<pod UID>/
  sandbox-runtimes.json
  default-runtime/
    <hash>/              one per default runtime the file advertises, by bundle hash
      gvisor/runsc …     hard links into the cache, made by the staging call Run uses
```

*Lifecycle.* The syncer owns the directories and the prewarmer only
fills them. Once a pod's file is written and registered, the syncer
makes `default-runtime/` match what the file names as defaults: the
directory of a default the file no longer names is removed, which
unlinks that runtime, and an empty directory is created for each default
that lacks one. Then it hands the prewarmer one item per default, the
hash and its directory. The hand-over comes after the build on purpose:
a build that fails, as a permanent runsc probe failure does, leaves the
file and so the directories as they were, with the previous default
still linked and still advertised. The prewarm starts late by only a
little: for a gVisor default the build's probe has already fetched the
release, so the prewarm is links and the pause image pull, and for a
micro-VM default the build takes milliseconds. The prewarm job checks
that its directory exists, stages into it with a variant of the staging
call that creates no directories, pulls the pause image beside it, and
on an error checks again: a directory gone, after a later sync or the
pod's removal, means the item was superseded, and it ends without a
retry and without recreating anything. A directory the syncer already
filled costs a stat per asset, so a re-sync with an unchanged default
downloads nothing and leaves the links alone. A failed download retries
with the existing backoff and gives up after eight attempts; the
directory stays, empty or partial, until the next sync, and a Run placed
on that default meanwhile fetches on demand and shares the download. One
queue and one worker drain the items in order, so defaults download one
at a time across the node and never compete for its bandwidth.

*Why the old default is not kept during the download.* By the time the
directories change, the file names the new default and the worker has
registered it, so the scheduler places nothing new on the old one.
Actors already running on it hold their own links, and restores of
snapshots taken on it go through the on-demand path like every
non-default version. Keeping its links would only hold cache space.

*GC loop.* `sandbox_asset_gc.go` adds `--sandbox-asset-cache-gc-period`
(5m, 0 disables), `--sandbox-asset-cache-max-bytes` (0 means unbounded:
no pass runs, warned at startup) and `--sandbox-asset-cache-gc-dry-run`.
A pass that finds the cache over budget asks the store to free down to
90% of it, oldest first. Same shape as the image GC: immediate first
pass, serialized passes, panic recovery, shortfall warnings that back
off to a periodic reminder.

*Metrics.* `ate.sandboxassetcache.size` (observable up/down counter,
bytes), `ate.sandboxassetcache.evictions` and
`ate.sandboxassetcache.gc.passes` by `ate.sandboxassetcache.gc.outcome`
(`complete`, `shortfall`, `skipped`); a pass within budget records
nothing, and a dry run records its pass but no evictions. Registry group
and entries added, `hack/verify/metrics.sh` passes, rows and a paragraph
in `docs/observability.md`.

**What a shortfall means.** Everything left is linked by an actor,
linked by a worker as its default, younger than the min age, or busy.
The size then measures what the node's actors and defaults need, not
waste; the budget is too small or the node hosts too many runtimes.

**Not in this PR.** A shared GC driver for the image and asset caches,
which would also give the image GC the metrics it lacks; filed as a
follow-up once this lands. Step 6, the summary doc, which agent-substrate#2792 carries
and which will be rewritten for this layout.

**Tests.** filecache: a linked file, a partly linked tree and a fully
linked tree all survive a pass; unlinked trees still go oldest first; an
entry linked between the listing and the retire, without a touch, is
vetoed at retire time. atelet: a prewarm links the runtime into its
directory and an eviction pass keeps the entry; a second prewarm of the
same directory fetches nothing; removing the pod directory makes the
entry evictable; a job whose directory is gone before it runs, or whose
pod goes while it stages, fetches nothing, recreates nothing, and is not
retried; the sync hands the default over only after the build, removes a
retired default's directory, keeps the current one and its contents
across a re-sync, hands nothing over and touches no directory when the
build fails, and the pod's removal takes the directories; the end-to-end
informer test asserts the link under the worker's directory; retry,
pause-image and attempt-timeout cases as before. GC: target table, flag
validation, quiet, skipped, complete and shortfall passes with the
metrics checked through a manual reader, the size callback, immediate
first pass, ticking, panic recovery.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…trate#2819)

Late Checkpoint and PromoteSnapshot requests can conflict with a newer
snapshot on atelet. Return `Aborted` for stale generations and
same-generation UUID conflicts, and have ateapi preserve the actor and
its worker assignment while reporting the conflict to the caller. Other
`FailedPrecondition` errors retain their existing crash policy.

Checkpoint conflicts are handled at the suspend call site. CreateTag
maps PromoteSnapshot conflicts in its own upload error handler.

Both ateapi and atelet must include this change to avoid crashing actors
on snapshot generation conflicts; mixed versions retain the previous
crash behavior.

Regression tests cover the atelet error-conversion boundary, in-flight
and background-operation conflicts, caller cancellation, and
synchronous/asynchronous SuspendActor recovery. The API test keeps the
last completed snapshot and its objects through repeated conflicts, then
retries the same pending snapshot after the conflict clears. The API
guide describes the retry behavior and its limits.

Fixes agent-substrate#2808.

Validated `13e53825`, including main `ddb46651`, on Linux/amd64 with Go
1.27.2 as a non-root user with `CI=true`.

- `go test -race -p=4 -count=1 ./...` passed: 167 packages, 10,695
passing test and subtest results, 43 skips, and no failures.
- Builds of `ateapi` and `atelet` passed.
- `hack/verify-all.sh` passed all 16 checks. Regeneration and
verification left the source tree clean.
- Focused PostgreSQL/envtest regressions passed. Removing the conflict
mappings made the synchronous and asynchronous Checkpoint cases and the
tag-upload conflict case fail as expected.

Root-gated tests and real runtime cluster E2E were not run.

AI assistance: Codex helped implement and test this change.
…r it (agent-substrate#2854)

In `demos/counter/README.md`, each command block folded under a
`<details><summary>` opened on the line right after the summary tag. A
summary tag opens an HTML block, and Markdown inside an HTML block
renders only after a blank line, so GitHub showed those twelve blocks as
literal backticks instead of code. This inserts the blank line after
each of them. No other Markdown file in the repo has a fence glued to an
HTML line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…oint, and takes its own exporter settings (agent-substrate#2189)

Follow-up to agent-substrate#1878 (agent-substrate#1877).

ateworker exports through atelet's relay, or directly to its own OTLP
endpoint when there is no relay. With neither, it used to export to
`localhost:4317`, where no collector runs. It now skips the trace,
metric and log exporters and logs a warning per signal with `reason="no
OTLP relay or endpoint"`.

ate-controller now always sets the worker pod identity
(`OTEL_RESOURCE_ATTRIBUTES`), metric export tuning and sampler, so
telemetry sent through the relay carries pod identity even without an
endpoint. The worker exporters come from new worker-only
`ATE_WORKER_OTEL_{LOGS,TRACES,METRICS}_EXPORTER` keys in
`ate-otel-config` (flags `--otel-{logs,traces,metrics}-exporter`). The
controller's own values don't fit the workers: `prometheus` means
scraped, and ateworker serves no `/metrics`.

#### Breaking change

Please add the `breaking-change` label.

- Worker pods no longer take `OTEL_LOGS_EXPORTER` from ate-controller.
If you set it to send worker usage records over OTLP, also set
`ATE_WORKER_OTEL_LOGS_EXPORTER` to the same value in `ate-otel-config`.
The kind overlay already does.
- On installs where ate-controller has no OTLP endpoint, WorkerPool
Deployments roll once on upgrade because the worker env changes.
Installs that set the endpoint in `ate-otel-config` (the default) don't
roll.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…ate#2727)

#### Breaking change

`DeleteActor` now deletes an actor in any state, and the `any_state`
field and the `kubectl ate delete actor --any-state` flag are gone.

- Callers that relied on `DeleteActor` returning `FAILED_PRECONDITION`
for a running actor no longer get that guard. A running actor is now
terminated, its volumes detached, and its worker released. To avoid
deleting an actor that changed since you read it, check its state
yourself before deleting, or pass `options.uid` and `options.version`.
- `DeleteActorRequest.options` moved from field 3 to field 2. Regenerate
clients from the new `ateapi.proto`. Old stubs that send `options` will
have it silently dropped, and old stubs that set `any_state` will fail
to parse.
- Remove `any_state` / `AnyState` from `DeleteActorRequest` in your
code.
- Drop `--any-state` from any scripts that call `kubectl ate delete
actor`. The flag is now rejected as unknown.


Fixes agent-substrate#1161
 
> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
The store's pgx pools carried no tracer, so no span was ever emitted for
database work: a traced GetActor arrived as a single gRPC server span
with the PostgreSQL time indistinguishable from handler overhead, and
even the multi-span suspend/resume traces showed step, atelet and
snapshot spans but nothing for the many store round-trips between them.
The store blind spot in docs/metrics/substrate.yaml describes the
metrics half of this; the trace half is what this change closes.

Attach a QueryTracer to the pool config (the watch pool inherits it via
Copy). Each statement becomes a client span named after its leading
keyword (db.SELECT, db.UPDATE, ...) with the parameterized statement
text as db.query.text — arguments never appear in it. Spans only join an
existing trace: statements from background work (outbox polling, lease
maintenance) carry no surrounding span, and opening a root span for each
would flood the backend with single-span traces. pgx.ErrNoRows leaves
the span unmarked, since the store maps it to NotFound as an expected
lookup outcome.

Fixes agent-substrate#1455

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
atelet's `DetachActor` returned UNIMPLEMENTED. Without it,
ate-api-server can't make a suspended actor's latest snapshot durable
and free it from the node on demand. Resuming the actor on another node
needs that.

The handler validates the request and runs the snapshot tracker's
`Detach`. That's the same detach atelet runs on its own after
`--snapshot-detach-delay`. The handler returns the actor's new snapshot
state.

#### API changes

- `Atelet.DetachActor` (atelet internal) is now implemented. The proto
comment drops its "returns UNIMPLEMENTED" TODO. No fields change.

#### Not in this PR

- Detach deletes the actor's local snapshots but not the rest of the
actor's directory. The detach atelet runs on its own does the same.
Removing the whole directory overlaps agent-substrate#2742 and agent-substrate#2772, and belongs in
the tracker so both paths get it.
- Nothing in ate-api-server calls `DetachActor` yet. Resuming elsewhere
will be a follow-up.
…te#2761)

**What**

A benchmark that runs atunnel in its own process and drives load through
it at rising rates, reporting atunnel's CPU, memory and latency per
rate.

Includes a first full run: `runs/20261016-123913/results.txt`, and
`report.md`, which explains the results and two findings (one filed as
agent-substrate#2759).

**How**

- atunnel is started and hosts actors with the worker's own code:
`tunnel.Start`, then per actor `PrepareEgress`, `actornet.ServeSandbox`
and `Activate`. Only atelet, the actors' programs, the router and the
egress gateway are faked.
- Everything is one program, `atunnel-bench`, with a subcommand per
process: `tunnel` (atunnel), `worker`, `sink`, `load`, `pki`.
- `run-docker.sh` runs three containers:
- **worker** (privileged): atunnel in its own cgroup with CPU and memory
limits, plus the fake atelet, an ingress actor in every actor's sandbox,
and an egress actor in the first;
  - **sink**: the fake egress gateway;
- **load**: the load generator, which sends ingress and reads atunnel's
stats.
- Seven scenarios cover ingress (HTTPS and CONNECT) and egress (reused,
new, and idle connections, and throughput).

**For reviewers**

- `.golangci.yaml`: adds a depguard exception so the benchmark can
import `internal/ateworker`.
- `internal/workerhost` has a root-gated test that runs traffic through
a real sandbox.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…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
…bstrate#2801)

MicroVM actors with a one character container name never started: We
used the name as the kata container ID, and kata rejects IDs shorter
than two characters. gVisor and Kubernetes allow these names.


https://github.com/kata-containers/kata-containers/blob/4.1.0/src/libs/kata-sys-util/src/validate.rs#L17-L22

The kata ID is now `c_<name>`. It only depends on the name, since a
restored guest keeps the IDs it booted with.
This won't hit any limits, and it can't conflict with container names.
It also fixes a collision risk for container named `durable` + durable
dir volumes (doesn't exist for gvisor).

I had an e2e test, but it seems unlikely we'll regress this and not
worth the compute on every PR.

AI-assisted. Tested on kind (micro-VM and gVisor) and I reviewed the
changes.

Breaking change: Like other pre-GA changes, you'll lose old snapshots.
This should be small enough to land pre-GA.

Fixes agent-substrate#2800

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Fixes agent-substrate#2407, agent-substrate#2436.

> It's a good idea to open an issue first for discussion.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR

## What changes

One rename in the volume API, split out of agent-substrate#2567 so the breaking part
lands before GA and the shared-volume feature can follow as a
non-breaking addition.

**`Volume.external_volume_template` becomes `Volume.external`**, typed
`ExternalVolumeSource`, with the existing `ExternalVolumeTemplate` under
its `template` member. The field name now matches its siblings
(`durable_dir`, `system_info`, `image`) and the message follows the
`*VolumeSource` convention (agent-substrate#2407, agent-substrate#2436). The `ExternalVolumes` preview
gate moves onto the new member, so gating behaviour is unchanged.
`ExternalVolumeSource` is a union with one member today; the
shared-volume reference is added as a second member in agent-substrate#2567.

   ```yaml
   volumes:
   - name: data
     external:
       template:
         capacity: 10Gi
         storageClassName: standard
   ```

The per-actor record in ActorStatus gets wrapped in a new
ExternalVolumes message. This will make changes for shared volume
support easier post GA.

Callers, tests, demos, benchmark manifests and docs are updated;
generated code is regenerated.

## Testing

Unit, store (Postgres), controlapi and functional suites pass;
`hack/verify/gofmt.sh`, `hack/verify/boilerplate.sh`, `apitool validate`
and its exemption test pass.

#### Breaking change

`ActorTemplate.volumes[].externalVolumeTemplate` is gone; templates must
use `volumes[].external.template`. Field number 3 is retired, not
reused, so templates stored under the old field decode with no volume
source and must be recreated (they fail validation on their next
update). Go consumers of `pkg/proto/ateapipb` see the new
`ExternalVolumeSource` type and the moved getter.

Requesting the `breaking-change` label.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ials on GCS (agent-substrate#2762)

Part of agent-substrate#777

The control plugin sidecar in the ate-api-server pod now serves a
credential exchange: the node plugin will present the worker-for-actor
JWT atelet gives it with a transfer and receive a GCS access token
scoped to that actor's snapshot prefixes, read-write on its own and
read-only on the tag it was created from. Nothing calls it yet, the node
plugin and atelet side come in the next PR, so transfers keep running on
the pod's own credentials until then.

Four commits:

- `actorlookup` fetches the actor a verified JWT names over the
loopback, `127.0.0.1:443`, with the pod identity certificate, and
refuses one whose UID changed.
- `exchange` and its private proto,
`internal/proto/objectstoreexchangepb`: verify the JWT with
`FileKeySource` and `WithSubstrateClaims`, accept only the
worker-for-actor kind so an actor cannot trade its own token for its
snapshots, derive the grants with `actorscope`, mint through
`gcsissuer`. Errors follow the table in the proto comment.
- Wiring in control mode: with `--exchange-listen` set, a second gRPC
server over TLS with the pod's service DNS certificate. On S3 the
exchange answers `UNIMPLEMENTED`. The README gains the package map and
which pieces are shared between backends.
- Manifests: the sidecar's port, the issuer read from the same ConfigMap
key ate-api-server uses, three read-only mounts including only the
`jwks` item of the pool Secret, the Service port, and a network policy
rule admitting only atelet pods.

On an install that enforces authorization the lookup needs agent-substrate#2757, the
`api_server_sidecar` role. Without it the exchange answers
`UNAVAILABLE`, which nothing observes until the node plugin calls it.

Verified on a GKE dev cluster from inside the ate-api-server pod with a
JWT signed by the cluster's pool: exchange in about 100ms, cache hit on
the second call, forced mint, the four refusals with their codes, and
the token reading and writing exactly within the grants, with 403 on a
sibling prefix, another actor's prefix, and a list. A repoint followed
by a suspend moved the grant to the new location and the cache minted on
its own since the old token no longer covered it. The end-of-hour window
of the source credential was observed as documented in agent-substrate#2722.

Key rotation keeps `jwks` in step with `pool` through the current
`kubectl-ate` or an ate-setup rerun after rotating with an older one.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…t in the metrics suite (agent-substrate#2792)

> agent-substrate#2756 has merged; this is one commit on `main`.

The page describes agent-substrate#2756's final design: the default runtime is linked
under the worker's own directory, in a directory per default the
worker's file names, made and removed by the syncer and gone with the
pod, so the on-disk table, syncer section, eviction rule, and runbook
follow that layout and a subsection gives its lifecycle; and with no
budget set atelet starts no eviction pass.

Step 6 of agent-substrate#2675, in its small form: the e2e under eviction pressure was
dropped as too much machinery for what it proves, and this is what
remained worth doing.

**Metrics suite.** `ate_sandboxassetcache_size` joins
`PlatformMetricPrefixes`, so `TestPlatformMetricsEmitted` proves on
every kind lane that atelet registers the instrument, its callback walks
a real cache directory without error, and the OTLP path to the collector
carries it. Only size qualifies: it is an observable instrument read on
every collection, so it has a series from the first export on any
install. The eviction and pass counters emit only once a pass had work
to do, which never happens with the default unbounded budget.

**Doc.** `docs/sandbox-asset-cache.md`, for the operator or engineer who
meets the cache for the first time: what lives under `/var/lib/ate` and
why it must share a mount; how an actor gets its assets, from the
runtime the request names through the index, the cache and the hard
links, and what Run, Restore and Checkpoint each do; how the worker
sandbox syncer decides which runtimes a node needs, prewarms and pins
the defaults, and prunes; the eviction rules and the six flags; the
three metrics and what a shortfall means; a runbook for a node over
budget; and what is deliberately left out. Linked from the README's doc
list, the node supervisor section of the architecture page, and the
metrics paragraph of the observability guide.

The page describes the request as naming the runtime the worker
registered, which is agent-substrate#2788's behavior rather than this stack's. It
documents the state once both land rather than the interim.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ubstrate#2719)

## Problem

A node's suspend/resume turnover plateaus with most of the wall time
spent waiting on GCS, even though the node's network has headroom.

`pkg/objectstorage` already keeps a pool of eight `storage.Clients`
because one client holds a single HTTP/2 connection and multiplexes
every request onto it. But the pool only served the parts of a composite
upload (objects above 64 MiB) and the ranges of a parallel download
(objects above 16 MiB). Everything smaller went through `putSingle` and
the first range of `GetObject`, both hard-wired to the wrapped client.

Compressed snapshots are usually below those thresholds (about 10 MiB
for gVisor, about 50 MiB for micro-VM). The object-store plugin is one
process per node with one shared client, so when a node suspends and
resumes many actors at once, every one of their snapshot transfers
queues on the same TCP connection.

## Change

- Add `nextClient`, which hands out pooled clients round-robin for a
transfer that is one request and so has no part or range index of its
own.
- `putSingle` and the first range of `GetObject` use it. Parts and
ranges keep their index-based pooled client.
- Build the pool in `NewGCSClient` alongside the wrapped client instead
of lazily on first use, so a burst of transfers on a fresh node does not
wait behind eight sequential client constructions. The `sync.Once` and
the stored options go away with it.
- Reword the fallback warning, which still spoke of part uploads, and
have it report how many pooled clients were built out of how many
wanted.
- Comments updated to say why small objects go through the pool.

No change to how objects are split, named or stored. Small objects still
go up and come down as one request, which the package's existing
measurements show is fastest for them. The change is which connection
that request uses.

## Testing

- New `TestSingleRequestTransfersSpreadOverThePool`: six alternating
small gets and puts land two on each of three pooled clients and none on
the wrapped client.
- Tests that build a `gcsClient` by hand no longer need to pin a lazy
pool. With no pool set they fall back to their test client, which is
what they always wanted.
- `go test ./pkg/objectstorage/ ./internal/objectstoreplugin/...
./cmd/objectstore-plugin/... ./cmd/atelet/` pass, with and without
`-race`. `vet`, `gofmt`, boilerplate and `golangci-lint` are clean.
…torageLocation override in preview mode (agent-substrate#2544)

> The PR title becomes the release note. Write it for users: what
changed for them, not how the code changed.

#### Breaking change

ate-api-server now stores snapshots under a new, operator-set
`--snapshot-location`
(for example `gs://BUCKET/` or `gs://BUCKET/PREFIX/`).
`ActorTemplate.snapshotConfig.storageLocation`
is now a preview field, honored only with
`--preview=PerTemplateSnapshotLocation`.
`snapshotConfig` itself is now optional; an omitted one takes every
default.

What you must do when upgrading:

- **ate-setup installs:** set `SNAPSHOT_LOCATION` (`--snapshot-location`
/ `storage.snapshotLocation`).
ate-setup passes it to ate-api-server and, unless the
`PerTemplateSnapshotLocation` preview gate
is enabled, fails before touching the cluster if it is unset.
`BUCKET_NAME` alone is not enough.
The kind profile defaults it to `gs://$BUCKET_NAME/`
(`gs://ate-snapshots/`). ate-setup does not
create the bucket or grant access to it on GKE (`go run
./tools/setup-gcp bootstrap` does).
- **Custom deployments of ate-api-server:** add `--snapshot-location`
(or `ATE_API_SNAPSHOT_LOCATION`
with `--snapshot-location=@env`). Without it, the server refuses to
start unless the
  `PerTemplateSnapshotLocation` preview gate is enabled.
- **Templates that set `storageLocation`:** without the gate, that value
is ignored and new
snapshots and tags go under `--snapshot-location`. If you rely on
per-template buckets
or paths (IAM, retention, billing), enable
`--preview=PerTemplateSnapshotLocation` to keep the
old behavior. With the gate, the field stays optional: a template that
leaves it empty uses
`--snapshot-location`, and `CreateActorTemplate` fails with
`FailedPrecondition` if neither is set.
- **Clients that set `storageLocation` with the gate enabled:** they
must opt in to preview
features (`kubectl ate --preview`, or the `Ate-Preview-Allowed: true`
header). Otherwise
  `CreateActorTemplate` fails with `InvalidArgument`.
- **Existing actors:** snapshots already taken keep working for resume.
If an actor's snapshot
location changes (by upgrading with the gate off, changing the flag, or
toggling the gate),
its next suspend writes under the new location and releases the previous
snapshot from the
old one. Until that suspend, changing the actor's `actor_template` is
rejected, and deleting the
actor collects its snapshots under the old location. If releasing the
previous snapshot
fails, its objects stay under the old location and a later delete does
not collect them;
ate-api-server logs a warning naming the snapshot URI. To keep the old
locations, enable the
  gate before upgrading.

ActorTemplate is not the right place to specify snapshot location.
Bucket is an implementation detail of snapshots, workloads shouldn’t
need to care about it. It is the responsibility of substrate admins to
provision the buckets and give access to Substrate. This PR adds a
cluster wide storage location as a building step towards the final goal
of adding these details in a separate SnapshotStorageConfig.


- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…te#2494)

The RPC log line carries the whole response; a List page of a thousand
actors is tens of kilobytes and rotates the lifecycle lines out of the
container log.

- `List*` responses are logged as `<ListActors response elided, N
bytes>`; the response to the client is untouched.
- A failed `List` call logs the handler's `null`, not an elided
placeholder.
- Tests cover both.

`go test -race ./internal/ateinterceptors/`, lint clean.
The AteomHerder listener required a client certificate chaining to the
pod-identity CA, but any pod in the cluster can obtain one, and the
caller's identity was never checked. Any pod reaching atelet could drive
actor lifecycles (Run/Checkpoint/Restore/Terminate) on the node.

- Authorize the verified client cert's SPIFFE ID against
`--herder-allowed-spiffe-id` (default `spiffe://cluster.local/ns/<atelet
ns>/sa/ate-api-server`) during the TLS handshake.
- Install client verification on the per-handshake config returned by
GetConfigForClient. crypto/tls replaces the whole config with that one,
so the same-node check previously set on the outer config of the
ateom-facing credential broker never ran; it now does.
- Drop hostPort 8085 from the atelet DaemonSet: ate-api-server dials the
atelet Pod IP, so the node-IP exposure only widened reachability.

Fixes agent-substrate#2073

#### Breaking change

Only ate-api-server can call AteomHerder now. Anything else that called
atelet on `:8085` (including via `<node-ip>:8085`) is rejected at the
TLS handshake. Installs that rename the ate-api-server ServiceAccount or
run it in another namespace must pass `--herder-allowed-spiffe-id` to
atelet.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR (none
needed)
…lag (agent-substrate#2884)

`ate-setup` passes a `--preview` argument to every substrate binary it
installs. Neither fake-atelet nor fake-workersync defined that flag, so
both exited at startup with `unknown flag: --preview`, and a
`--fake-data-plane` install never came up.

This defines `--preview` in both binaries and initializes the preview
gates from it, the same way the other control-plane binaries do.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR (none
needed)
…substrate#2485)

Bootstrap no longer turns off the Filestore add-on on a cluster it did
not create. Clusters it creates still start with it off.

Fixes agent-substrate#2357

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…rate#2178)

Fixes agent-substrate#2154

Large GCS uploads left their last compose intermediates behind. Now
removed on return, and the duplicated cleanup is shared.

Test runs against fake-gcs-server.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR

---------

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
…eaves that node (agent-substrate#2893)

Part of agent-substrate#777

ate-api-server checks that the caller's node hosts the actor when it
mints a worker-for-actor JWT, but the token then lives for an hour and
the credential exchange trusted any valid one. A node that kept a JWT
after the actor resumed elsewhere could still exchange it for a
read-write token on the actor's prefix, and the exchange also answered
for crashed, deleting or reverting actors.

The JWT now carries a `node` claim, the node it was minted for, set by
`MintWorkerActorJwt` from the identity it already verified against
`HostingNode`. The exchange refuses the token unless that node is still
the actor's `status.assignedNode` and the actor is in a state with a
transfer: RESUMING, RUNNING, SUSPENDING or SUSPENDED. `assignedNode` is
set by a resume, stays set while the actor is SUSPENDED with its local
snapshot on that node, and is cleared when the node detaches the actor,
by a revert and by a delete, or replaced by a resume onto another node,
so a stale JWT stops working at that status write rather than at expiry.
An actor's own token never carries a node.

The transfer states are listed once, in `resources.TransferStates`, read
by both the host check and the exchange, with a test that fails when a
new actor state is added without being classified.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…t-substrate#2723)

Update `.github/SECURITY.md`, `docs/threat-model.md`,
`docs/security.md`, and `AGENTS.md`:

- **`.github/SECURITY.md`**: Transition to disclosure via GitHub Private
Vulnerability Reporting; remove preview caveats, disclaimers, and
unverified CI claims; clarify platform boundaries and supported
versioning.
- **`docs/threat-model.md`**: Remove preview notices and author headers;
condense architecture component definitions to align with recent runtime
naming (`ateworker`); clarify that threat mitigation quality is
dynamically evaluated against code in CI rather than tracked statically
in the reference catalog.
- **`docs/security.md`**: Retain high-signal architectural principles
while trimming transient implementation minutiae; mark `Delete
Guarantee` and `Atespace Separation` as in-progress; update workload
identity with on-demand JWTs; add cross-references to Authentication and
Authorization guides.
- **`AGENTS.md`**: Replace early-stage disclaimers with core zero-trust
security invariants.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…ts (agent-substrate#2794)

Finalize applies the snapshot state atelet returns from Checkpoint. When
that state conflicts with the actor's record, finalize returned an
error, and the comment said a retry could call atelet again. A retry
can't recover. Atelet has already destroyed the workload, so the retried
Checkpoint fails and the actor crashes with a reason that hides the
conflict.

This PR crashes the actor at the conflict instead. The crash reason
names the conflict, matching finalize's other checks for a corrupt
record, and atelet's error is logged.

No proto or API changes.

Earlier versions of this PR also recorded the async upload URI and named
it on resume, released dropped durable snapshots, and tested the
snapshot's locality in the template-repoint guard. Main now has all of
these from other PRs, so this PR keeps only the crash.
…te#2695)

Follow-up to agent-substrate#2626

The control plane only found out a sandbox died when the next operation
on the actor failed, which for an idle actor could be never.

- New WorkerService.ReportActorCrashed (and the atelet AteomSupport
mirror), authorized like RequestActorSuspend. A RUNNING actor is crashed
under its lease, anything else is refused since the operation in flight
owns it.
- ateworker-microvm reaps cloud-hypervisor / virtiofsd as soon as they
exit, and ateworker-gvisor watches the runsc sandbox. If one dies while
ateworker isn't stopping it, ateworker tears the actor down itself (the
crash clears the assignment, so nothing else would), writes its final
usage record, and reports it
- Exits are watched through pidfds on the netpoller, so watching doesn't
cost a thread per process
- The gVisor sandbox is pinned right after runsc create, which closes
the pid reuse gap @sfunkenhauser pointed out on agent-substrate#2626
- Not covered yet: a workload exiting while its sandbox stays up, and a
micro-VM guest panic, which reboots the guest without cloud-hypervisor
exiting. Both need their own design ...

Tested with a new e2e test, TestSandboxCrashIsReported (kills the
sandbox from an ephemeral container, expects CRASHED, then revert +
resume), plus both full lanes on GKE with no false crash reports.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR (none
needed)

This PR was AI assisted. I have tested everything on GKE and reviewed
the changes.
…agent-substrate#2888)

agent-substrate#2433

## Breaking change

The binary wire format changes for some messages. Field names are
unchanged, so protojson and `kubectl ate` output in JSON or YAML look
the same.

- **Stored data:** the ateapi store keeps Actors, ActorTemplates,
Workers, ActorAssignments, Tags, Atespaces and EgressPolicies as binary
protos. Rows written before this change decode with fields in the wrong
places, and CRASHED, DELETING and REVERTING Actors read back with the
wrong state. Existing installs need a fresh database. There is no
migration.
- **Control API clients:** `kubectl ate`, and anything else built
against the old `ateapipb`, must be rebuilt. An old client talking to a
new server misreads fields without any error.
…#2891)

`deploy apiserver` now deploys ate-idp-server too. In the past only
`deploy ate-system` used to deploy it, so an upgrade has to put it in
step 4e, which a rollback does not undo, and a broken new
`ate-idp-server` had no way back.

The two share only the actor JWT pool and need no particular version of
each other, so they can move together. Rolling back step 4a, which runs
the old `deploy apiserver`, now rolls ate-idp-server back as well,
without a new upgrade step.



Part of agent-substrate#1686 

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Atelet now runs at most one lifecycle RPC per actor at a time. Run,
Restore, Checkpoint, PromoteSnapshot, DetachActor, and Terminate wait
their turn for the actor.

Before, Terminate could forget an actor and delete its local snapshots
and directories while a Checkpoint or Restore of the actor was still
using them, and a Checkpoint that committed afterward recreated snapshot
state for the terminated actor.

Terminate waits for any in-flight RPC for that actor to finish before
forgetting its snapshot state and removing its local snapshots and actor
directories. With RPCs serialized at the atelet boundary,
snapshottracker only coordinates background uploads, detaches, and
reports against the active RPC.

Part of agent-substrate#2069,
agent-substrate#2571, agent-substrate#2836

- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
…thz in the architecture (agent-substrate#2868)

Part of agent-substrate#2617.

- The authz security regression suite now checks that all five
WorkerService RPCs reject non-atelet callers. It adds
`MintWorkerActorJwt` and `ReportActorSnapshotState` (served since
agent-substrate#2570), each called with the bootstrap owner's token and with atelet's
own ServiceAccount token.
- Drops the stale "not served yet" notes for `ReportActorSnapshotState`
in the authz registry and `docs/authorization.md`, and describes the
check its handler makes.
- Updates `docs/architecture.md` now that control-plane authorization is
enforced (agent-substrate#2591): removes it from the unaddressed problems, describes
how `ate-api-server` authenticates and authorizes RPCs, and states the
remaining ingress gap (any allowlisted client can reach any actor; the
default `deprecated-insecure` mode accepts any client).

- [x] Unit tests, vet and build pass locally
- [ ] E2E (authz) runs in CI (needs a cluster)
- [x] Appropriate changes to documentation are included in the PR
thompsonmax and others added 8 commits October 19, 2026 21:37
…entity docs (agent-substrate#2597)

Part of agent-substrate#1756.

**Why:** every install still has to set `actorIdentityJWTProvider`, and
`docs/authentication.md` says it decides who may mint actor JWTs. It no
longer decides anything:

- agent-substrate#757 added it when minting lived in a separate `ActorIdentity`
service. `MintJWT` refused any caller whose JWT came from a different
provider's issuer, which in practice limited minting to atelet's
Kubernetes service account tokens.
- agent-substrate#1315 moved minting into `ateapi.Control` and dropped that check,
leaving caller limits to the authz framework.
- agent-substrate#1834 removed the last code that read the provider's issuer.

So ate-api-server requires a field and fails to start if it names a
missing provider, yet nothing uses it, and the docs say it decides who
may mint, which authorization now decides. Section 7 of
`docs/api-guide.md` still describes the old service and its atelet-only
rules.

**What changes:**

- `actorIdentityJWTProvider` is gone from ate-api-server's
authentication config, and ate-setup stops writing it.
- `docs/authentication.md` drops it and keeps the pointer to
`authorization.md` for what a caller may do.
- Section 7 of `docs/api-guide.md` now describes `MintActorJwt` and
`MintActorCertificate` on `ateapi.Control`, who may call them, their
fields, and the JWT's claims, instead of the old service, its caller
checks, and a wrong SPIFFE path. The ADK paragraph, which pointed at the
same service, now describes the egress gateway attaching actor JWTs.

For reviewers: the config is parsed strictly and ate-setup only creates
`ate-api-authentication` when it's missing, so an existing install must
delete the `actorIdentityJWTProvider` line from that ConfigMap before
ate-api-server restarts on this version.

Testing: unit tests for the config loader and ate-setup's rendered
config. I didn't run e2e; CI's e2e installs a fresh kind cluster, so it
doesn't exercise the upgrade case.

- [ ] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…#2256)

Part of agent-substrate#1660.

- The `egresscredinject` suite's echo-origin rules, http and https, now
also replace `X-Actor-Token` with an actor JWT. The test decodes the
token the in-cluster echo server sends back and checks `sub`, `aud`, the
lifetime (`exp - iat`) and the `actor.ate.dev` claim, including the
actor's UID. It also fails if the token is the `worker-for-actor` kind.
- A fetch without the header must reach the echo server without
`X-Actor-Token`, matching the existing check for `Authorization`.
- `assertEchoedAuthorization` is now `assertEchoedHeaders`, so one fetch
checks both headers.

The test doesn't check the JWT's signature. That needs the published key
set, which the OIDC discovery e2e (agent-substrate#2606) covers.

Testing: lint and vet pass. The previous head passed e2e in CI on both
sandbox classes; this rebase only changes an import path for the module
rename.

- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR (the
user guide is agent-substrate#2596)
…etric family (agent-substrate#2889)

Follow-up to agent-substrate#2756, so that agent-substrate#2796 can give the snapshot cache a budget
without a third copy of the eviction loop.

**What moves.** The sandbox asset cache's eviction loop, flags,
instruments, target formula and shortfall backoff move from
`cmd/atelet/sandbox_asset_gc.go` into a package,
`cmd/atelet/internal/filecachegc`, written against the filecache store.
The loop's behavior is unchanged: immediate first pass, serialized
passes, panic recovery, dry run, budget with a 90% low water, shortfall
warnings that back off to a periodic reminder.

**How a cache uses it.**
- `filecachegc.NewFlags("sandbox-asset-cache", "sandbox asset cache")`
registers `--sandbox-asset-cache-gc-period`,
`--sandbox-asset-cache-max-bytes` and
`--sandbox-asset-cache-gc-dry-run`, so the asset cache's flags are the
same as before. The snapshot cache will call it once with its own
prefix.
- `filecachegc.NewInstruments(meter)` creates the instruments once per
process.
- `filecachegc.Start(ctx, name, store, flags, instruments)` reports the
cache's size, warns once when there is no budget, and runs the loop when
there is a budget and a period.

**One metric family.** `ate.filecache.size`, `ate.filecache.evictions`
and `ate.filecache.gc.passes`, labeled by `ate.filecache.name` (today
`sandbox_asset`) and, for passes, `ate.filecache.gc.outcome`, replace
`ate.sandboxassetcache.size`, `.evictions` and `.gc.passes`. The caches
run on one store and one loop, so one set of instruments describes them,
and "which cache is over budget on this node" is one query grouped by
name. The registry, `ateattr` and the observability guide are updated.
The name is `filecache` rather than something broader because the family
is about the filecache store; the image cache, which measures layers
against volume watermarks, is not a member and keeps its loop.

**Tests.** The asset GC's tests move into the package unchanged in what
they prove, plus one that puts two stores on the same instruments and
asserts two size series under their names.

**Docs and e2e.** The sandbox asset cache page from agent-substrate#2792 and the e2e
metrics suite follow the rename in the last commit: the suite asserts
`ate_filecache_size`, and the page's metrics table and runbook use the
new names and say where the label comes from.

**Follow-up.** agent-substrate#2796 adopts the driver for the snapshot cache and adds
the `snapshot` label value.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…t-substrate#2848)

> The PR title becomes the release note. Write it for users: what
changed for them, not how the code changed.

Fixes agent-substrate#2847

`Uploader.plan` reserved `max(streaming, ranged)` for every file. A file
that the GCS client splits into range parts never takes the streaming
path, yet the streaming term grows with the budget and `GOMAXPROCS`. On
a 192-vCPU node with a 16Gi budget, a 292 MiB snapshot reserved 2.6 GiB
while its four parts hold 192 MiB, so only 5–6 uploads fit at once.
Synchronous suspends spent 82% of their checkpoint time waiting for the
budget; measurements are in agent-substrate#2847.

This change reserves for the path an upload takes:

- A file `PutSparseFile` will split (two or more parts) reserves `parts
× sparseUploadMemory`.
- A streamed object whose sparse stream is bounded below
`uploadCompositeMin` reserves that single request's buffers (the
`uploadCompositeMin` lookahead plus the client's `uploadChunkSize` chunk
buffer) plus its workers, instead of the composite part buffers.
- `Send` sizes a small manifest's reservation the same way.

S3 is unchanged: it has no range path and always buffers parts. The
minimum budget (`384Mi` GCS, `224Mi` S3) is unchanged too, because a
streamed object larger than one request still needs the composite
buffers.

| GCS, 192 vCPUs, 16Gi budget | Reserved before | Reserved after |
| --- | --- | --- |
| 292 MiB memory image (4 parts) | 2,688 MiB | 192 MiB |
| 611 MiB memory image (9 parts) | 5,248 MiB | 432 MiB |
| Metadata file (bytes to KiB) | 384 MiB | 192 MiB |
| Manifest | 320 MiB | 128 MiB |

On the cluster described in agent-substrate#2847 (one run each, 64 SWE-bench actors
next to synchronous probe suspends), probe suspend p95 went from 18.2 s
to 3.7 s at 16Gi, and from 112–136 s to 42 s at the 512Mi default. Those
runs used an earlier revision that didn't count the client's 64 MiB
chunk buffer for single-request objects. This version reserves 64 MiB
more per metadata file and manifest.

Validation:

- `go test -race ./pkg/objectstorage ./internal/objectstoreplugin/...
./cmd/objectstore-plugin -count=2` passes.
- `golangci-lint run ./pkg/objectstorage/...` reports 0 issues;
`hack/verify/gofmt.sh` and `hack/verify/boilerplate.sh` pass.
- `BenchmarkConcurrentSnapshotUploads` is within run-to-run noise of
`main` (3 runs each: 362–369 vs 371–373 files/s at the shared 512 MiB
budget). Its in-memory destination doesn't take the GCS paths this
changes.
- New `TestUploaderReservesOnlyTheChosenPath`;
`TestUploaderPlansFromPopulatedBytes` and
`TestUploaderReservesBothPipelines` are updated for the GCS
reservations.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…ll (agent-substrate#1509)

Fixes agent-substrate#1076.

## The bug

`ensureLayer` and `retireLayer` shared one singleflight key per layer,
and that shared flight was the retire/reuse interlock. A caller that
joins a flight rather than leading it runs no code and learns nothing
about what the flight did. When the flight was a retirement,
`ensureLayer` returned the layer path anyway, just after the rename had
moved it aside, and the pull failed its final check with `layer dir
vanished during pull`. The window is microseconds wide, so it shows up
as a rare CI flake.

## The fix

Count a pull's in-flight use of each layer instead of using the flight
as a lock.

- A pull pins each of its layers before any layer work and unpins when
it returns.
- `retireLayer` never retires a pinned layer. The pin check, a re-stat
and the rename run under one mutex, so either the pin lands first and
retirement is vetoed, or the rename lands first and the pull unpacks the
layer again. A missing or fresh layer is ruled out before the mutex is
taken.
- Retirement no longer uses the layer singleflight, so a joined flight
is always another pull's, and its result can be trusted.

## Tradeoff

A wedged pull holds its layers until it returns, bounded by the pull
timeout. Under disk pressure that holds space longer than before. In
exchange, a slow pull can no longer lose layers it already finished.
…ers (agent-substrate#2885)

ate-api-server grants the controller role (list, register and delete
Workers) only to `--controller-spiffe-id`, which by default is
ate-controller's identity. On a `--fake-data-plane` install,
fake-workersync manages Workers in place of ate-controller, using its
own identity. With authorization enforced, it was denied listing Workers
and exited, so the install never became ready.

The fake-data-plane kustomize component now sets fake-workersync as the
controller identity.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR (none
needed)
…rate#2735)

## What is this PR ?

This PR adds:
- **`ate_env` boomer user class (`AteEnvUser`)** in
`internal/benchmarking/boomer/ateenv`: mirrors `glutton`'s per-VU actor
lifecycle (`CreateAtespace`, `CreateActor`, `ResumeActorFirstResume` /
`ResumeActor`, live window with `AteEnvExec`, `SuspendActor` /
`PauseActor`, and `DeleteActor`), replacing HTTP `/ping` with
`ProcessService.StartProcess(["echo", "hello world"])` +
`ProcessService.StreamProcessOutput(follow: true)` over plaintext HTTP/2
gRPC directly through `atenet-router` (`ate-target-actor:
<atespace>/<actor>`).
- **`ate-env` workload manifest template**
(`benchmarking/workloads/manifests/ate-env-template.yaml.tmpl`) and
opt-in `WORKLOAD_TEMPLATES="ate-env"` support in
`benchmarking/workloads/deploy.sh` (with `ATE_ENV_IMAGE` check matching
`glutton-storage`).
- **Locust harness stub** (`benchmarking/locust/tests/ate_env.py`) and
documentation in `benchmarking/README.md`.

---

## Live Cluster Smoke Validation

Validated on a GKE cluster (`4` `c3-standard-4` worker nodes, `4`
`benchmark-worker` replicas, `256Mi` actor memory limit) across
`suspend` and `pause` modes for both `gvisor` and `microvm`.

### Snapshot Size Comparison (`gvisor` vs `microvm`, `256Mi` Actor
Memory Limit)

| Metric | `gvisor` (`ate-env`) | `microvm` (`ate-env`) |
|---|---|---|
| **Golden Snapshot — Uncompressed Total
(`atelet_snapshot_size_bytes`)** | **2.46 MiB** (`pages.img`: 2.13 MiB,
`checkpoint.img`: 328 KiB, `pages_meta.img`: 4 KiB) | **62.43 MiB**
(`memory-ranges`: 62.35 MiB, `state.json`: 64 KiB,
`rootfs-upper-guest.tar`: 8 KiB, `base-id`: 4 KiB, `config.json`: 4 KiB)
|
| **Golden Snapshot — Compressed in GCS** | **430.3 KiB** (4 objects,
~5.8× compression) | **16.37 MiB** (6 objects, ~3.8× compression) |
| **Per-Checkpoint after `AteEnvExec` (`suspend`) — Uncompressed Mean**
| **4.30 MiB** (`pages.img` mean: 3.94 MiB, p50: 3.17 MiB, p90: 6.12
MiB, p99: 9.20 MiB; `checkpoint.img` mean: 365 KiB) | **66.65 MiB**
(`memory-ranges` mean: 66.54 MiB, p50: 71.53 MiB, p90: 90.60 MiB;
`state.json` mean: 69 KiB; `rootfs-upper-guest.tar` mean: 36 KiB) |
| **Per-Checkpoint after `AteEnvExec` (`pause`) — Uncompressed Mean** |
**4.61 MiB** (`pages.img` mean: 4.24 MiB, p50: 3.60 MiB, p90: 7.82 MiB,
p99: 9.37 MiB; `checkpoint.img` mean: 365 KiB) | **67.32 MiB**
(`memory-ranges` mean: 67.20 MiB, p50: 71.53 MiB, p90: 90.60 MiB;
`state.json` mean: 70 KiB; `rootfs-upper-guest.tar` mean: 46 KiB) |
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…t-substrate#2879)

Completes the removal of `sandbox_assets` from the `ate-api-server` ->
`atelet` wire protocol following agent-substrate#2780 and agent-substrate#2781. Since `atelet` now
resolves the pause image and sandbox binaries for `Run` and `Restore`
directly from `sandbox_runtime` via its advertised-runtime index,
`ate-api-server` no longer needs to resolve `SandboxConfig` or send
`SandboxAssets` on `RunRequest` and `RestoreRequest`.

- **`ateletpb` & validation**: Remove `sandbox_assets` from `RunRequest`
and `RestoreRequest`, delete `SandboxAssets`, `ArchAssets`, and
`AssetFile`, and make `sandbox_runtime` required on both RPCs.
- **`cmd/ateapi` & RBAC**: Delete `sandbox_assets.go`, remove
`sandboxConfigLister` and `workerPoolLister` from `ActorWorkflow` and
`NewRPCService`, drop `sandboxconfigs` from `ate-api-server` RBAC, and
require a non-nil `DefaultRuntime` in `scheduler.Applies`.
- **`cmd/atelet`**: Read `op.sandboxClass` from `sandbox_runtime` in
`Restore`, delete dead `recordFromRequest`, and replace `poolConfigName`
with `WorkerPool.ResolvedSandboxConfig()`.

- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
@EItanya
EItanya marked this pull request as ready for review October 19, 2026 23:55
@EItanya
EItanya marked this pull request as draft October 20, 2026 02:20
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
@EItanya
EItanya force-pushed the sync/upstream-main-20261019 branch from 7ab9c87 to 24a6ef4 Compare October 20, 2026 02:47
@EItanya
EItanya marked this pull request as ready for review October 20, 2026 10:45
@EItanya
EItanya merged commit 9606240 into main Oct 20, 2026
5 checks passed
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.