Skip to content

Per-container CPU and memory limits for micro-VM actors - #2

Open
eliranw wants to merge 111 commits into
mainfrom
eliranw/microvm-container-resources
Open

Per-container CPU and memory limits for micro-VM actors#2
eliranw wants to merge 111 commits into
mainfrom
eliranw/microvm-container-resources

Conversation

@eliranw

@eliranw eliranw commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Per-container CPU and memory limits for micro-VM actors. First slice of agent-substrate#752.

An ActorTemplate container can cap its own cpu and memory so it cannot starve or kill its siblings in the same actor. A container that exceeds its memory limit is OOM-killed on its own; the rest of the actor is unaffected.

sandboxClass: microvm
containers:
  - name: trainer
    resources:
      limits: {memory: 1500Mi}
  - name: sidecar
    resources:
      limits: {memory: 256Mi, cpu: "0.2"}

Why micro-VM only

The two sandbox classes support opposite halves of agent-substrate#752. Micro-VM actors have a real guest kernel, so each container gets its own cgroup and the limits bind. gVisor applies cgroup limits at the sandbox level: one sentry backs every container in the actor, so a per-container cgroup is created and then stays empty (google/gvisor#190). Measured on a running actor, the workload container's cgroup reported memory.current=0 while all 20 sandbox processes sat in the pause leaf. A template that sets resources with sandboxClass: gvisor is rejected at admission.

How a limit travels

ActorTemplate → CEL validation → ate-api-server resolves each resource.Quantity once → ateletpb → atelet writes OCI linux.resourcesateom-microvm merges kata's defaults and checks the guest envelope → SpecToAgentPB → kata agent → guest cgroup.

The limit is carried as a standard OCI field rather than a substrate-private concept, so a runtime that gains per-container enforcement picks it up without new plumbing.

Relationship to agent-substrate#679

agent-substrate#679 adds actor-level sizing (ActorTemplate.spec.resources), which sizes the sandbox itself: guest RAM and vCPUs for a micro-VM, or the sentry for gVisor. This PR adds per-container limits (spec.containers[].resources), which subdivide inside that sandbox. Different fields, complementary layers, no schema conflict.

The two also explain each other's gVisor stance. Actor-level limits work under gVisor because the sandbox can be capped as a whole. Per-container limits cannot, because one sentry backs every container, which is why this PR rejects them there.

Either can merge first. Three follow-ups for whichever lands second:

Verified on hardware

Same template, run twice, one commit apart on a micro-VM actor:

before after
hog_ovl/memory.max max 67108864 (the declared 64Mi)
bystander_ovl/memory.max max max
hog allocates 128MB survived OOM-killed
bystander alive alive

The bug in between: SpecToAgentPB converted only Devices and CPU.Shares out of Linux.Resources, so a memory limit reached the bundle's config.json and was dropped on the way to the agent. Every unit test passed and the on-disk spec was correct while the feature did nothing.

Notes for review

  • ContainerResources deliberately does not reuse corev1.ResourceRequirements, which also carries requests and claims. There is no scheduler inside an actor to hint at, and for memory a soft request cannot express "must have this much to come back at all". Review on Right-size actor sandboxes to declared ActorTemplate resource limits agent-substrate/substrate#679 raised the same concern from the other direction: it takes a full ResourceRequirements and requests persists into the immutable spec with no effect.
  • Limits is a bounded named type. A MaxProperties marker on the field lands at the wrong schema level for a named map, and without a bound the CEL cost estimator rejects the whole schema.
  • A cpu limit below 10m is raised to 10m: the kernel rejects a CFS quota under 1ms.
  • mergeKataResources fills the gaps kata's defaults cover rather than allowlisting known fields, so a field added upstream reaches the guest instead of being dropped.

Known gaps

  • An OOM-killed container is not reported above the guest. The actor stays STATUS_RUNNING and nothing records it. Drafted as a comment on Feature Request: Resource-usage telemetry at actor / ActorTemplate granularity agent-substrate/substrate#550, where per-container memory.events is the natural home.
  • A worker pod still running an older ateom-microvm takes the old if Resources == nil branch once atelet starts emitting resources, and loses kata's device allowlist. Needs a capability check or an upgrade-order constraint.
  • Restored actors inherit the golden's cgroups rather than applying their own spec, and the envelope is validated against whichever pool the actor landed on. Correct today only because ActorTemplateSpec is immutable.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

@eliranw
eliranw force-pushed the eliranw/microvm-container-resources branch from d68e176 to 2f2f427 Compare August 11, 2026 16:36
@eliranw eliranw closed this Aug 11, 2026
@eliranw eliranw reopened this Aug 11, 2026
zoez7 and others added 27 commits August 11, 2026 10:06
Commit da8414b made unmarshalSandboxRecord reject records without a
pauseImage, fixing the test failure found in
```
--- FAIL: TestUploadLocalCheckpointDir (0.01s)
    --- FAIL: TestUploadLocalCheckpointDir/matching_scope_uploads_all_files (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/microvm_full_capture_uploads_durable_tar_alone_as_data (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/gvisor_full_capture_cannot_become_data_yet (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/microvm_full_capture_without_durable_tar_has_no_data (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/unknown_sandbox_class_cannot_convert (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/data_capture_cannot_become_full (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/manifest_without_scope_is_rejected (0.00s)
    --- PASS: TestUploadLocalCheckpointDir/gone_locally_but_already_uploaded_succeeds (0.00s)
    --- PASS: TestUploadLocalCheckpointDir/gone_locally_and_remotely_crashes_the_actor (0.00s)
    --- FAIL: TestUploadLocalCheckpointDir/upload_failure_is_a_plain_retryable_error (0.00s)
```
spec.pauseImage has moved and no longer exists in the ActorTemplate
…gent-substrate#804)

stage-to-rustfs.sh required developers to install the `aws` CLI, and
reached rustfs through a `kubectl port-forward`. The port-forward is a
footgun: when it targets the wrong cluster, or runs before the control
plane exists, the uploads fail against a dead localhost:9000 rather than
saying rustfs isn't there.

Run the S3 client in a throwaway container instead, using the same
pinned amazon/aws-cli image as the rustfs-bucket-init Job that creates
the bucket, and stream each asset in on stdin. The container joins the
kind node's network namespace so rustfs's ClusterIP is routable -- a
container merely attached to the `kind` docker network reaches the
node's own IP but has no route to the service or pod CIDRs. Dropping the
port-forward also fixes the macOS case, where a container cannot reach
it at all (the container's localhost is the Docker VM, not the host).

Also wait for the rustfs rollout and the bucket-init Job before
uploading, so running this too early reports what it's waiting on
instead of timing out on a connection.

Fixes #<issue_number_goes_here>

> 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

Surfaced in agent-substrate#743
…ers gauges (agent-substrate#826)

Part of agent-substrate#564 (Part 4, Fixes agent-substrate#564)

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

## Description
This PR implements Part 4 of agent-substrate#564 by adding OpenTelemetry gauge
instrumentation for `ate.workerpool.desired_workers` and
`ate.workerpool.ready_workers` in `atecontroller`.

## Key Changes:
* Added `ReadyReplicas int32` to `WorkerPoolStatus` with
`+kubebuilder:printcolumn:name="Ready"` annotation in
`pkg/api/v1alpha1/workerpool_types.go` and regenerated the CRD manifest.
* Updated `WorkerPoolReconciler.syncStatus` to synchronize
`dep.Status.ReadyReplicas` into `wp.Status.ReadyReplicas`.
* Implemented `InitMetrics(meter)` registering
`ate.workerpool.desired_workers` and `ate.workerpool.ready_workers` as
OpenTelemetry Observable UpDownCounters (`{worker}`) with an
asynchronous observer callback that samples controller-runtime's local
informer cache (`r.Client.List`).
* Labeled metric datapoints using centralized attributes
`ateattr.WorkerPoolNamespaceKey` and `ateattr.WorkerPoolNameKey`.

## Testing
* `go test -buildvcs=false ./cmd/ateapi/internal/controlapi/...`
* `go test -buildvcs=false ./cmd/atenet/internal/router/...`
* `make test`
## E2E  Test
* `./hack/create-kind-cluster.sh`
* `./hack/install-ate-kind.sh --deploy-ate-system --deploy-demo-counter`
* `./hack/run-e2e.sh ./internal/e2e/suites/metrics/...`

---------

Co-authored-by: Jeff Luo <jeffluoo@google.com>
Adds a `CLASS` column (`gvisor` | `microvm`) and a `--sandbox-class`
filter to `kubectl ate get workers` and `kubectl ate top workers`.

## Why

Nothing user-facing distinguishes worker sandbox classes today, and `top
workers` is where that silence actively misleads: its CPU/MEMORY columns
come from metrics-server, i.e. the **host-side worker pod**. A gVisor
worker's pod usage roughly tracks its workload, but a micro-VM worker's
memory reflects the guest's RAM (demand-paged toward a fixed allocation)
rather than the workload — on a mixed-class cluster the table invites
comparing numbers that are not comparable. This is the same
source-comparability caveat `ateompb.StatsSource` documents for
workload-side samples.

`ate.sandbox.class` is already one of the sanctioned low-cardinality
metric-label keys in `internal/ateattr`, so this surfaces an established
dimension rather than inventing one.

## What

- `printer.WorkerTopItem` gains `Class` (`json:"class,omitempty"`); both
table printers gain a `CLASS` column after `POOL`.
- `top workers` populates it from `Worker.GetSandboxClass()` — the field
is already on the `ListWorkers` response (`ateapi.proto` field 9), so
there is **no server, proto, or RPC change**.
- `--sandbox-class` filters both commands, alongside the existing `-n` /
`-a` / `-l` filters (shared `filterWorkers`).
- Column renders empty against a server that predates `sandbox_class`;
pinned by the free-worker and metrics-unavailable test cases.

## Sample output

Against a dev cluster running one gVisor pool and one micro-VM pool
(nested-virt GKE node pool), with one actor resumed onto a micro-VM
worker:

```console
$ kubectl ate top workers
NAME                               POOL              CLASS     STATUS     ASSIGNED ACTOR                                               CPU(CORES)   MEMORY(bytes)
counter-5f74698959-6qnqk           counter           gvisor    FREE       <none>                                                       1m           6Mi
counter-5f74698959-fcw6j           counter           gvisor    FREE       <none>                                                       1m           7Mi
counter-5f74698959-hd6px           counter           gvisor    FREE       <none>                                                       1m           6Mi
counter-5f74698959-ptlrt           counter           gvisor    FREE       <none>                                                       1m           5Mi
counter-5f74698959-pzrqs           counter           gvisor    FREE       <none>                                                       1m           6Mi
counter-microvm-7c5f74d879-6mdxs   counter-microvm   microvm   FREE       <none>                                                       1m           6Mi
counter-microvm-7c5f74d879-z9qds   counter-microvm   microvm   ASSIGNED   ate-demo-counter-microvm/counter-microvm/demo/my-counter-1   5m           45Mi

$ kubectl ate top workers --sandbox-class=microvm
NAME                               POOL              CLASS     STATUS     ASSIGNED ACTOR                                               CPU(CORES)   MEMORY(bytes)
counter-microvm-7c5f74d879-6mdxs   counter-microvm   microvm   FREE       <none>                                                       1m           6Mi
counter-microvm-7c5f74d879-z9qds   counter-microvm   microvm   ASSIGNED   ate-demo-counter-microvm/counter-microvm/demo/my-counter-1   4m           45Mi
```

(The FREE micro-VM workers sit at ~6Mi because the VM only exists once a
workload boots; the assigned worker's memory grows with the guest's
touched pages.)

## Testing

- `go test ./cmd/kubectl-ate/...` — table expectations updated for both
commands and the printer, including empty-class rendering and
`--sandbox-class` filter cases for each command.
- Output above is from a live GKE cluster.
Part of agent-substrate#23

Trap SIGTERM in the ateom, and forward SIGTERM to all application
containers.
Once all application containers exit, ateom will exit itself.
Instead of requiring custom code for each field, let the callers (i.e
RPC handlers) declare which fields are mutable and implement a generic
`Apply` function that copies fields over, using proto reflection.
Phase 2 of agent-substrate#463, continuing from agent-substrate#735 (the eviction engine): the
watermark-driven loop in atelet that turns eviction on, plus the flags
that govern it. After this: observability (metrics/spans), the ops-tool
swap, and the e2e suite.

## What

- A serialized, panic-recovered pass every `--image-cache-gc-period`
(default 5m; `0` disables the loop entirely). Each tick: `statfs` the
cache volume + sum the pool's recorded sizes, compute a byte target —
down to `--image-cache-low-percent` when usage crossed
`--image-cache-high-percent`, and/or down to `--image-cache-max-bytes` —
and hand it to `Store.EvictUnused`.
- **The target is capped at the pool's own size.** The cache is one
tenant of a shared volume; uncapped kubelet-style watermark math on a
pressured volume asks a tiny cache to fix pressure it didn't cause,
evicting everything every tick.
- **Gated passes are detected by contract, not inference**: the engine
exports `ErrIncompleteEnumeration` (wrapped into every gate return), and
`classifyGCPass` — a pure, table-tested function — maps each pass to
skipped/shortfall/complete/quiet. A gated pass logs ERROR "pass skipped"
and never feeds the shortfall backoff.
- **Genuine shortfall warns with backoff** then decays to a periodic
reminder: on a volume under foreign pressure, shortfall is the steady
state and must not ERROR every tick.
- atelet passes `WithActorsDir(ateompath.ActorsDir)` so the root set
sees placed actors (the wiring agent-substrate#735's notes required); `ateompath` gains
the `ActorsDir` constant.
- The first pass runs immediately at boot (a node starting under disk
pressure shouldn't wait a full period), and a `CacheSize` failure skips
the pass explicitly rather than silently zeroing the target.
- `--image-cache-gc-dry-run` computes and logs every decision while
deleting nothing — the production soak mechanism. A cache dir outside
`BasePath` logs a warning (its watermarks would measure a different
volume than actor state).
- README: the GC section's "loop lands next" intro replaced with the
loop/flag documentation.

## For reviewers: the enabled-by-default question

Defaults ship **enabled** (5m / 85% / 80%, mirroring kubelet so operator
intuition transfers), with dry-run as the opt-out soak path. The
alternative is dry-run-by-default for a release. The target cap removes
the known pathological case; happy to flip the default if you'd rather
soak first.

## Testing

- `imagegc_test.go`: watermark/cap target-math table (including the
capped-target cases), flag validation.
- Loop + engine together, Kind: full gVisor e2e suite green under
aggressive flags (15s period, 1-byte cap); planted orphan ignored by
periodic passes and reclaimed at startup; shortfall backoff observed
against an impossible cap; no enumeration-gate false positives.
- GKE (6 nodes): idle pool evicted under aggressive watermarks,
subsequent resumes cold-pulled cleanly with snapshot memory continuity;
flags restored to defaults after.
This reverts the ActorTemplate valueFrom.secretKeyRef support added in
agent-substrate#20 (issue agent-substrate#15).

We don't want actors to have any access to secrets, they will be
injected on the egress route instead. Removing this now so we don't need
to copy secrets into substrate resources.
…-fast context checks (agent-substrate#863)

This PR addresses the cold-start timeout issue reported in agent-substrate#811 by
driving down the decompression/extraction cost of gVisor release
archives on nodes, adding structured logging, and supporting context
cancellation during extraction.

* Ran unit tests locally: `go test -v ./cmd/atelet/... -run
TestExtractTarArchive` (PASS).
* Benchmarked extraction formats (Bzip2: 19.05s baseline vs Gzip: 1.50s
vs Uncompressed Tar: 0.25s).
## Summary

The comment on Hasher states the implementation is based on FNV-1a, but
fnvhash used fnv.New64() which is FNV-1. Switch to fnv.New64a() to match
the documented algorithm and get better distribution.

## Why this is safe 

The hash function is used solely for live work assignment across
controller replicas via rendezvous hashing. No hash values are persisted
anywhere.
…bstrate#847)

## What

Make the micro-VM runtime choose how it restores guest RAM based on the
cloud-hypervisor it is actually driving, so we can support v53.

## Why

cloud-hypervisor 53.0.0 changed on-demand restore in two ways: the
userfaultfd
handler now background-prefaults every registered page (`#8150`), and a
snapshot is
refused while that runs (`#8556`). Together these make
`memory_restore_mode=OnDemand`
— what we use today — **unusable** on v53: the prefault storm starves
the guest and
its readiness probe never passes. The actor never comes up.

cloud-hypervisor ships minor releases and rarely backports fixes to a
patch branch,
so pinning v52 means running an unmaintained hypervisor indefinitely. We
need to be
able to keep up.

Restoring eagerly sidesteps both problems: it reads only the snapshot's
populated
extents and registers no userfaultfd, so nothing prefaults and nothing
gates a later
snapshot. But eager is the wrong choice on v52, where on-demand costs a
tenth of the
memory. So this is a per-VMM decision, not a configuration knob.

## How

- `vmm.ping` already answers before every restore and its reply carries
the version;
we were discarding it. `Ping`/`WaitReady` now return it — no extra
process, no
  extra round trip, nothing to cache.
- Pick the mode from that version. The affected range is **bounded**
(`prefaultingSince`/`prefaultingUntil`) rather than "53 and up forever",
so when a
release stops prefaulting unconditionally, on-demand's smaller footprint
comes back
  by moving one constant.
- An unreadable version restores eagerly and logs it. The two ways to be
wrong are not
equal: guessing on-demand on an affected version leaves the actor unable
to start,
  while guessing eager only costs memory.
- Eager makes the snapshot self-contained, so two things that follow
from it: skip the
merge (it would copy the whole resident set onto the restore source for
nothing), and
drop the staged memory image (nothing pages from it or merges against it
afterwards).

## Measurements

kind, arm64, 2 GiB guest, counter demo. Each config measured
identically, no flags set.

| | idle memfd | VMM RSS | pause (median) | per-actor disk |
|---|---|---|---|---|
| v52, before and after this PR | 16 MiB | 20–21 MiB | ~0.26 s | 158 MiB
|
| v53, before this PR | — | — | — | **actor never becomes ready** |
| v53, with this PR | 158 MiB | 162 MiB | ~0.32 s | 158 MiB |

On v52 the runtime detects `52.0.0`, selects on-demand, and reproduces
the current
shipped behaviour exactly — this is a no-op on what we run today. On v53
it detects
`53.0.0`, selects eager, and the actor works.

Within v53, the two follow-on changes are worth their own line:

| | v53 pause (median) | v53 per-actor disk |
|---|---|---|
| mode selection only | ~0.69 s | 318 MiB |
| + skip the merge | ~0.45 s | 318 MiB |
| + drop the staged image | ~0.32 s | **158 MiB** |

## Testing

- Unit tests for version parsing and selection, including the real
`vmm.ping` payloads
from both binaries we ship, and a test documenting how to retire the
workaround.
- e2e on kind against **both** binaries with no flags set: v52 →
on-demand, v53 → eager,
  counter continuity across five pause/resume cycles each.
- `go test -race ./cmd/ateom-microvm/...` green; builds for linux and
darwin.

----

NOTE: This PR does not upgrade chv to v53+ by default, I'd like to close
the remaining gaps first.
## Summary

Adds `hack/metrics/ci-failure-analysis.py`, a script that samples recent
failed `pr-workflow` CI runs, fetches their job logs, and classifies
each failure by root cause rather than treating all CI failures as
equivalent.

**Failure categories:**
- `named_test_fail` — a specific Go test emitted `--- FAIL: TestName`
- `no_free_workers` — envtest/integration tests hit worker resource
contention
- `e2e_timeout` — e2e tests timed out waiting for actor/service
responses (503s, connection resets)
- `gcs_access` — GCS/S3 bucket access denied when fetching sandbox
assets
- `license_check` — `hack/verify/licenses.sh` detected uncommitted
LICENSES changes
- `verify_fail` — other `hack/verify-all.sh` step failed
- `unknown` — could not classify from log output

**Usage:**
```bash
python hack/metrics/ci-failure-analysis.py \
  --repo agent-substrate/substrate \
  --fetch-limit 500 --sample 40

# Save for later comparison
python hack/metrics/ci-failure-analysis.py \
  --repo agent-substrate/substrate \
  --fetch-limit 500 --sample 40 \
  --save ci-breakdown.json
```

**Sample output (40 sampled runs, Aug 2026):**
```
Named Go test failure  50%  ████████████████
No free workers        30%  ██████████
E2e timeout / 503       5%  █
Unclassified           15%  █████

Named test failures:
  6x  TestDurableDirLifecycle
  4x  TestActorLifecycle
  2x  TestMultipleDurableDirLifecycle
  2x  TestSyncer_UpdateWorker_RetryOnVersionConflict
  2x  TestLoaderConcurrentHandshakes
```

This informed the timeout fixes in agent-substrate#800 and is intended to feed the
planned scheduled CI remediation agent.

## Checklist

- [x] Issue is linked above (see agent-substrate#799)
- [x] Tests pass locally (`go test ./...`)
- [x] Documentation updated if behavior changed (script is new, no
existing docs affected)

---------

Co-authored-by: Aditya Shantanu <aditya-shantanu@users.noreply.github.com>
…client (agent-substrate#820) (agent-substrate#876)

Fixes agent-substrate#820.

## Problem
The claude-code-multiplex demo UI dialed ateapi with a hand-rolled
dialer (`dialAteAPI`) using `InsecureSkipVerify` TLS and **no bearer
token**. Since ateapi now requires authorization, every `/api/actors`
call fails with `missing bearer token`.

## Fix
Per @maxsmythe's guidance, drop the demo's bespoke dialer and use the
standard authorized client in `internal/ateclient`:

- `server.go` now calls `ateclient.NewClient(ctx, "", "", ATEAPI_ADDR,
false)` — the same path kubectl-ate uses: mints an ate-client
ServiceAccount token and verifies ateapi's serving cert against the live
ClusterTrustBundle before attaching the token.
- Empty `ATEAPI_ADDR` → auto port-forwards to `svc/api` in `ate-system`
(no manual `kubectl port-forward` needed); set `ATEAPI_ADDR` to target
an already-forwarded endpoint (still authenticated).
- Removed the now-dead `crypto/tls` + raw `grpc`/`credentials` imports
and the `defaultAteapiAddr` constant; updated the package usage doc
comment.

## Test
- `go build ./demos/claude-code-multiplex/ui/` — clean.
- `go vet ./demos/claude-code-multiplex/ui/` — clean.

Net: `1 file changed, 31 insertions(+), 35 deletions(-)`.

Signed-off-by: Alex Bulankou <alexbu@google.com>
Benchmarking needed updates to fix auth bitrot and to enable readint
endpointslices. Also streamlined the ability to manually benchmark
microvm and removed Python Locust workers by default to simplify
self-serve benchmarking.
…gent-substrate#743)

### Why

The README Quickstart covers getting a local Substrate cluster running
with
the default gVisor runtime, but there's no documentation for the
micro-VM
runtime, where the friction can't be scripted away: it requires
`/dev/kvm`
(bare metal, nested virtualization, or Lima on macOS), and on Apple
Silicon
the Lima configuration and asset assembly are non-obvious. New
contributors
  have to reverse-engineer the `hack/` scripts.

  ### What
  
Adds `docs/dev/microvm-local.md` — "Running the microVM runtime locally"
— a
focused guide covering only the microVM delta, based on notes from real
onboarding runs. General setup is deferred to the README Quickstart
(listed
  as the guide's prerequisite) rather than duplicated:

- **Option A: Linux host with KVM** — verifying `/dev/kvm` and CPU virt
support, the rootless-Docker caveat for the KVM probe, cluster creation,
    and the one-shot `run-microvm-demo-kind.sh` bring-up.
- **Option B: Apple Silicon macOS via Lima** — nested virtualization
with the
guest image pinned to Ubuntu 25.10 (until the kernel issue in the
default
image is fixed), `vzNAT` networking, and assembling the arm64 assets
inside
the Lima guest (`assemble.sh` requires a Linux host of the target arch).
- **Trying it out** — defers to the next steps the demo script prints
and
links the counter demo's micro-VM variant, instead of duplicating those
    commands.
- **Troubleshooting** — symptom → root cause → fix entries actually hit
    during onboarding (rootless-Docker KVM probe failures, the `aws` CLI
staging requirement until agent-substrate#804 lands, arm64 `virtiofsd` build deps, M1
    lacking FEAT_NV2).
…-substrate#831) (agent-substrate#834)

Add the `ate.imagecache.requests` counter.

  **`ate.imagecache.outcome`** (new key in `internal/ateattr`)

  | Value | Meaning |
  |---|---|
| `hit` | the node holds a complete image record: each layer directory
that the record names is present |
  | `miss` | the lookup must pull |
| `error` | the lookup failed; the only outcome that carries
`error.type` |
| `cancelled`, `timeout` | the caller gave up, so the cache is not at
fault |

A failed lookup is neither a hit nor a miss, so it gets its own outcome,
as
`no_free_worker` does on `ate.scheduler.outcome`. `cancelled` and
`timeout` are
outcomes for the same reason they are on `ate.router.outcome`. The hit
ratio is
therefore `hit / (hit + miss)`, with failures and abandoned lookups out
of the
  denominator.

  **`error.type`** — set only on the `error` outcome.

  | Value | Meaning |
  |---|---|
| `404`, `401`, `429`, ... | the registry rejected the request; its own
HTTP status, reported verbatim |
  | `_OTHER` | the failure carries no status of its own |

Fixes agent-substrate#831

> 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
This is a very similar fix to agent-substrate#829

We're also removing the precondition as the storage layer function
arguments and passing it as a wrapper around the closure functions.

agent-substrate#763

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…d CRUD methods in redis package (agent-substrate#824)

Part of agent-substrate#477 .

All ATV fields are immutable. SandboxConifg will be frozen into ATV at
creation time.
Adds per-resource store methods and the ateredis package for the AT and
ATV as global resources.
Switches the port-forwarding implementation in `internal/portforward`
from SPDY to WebSockets using `portforward.NewSPDYOverWebsocketDialer`.

In Kubernetes 1.31, by default kubectl now uses the WebSocket protocol
instead of SPDY for streaming.

Using WebSockets also allows `kubectl ate` to work with Connect Gateway.
agent-substrate#892)

This was breaking 

`hack/install-ate.sh --delete-all` with
`--delete-demo-autoscaled-workerpool is not supported on GKE`

Also removed the custom usage message for
--deploy-demo-autoscaled-workerpool. This was duplicate with the default
message:

Before:

```
Demo: demo-autoscaled-workerpool

  --deploy-demo-autoscaled-workerpool                         Deploy demo-autoscaled-workerpool
  --delete-demo-autoscaled-workerpool                         Delete demo-autoscaled-workerpool
  --deploy-demo-autoscaled-workerpool            Deploy autoscaled-workerpool demo (HPA + prometheus-adapter + counter workload)
```

After:

```
Demo: demo-autoscaled-workerpool

  --deploy-demo-autoscaled-workerpool                         Deploy demo-autoscaled-workerpool
  --delete-demo-autoscaled-workerpool                         Delete demo-autoscaled-workerpool
```

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…oop review follow-ups (agent-substrate#837)

Phase 2 of agent-substrate#463, following agent-substrate#735 and agent-substrate#836: `validate-image-cache` drives
eviction through `Store.EvictUnused`, deleting its pre-engine prototype.
Also carries agent-substrate#836's post-merge review follow-ups.

## Tool
- **Prototype evictor deleted** (mtime-sorted tree removal, then
dropping every manifest record — no refcounts, no two-phase rename, no
restore). The tool now asks the engine to reclaim the shortfall below
`--min-free-gb`, so corpus runs exercise production semantics.
`--evict-idle` maps to `WithMinAge`.
- **`--evict-all`**: one-shot flush, no refs file or auth. Unlike `rm
-rf`, running actors' images survive via bundle-spec rooting
(`WithActorsDir`). It is not synchronized with a running atelet (the
engine's locks are per-process): the pool can't be corrupted, but a
layer reused mid-pass can be evicted from under an actor — a
not-yet-mounted start fails once and heals; an already-mounted actor can
take EIO. Documented, warned at runtime; the intended end state is an
atelet-owned flush (RPC or trigger), not a second process in the pool.
- **`--evict-idle` floors at 1m when an actors dir exists**: min-age is
the only protection that applies across processes, so on a live node it
can't be tuned away. Validation hosts (no actors dir) keep full freedom.
- Gate-aware errors in both eviction paths (`ErrIncompleteEnumeration` =
"did nothing, repair the named path" vs per-item errors = stats still
print, exit 1); a 30s cooldown after fruitless passes so workers don't
serialize full engine passes when nothing is evictable.

## Carried agent-substrate#836 follow-ups
- Negative `--image-cache-gc-period` rejected (was silently disabling
the loop); `noteOutcome` extracted with the backoff cadence unit-tested;
engine per-pass line demoted to DEBUG (no-target ticks are now silent);
single-line sentinel wraps; `filepath.Abs` + table test for the
outside-BasePath warning; `runPass`/`Run` covered behind a `gcStore`
seam; flag help and README made truthful (`period=0` still runs startup
recovery; watermark reads ~5 pts above `df`; the total-size cap still
evicts everything evictable under sustained foreign pressure — retention
floor named as the extension).

## Testing
- Unit: `-race` clean; new tests for backoff cadence, flag validation,
path check, `runPass` skip/panic paths, and `Run` (immediate first pass
pinned deterministically; ticking covered separately).
- Kind: full e2e green; gated-pass arc verified live (one ERROR per tick
naming the corrupt record; startup scan gates and recovers; planted
orphan reclaimed at restart); `--evict-all` on a live node evicted 4
unrooted images while all rooted images survived.
- GKE (6 nodes): zero image-cache log lines fleet-wide at default log
level across ticks.
- **Corpus sweep** (7.9 GB volume, always-low-water, `--evict-idle=10s`
on a host with no actors dir, parallel pulls): 64/64 images validated
with two eviction episodes mid-run (62 img / 103 layers / 5.2 GB, then
46 img / 65 layers / 2.7 GB) — continuous eviction under concurrent real
pulls, zero races.
…bstrate#832)

Third and last of the PRs for Phase 0 of agent-substrate#550, and what completes agent-substrate#594.
agent-substrate#739 does the gVisor half; this one is independent of it and does not
touch it, so the two can land in either order.

Fills in the measurement half of `GetWorkloadStats` for the micro-VM
runtime, so it returns real numbers instead of `Unimplemented`.

## Where the numbers come from

Inside the guest, not from a host cgroup.

The host cgroup here holds cloud-hypervisor, and its memory is the guest
RAM allocation it took at boot — near-constant, and near-identical for
an idle actor and a saturated one. The numbers that move with the
workload are the ones the guest kernel keeps, and the kata-agent's
`StatsContainer` is what reads them out. `AgentClient` grows that call;
it is safe alongside the stdout/stderr forwarding, since ttrpc
multiplexes the one connection, which is already what those goroutines
rely on.

What gets summed is the actor's overlay **workloads**, one per
container. Their carriers are deliberately absent: a carrier is created
and never started (see `CreateCarrier`), so it runs no process and its
cgroup has nothing in it to add. Summing the containers is what turns
per-container guest accounting into the one per-actor figure the proto
reports.

Summing the peaks is an upper bound on the peak of the sum rather than
the figure itself — two containers need not have peaked at the same
moment — and for the single-container actors this runtime mostly serves
it is exact. Flagging it in case you'd rather report the largest single
peak instead; I think the bound is the more useful of two imperfect
answers.

## The conversion

New `cmd/ateom-microvm/internal/agentstats`. Pure: it takes an
already-fetched `CgroupStats` and never talks to a guest, which keeps it
testable without a live micro-VM and — unlike the rest of the micro-VM
ateom — without the `linux` build tag.

It never fails. Every field the agent left out reads as zero, and nil
stats (what the agent answers for a container it has no accounting for)
is a zero sample rather than a panic on a path polled for the life of
every workload.

Working set subtracts the guest's reclaimable page cache, saturating at
zero, and accepts both the v2 name (`inactive_file`) and the v1
hierarchical one (`total_inactive_file`). CPU time is divided by 1000:
the agent reports nanoseconds, matching the runc stats struct its own is
modeled on, and the proto wants microseconds.

A container the agent cannot report contributes nothing instead of
failing the sample, and for the common way that happens zero is the
*correct* contribution rather than a fallback — a container that has
exited took its guest cgroup with it and consumes nothing from here on.
Failing an actor's telemetry because one sidecar is gone would be the
wrong answer. The sample fails only when no container could be read at
all, which is the guest as a whole not answering rather than one
container being gone.

## Status codes

A deliberate mirror of the gVisor handler, so the two runtimes answer a
poller the same way.

**`NOT_FOUND`** — available, or a UID mismatch. Each says the actor is
not here, and the caller's worker-to-actor mapping wants re-resolving.

**`FAILED_PRECONDITION`** — no guest to ask yet. A poll landing in the
boot or the restore (attribution is retained from the moment the ateom
accepts the actor), or one landing mid-teardown. A guest that answers
nothing at all is this code and not `Internal`: the sandbox going away
is a routine state here, and the next `CheckpointWorkload` turns it into
the `NOT_FOUND` above.

## Locking

The handler takes no lock, and that is a stronger constraint here than
on the gVisor side, where the cgroup path is a constant. The agent
client lives in `AteomService.running`, which `lock` guards, so reading
it from the handler would be a data race whatever the read is for.

Hence `AteomService.guestStats`: an atomic holding the agent client and
the container ids, published once the containers are up and cleared by
`teardownActor` before it closes anything. Clearing it there rather than
alongside the attribution is what keeps a poll landing mid-teardown on
the "no numbers right now" path instead of surfacing a closed connection
as a failed read.

`TestGetWorkloadStatsDoesNotTakeLock` pins the whole property: it holds
`s.lock` across the call, so a handler that reached for it — or that
looked the agent up in `running` — deadlocks. After the read the handler
reloads `activeActor` and compares pointer identity, so a checkpoint
plus a fresh run completing underneath it is `NOT_FOUND` rather than
misattributed.

## Epoch semantics differ from the cgroup source

`memory_peak_bytes` and `cpu_usage_usec` accumulate, and the epoch they
accumulate over does not begin where the gVisor source's does. This is
the source the proto's epoch note (landing in agent-substrate#739) is being careful
about.

There, a restore ends the epoch: `runsc delete` destroys the sandbox
cgroup and its counters with it. Here the counters live in the guest
kernel's own memory, so `restoreFullScope` — relaunch cloud-hypervisor
with `--restore`, then resume — brings them back with the guest RAM
rather than restarting them, and a **FULL** restore continues the epoch
across what the caller sees as a gap. **DATA** has no guest to resume
and cold-boots, so that scope does restart at zero.

**DATA_ON_GOLDEN** is the case that defeats the obvious detection: it
resumes the *template's* golden guest, so an actor's first sample can
begin at whatever the golden had accumulated when it was snapshotted —
an epoch beginning *above* the value last reported, with no decrease
anywhere for a caller to notice. Nothing here can hide that. A caller
wanting a lifetime figure has to accumulate one itself and treat a scope
transition as a boundary.

## Proto

Two comment-only edits, on different lines than agent-substrate#739 touches:

- `STATS_SOURCE_GUEST_AGENT` now says what it counts and what it cannot
see — the guest kernel, the agent, and the host VMM process are overhead
outside the workload's own containers. The cgroup source is the other
way round, since the sandbox's host process is one process and its
runtime's overhead is charged along with the workload's. The two sources
are not comparable figures and the enum should say so.
- The message doc now explains *why* there is no per-container
attribution rather than just stating it. This source could give it; the
gVisor source cannot split one at all. A field only one runtime could
ever fill would be worse than none.

## A gap worth knowing about

A restore whose post-restore agent dial fails answers
`FAILED_PRECONDITION` for the rest of that activation. Telemetry rides
on the connection log forwarding already keeps open, and that dial is
best-effort by design — a failed dial must not fail a restore whose
actor is already running. A second dial of its own would not help:
whatever kept the agent from answering a 15s retry loop would keep it
from answering that one too.

## Testing

- `agentstats`: a fifteen-case table over `FromCgroupStats` (v2 guest,
v1 `total_inactive_file`, both keys present, reclaimable cache at and
above usage, missing `memory.stat`, no peak, sub-microsecond truncation,
memory-without-cpu and the converse, nil and empty stats), plus
`TestSamplePlus` for the summation including saturation.
- `GetWorkloadStats`: happy path, `TestGetWorkloadStatsSumsContainers`,
`TestGetWorkloadStatsSkipsUnreadableContainer`,
`TestGetWorkloadStatsCountsAnsweredContainer`, a seven-case error table,
and the no-lock regression test.
- The `cmd/ateom-microvm` tests are `//go:build linux` and were run
rather than only compile-checked: `go test ./...` is green for the whole
repo inside a `golang:1.26.3` container, and the `GetWorkloadStats`
tests pass under `-race`.

**Draft, because one thing is measured rather than exercised.** The
field mapping is a property of the guest kernel and image rather than of
this code, and a live guest answering `StatsContainer` needs a micro-VM
worker, which needs a nested-virtualization node — neither the GKE dev
cluster (`c3-standard-8` with `advancedMachineFeatures` absent) nor a
local Mac provides one today. So it was measured against the pinned
assets themselves — the kata-static 4.0.0 kernel and rootfs that
`hack/microvm-assets/assemble.sh` stages and ateom fetches:

- **Which cgroup version a container gets** is not a question that guest
can answer two ways. Its kernel is 6.18.35, built `CONFIG_MEMCG=y` with
`CONFIG_MEMCG_V1` unset, so a v1 memory controller cannot be mounted
there at all. Its init is systemd 255.4, which reports
`default-hierarchy=unified` when run from that rootfs, and
`buildVMConfig` passes no hierarchy override on the cmdline. Containers
get v2, and `inactive_file` is the spelling that appears.
- **Whether a high-water mark is reported** resolves the same way. The
agent links cgroups-rs 0.5.1, whose v2 path reads `memory.current` into
`usage` and `memory.peak` into `max_usage`, and passes `memory.stat`
through verbatim. `memory.peak` has existed since 5.19, so on a 6.18
guest the peak is a real figure rather than the zero this defends
against.
- **The unit conversion** falls out of the same source: v2 has no
`cpuacct` controller, so the agent falls through to `cpu.stat` and
multiplies `usage_usec` by 1000 — the nanosecond reading the division
here turns back into microseconds.

The v1 spellings stay anyway. The guest image is a `SandboxConfig`
asset, so a cluster can be pointed at a different one, and a wrong guess
there should cost a low working-set figure rather than a failed sample.

Happy to take it out of draft as-is if reviewers are content with the
assets being measured instead of a guest being run; otherwise it waits
on a nested-virt node.

Part of agent-substrate#594
…bstrate#640)

Adds Postgres as an alternative persistence backend for ateapi as
proposed in agent-substrate#731. Currently this is opt-in with flags in ateapi or use
the `--store-backend=postgres` option in the install script.

Slack discussion:
https://cloud-native.slack.com/archives/C0B6M3E2J3D/p1785183769252679

Performance benchmark results for Postgres vs Redis:
https://docs.google.com/document/d/12-ko_BFHcBo_nJkx9f4B7zMbiiWKC2saGhMhZG3aQ-s/edit?usp=sharing

Benchmarks are not included in this PR, but you can reproduce the
results by following the doc above

Limitations and questions:

- This draft does not yet handle migrations, this might come as a follow
up
- It currently uses `testcontainers` for testing Postgres, but as a
result it adds a lot of dependencies

---------

Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
E2E test WorkerPools (and thus pods) weren't cleaned up until all tests
were finished running. This resulted in resource exhaustion and pods not
being scheduled. If a test completes successfully delete the namespace
immediately.

- [ x ] Tests pass
- [ x ] Appropriate changes to documentation are included in the PR
The atelet dial target concatenated the pod IP with ":8085", which
net.Dial rejects for IPv6; grpc.NewClient dials lazily, so it surfaced
as Unavailable on every snapshot and restore. Use net.JoinHostPort.

Also lifts the 8085 literal into internal/atelet.DefaultPort, shared by
atelet's --port flag and the dial target.
This is intended to be essentially a dead code cleanup, not a
controversial change. There were two ways to deploy the same proxy
component; I assume no one is customizing the deployment mechanic, and
this follows how other components work (just deployed with YAML).

This simplifies the setup so we only need to maintain one path. The
default option is retained so there is no behavioral changes.


> 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

Co-authored-by: Eitan Yarmush <eitan.yarmush@solo.io>
krisztianfekete and others added 29 commits August 19, 2026 11:02
Actor logs used `ate.dev/actor_*` while spans and metrics use `ate.*`
registry in internal/ateattr. This PR makes ateattr the single source of
truth for everything telemetry-related.

- Renamed the six actor log labels onto the registry: 
- `ate.atespace`, `ate.actor.name`, `ate.actor.uid`,
`ate.template.namespace`, `ate.template.name`,
`ate.actor.container.name`
- Logs join traces now. Records set `trace_id`, `span_id` and
`trace_flags`, so you can go from Actor restored to the resume that
caused it. Our own lines only, not an actor's stdout: one goroutine
forwards a whole container stream and can't know which request produced
a given line. Per line correlation comes with agent-substrate#853.
- Actors can't fake platform labels. They already couldn't overwrite
ours, but they could invent new ones like `ate.tenant` that look
platform issued downstream. Anything under `ate.` from an actor is now
dropped.
- Fixed the asymmetry that was actually left: actor supplied label
values weren't stringified, and one non string value makes Cloud Logging
discard the labels for that whole entry.
- Note: the actor_uid bullet in the issue is stale, agent-substrate#841 fixed it
earlier. Lifecycle records still set five labels rather than six, on
purpose as they're about the actor, so no container produced them.

Fixes agent-substrate#886

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

---------

Signed-off-by: krisztianfekete <git@krisztianfekete.org>
Finishes up the vision from agent-substrate#715 to have atunnel serve CONNECT on the
ingress path. This will give us the option to hit actors on other ports
besides 80. I haven't wired up atenet router yet because it's
nontrivial; we should do that in a second step so we can have a baseline
for performance

---------

Signed-off-by: Keith Mattix II <keithmattix2@gmail.com>
Make workers a gobal resource, identified by a unique name. Worker APIs
will be implemented in a follow up change.

Part of agent-substrate#730 

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

## Summary

Adds `docs/integration-repos.md`: where end-to-end integrations live,
how their
repositories are named, and how the fixes they need flow back into core.

The convention in one line — trivial demos stay in the core repo, each
non-trivial integration gets one dedicated repo under the
`agent-substrate`
org, and core gaps get closed by making core configurable with defaults
unchanged rather than by patching it downstream.

## Why now

We are about to create the first real, end-to-end integrations rather
than
counter-style demos: a code-execution sandbox, and an always-on agent.
Both are
large enough to need their own images, dependencies, and release
cadence.

Whichever repository gets created first will set the precedent for every
one
after it. This writes the convention down so that precedent is chosen
deliberately instead of inherited by accident.

## What it covers

- **Where code lives** — the core-repo/dedicated-repo split, the rough
test for
which side something falls on (API keys, external services, third-party
accounts), and why this is a set of peer repos rather than a second org.
- **Naming** — capability-named for general capabilities
  (`code-execution-sandbox`), integration-named for specific third-party
products, named for the product rather than the vendor behind it. Plus
what to
avoid: over-broad names, names that clone a vendor's API or brand, and
the
  redundant `-integration` suffix.
- **Third-party names** — allowed descriptively, with a non-affiliation
note in
the repo README, and brand/policy edge cases cleared before the repo
exists.
- **Upstreaming** — the part with teeth for this repo. Integration repos
that
accumulate local patches against core bitrot, and the gap they work
around
stays invisible to everyone else. So: prefer making core behavior
configurable
with defaults unchanged. agent-substrate#487 and agent-substrate#465 are linked as illustrations of
that
  pattern — this PR does not depend on either, and branches from `main`.
- **Two worked examples** that validate the convention rather than just
  following it, including the third-party-name edge case.

## Review

This was announced at the community meeting and circulated as a shared
design
doc with a 7-day review window, which has now closed. It synthesizes the
`#integrations` thread discussion. Comment history:


<https://docs.google.com/document/d/1Tb6u0b1XSvWrNpoyD4jdsQaJ58aAgDtQOM18uxujs-8/edit>

This PR is the trimmed version: doc-review scaffolding — status block,
reviewer
list, self-link — is dropped, and only the durable convention is carried
over.

## Left open

Two questions are deliberately out of scope, called out in the doc
rather than
answered. Both are maintainer calls and neither blocks the first
repositories:

- Governance tiers — whether to distinguish "official" from "community"
integrations with different review bars, as Home Assistant and Obsidian
do.
- Who creates integration repositories and grants per-integration
maintainer
  access.

## Also in this PR

- README gets an entry in the docs list, matching every other file in
`docs/`.
- `CONTRIBUTING.md` gets one sentence pointing there, since "where does
my
  integration go?" is a question a contributor asks before opening a PR.

Fixes #<issue_number_goes_here>

> 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
…substrate#961)

Second piece of agent-substrate#896 (Phase 1 of agent-substrate#550): atelet polls every local ateom's
`GetActiveWorkloadStats` and turns the samples into template-level
metrics — the TSDB half of agent-substrate#174's cardinality split. After this PR, "how
much CPU and memory is this template using" is a Cloud Monitoring query.

## The reader

A `statsPoller` in atelet, driven by `--actor-stats-poll-interval`
(default 1m; `0` disables; nonzero values are clamped to a 50s floor,
the worst-case duration of sampling one micro-VM ateom — 25 containers ×
2s per guest-agent call).

- **Stateless discovery**: each tick lists `ateoms/*` (entry names are
worker pod UIDs — the same sockets the lifecycle RPCs dial) and probes
each with the parameterless discovery read. No worker-to-actor mapping,
no control-plane dependency, nothing to recover after an atelet restart.
Attribution comes solely from the identity echoed in each sample, per
the RPC's contract.
- **One tolerance rule**: any dial or call failure means "not a target
this tick" — covering stale directories of deleted workers, ateoms that
have not started listening, and teardowns mid-poll. `NO_WORKLOAD` /
`NOT_MEASURABLE_YET` are skips by the RPC's own contract.
- **Bounded concurrency**: distinct ateoms are probed 8 at a time (one
probe per guest, so nothing the interval floor defends against is
reintroduced); a node of stuck-but-accepting sockets degrades to
ceil(n/8) timeouts instead of n sequential ones.
- **WorkerPool enrichment**: one field+label-selected pod LIST per tick
maps pod UID → owning pool (`ate.dev/worker-pool`, the label the pool
controller stamps); unresolved pods group without pool labels rather
than vanish. Chosen over an informer deliberately — negligible apiserver
cost at this cadence, no cache-sync ordering, and the resolver sits
behind a function seam if that trade ever changes. Needs one new
Downward API env (`NODE_NAME`); the pods RBAC already existed.

## The metrics

Labels on every series: `ate.template.namespace/name`,
`ate.sandbox.class`, `ate.stats.source`, `ate.workerpool.namespace/name`
— all bounded sets; actor and atespace identity never reach a metric
label (they belong to the events channel, the next PR).

- `ate.actor.stats.sampled_actors`, `…memory_current_bytes`,
`…memory_working_set_bytes` — **observable gauges** over the latest
tick's snapshot: each collection observes exactly the groups that
currently exist, so a template whose actors leave a node disappears from
the export. (Synchronous gauges would re-export their last value until
process exit — stale memory for actors long gone.)
- `ate.actor.stats.cpu_usage` — **Float64Counter in seconds** (cAdvisor
/ OTel `*.cpu.time` convention; the wire stays µs). The raw
`cpu_usage_usec` is cumulative per-epoch per actor, so the poller
accumulates per-sweep *increases* against per-actor baselines: first
sight establishes a baseline and charges nothing (atelet cannot tell a
new actor from its own restart — re-charging epochs the previous process
counted would spike `rate()`), a decrease is an epoch reset charged from
the new value, and baselines are pruned to the actors seen. Bounded
imprecision (≤1 interval per actor across restarts; the pre-checkpoint
tail) is documented on the instrument; per-actor precision arrives with
the lifecycle events.

## Validated live on ate-dev

Both source families, simultaneously, with one actor per class:

<img width="2256" height="1180" alt="image"
src="https://github.com/user-attachments/assets/86ed3390-99b9-47f4-bbd2-a39ff1fd8d45"
/>


The same counter application reads 5-6× larger through the cgroup source
(whole sandbox: sentry heap, netstack, gofers) than through the guest
agent (workload containers only) — the concrete case for the
`ate.stats.source` label and its group-don't-sum rule. Also exercised
live: pool labels resolved on every series, restart-without-spike on the
CPU counter across a DaemonSet rollout, and OTLP delivery to the
gke-managed-otel collector with zero export errors.

## Out of scope

- Per-actor events + lifecycle first/final samples — next PR per agent-substrate#896.
- `k8s.node.name` resource attribute (one manifest line, any time).
- e2e coverage — the metrics e2e suite is the natural home once the
events channel lands.

Part of agent-substrate#896, toward agent-substrate#550.
Fixes agent-substrate#744
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…ent-substrate#803)

Part of agent-substrate#802 (first PR: the actorIdentity data source; does not close
the issue).

## What changed and why

Adds a systemInfo volume source to ActorTemplate — a read-only volume
whose files are generated by atelet on every Run/Restore, analogous to
Kubernetes projected volumes. The initial data source, actorIdentity,
writes the actor's own name to a configurable relative path:

```
spec:
  volumes:
  - name: system-info
    systemInfo:
      dataSources:
      # Part 1 (this PR): own-metadata projection, downwardAPI-style
      - actorMetadata:
          items:
          - field: name              # enum: name | atespace | uid
            path: actor-name
          - field: atespace
            path: atespace
          - field: uid
            path: actor-uid
  
  containers:
  - name: main
    image: app@sha256:...
    volumeMounts:
    - name: system-info
      mountPath: /run/ate  
```

Because the files are regenerated before the sandbox starts, they carry
the resumed actor's own values regardless of what checkpointed state it
boots from — the property the old hardcoded /run/ate identity mount
provided, now as an explicit, extensible API that future data sources
(identity JWTs, certificates — see agent-substrate#802) can slot into.

**Behavior change:** the automatic /run/ate/actor-id mount is removed;
actors must opt in by declaring the volume (the e2e identity probe in
this PR is the reference example).

### Reviewer notes:

- Over half the diff is vendored + generated code
(cmd/atelet/internal/third_party/atomicwriter/, atelet.pb.go,
zz_generated.deepcopy.go, the CRD manifest). The hand-written surface is
~700 lines.
- System-info volume roots live under a new ActorPath/system-info/ host
dir, deliberately separate from durable-dir/: the micro-VM durable
machinery snapshots everything under the durable-dir root, and generated
identity files must never be captured into snapshots.
- Supports microVM as well as gVisor.
- The e2e identity suite exercises the new API end-to-end with unchanged
probe binary and assertions. It runs in the kind-cluster CI job (not run
locally).

## Checklist

- [x] Issue is linked above
- [x] Tests pass locally (go test ./...)
- [x] Root-gated tests pass if applicable (N/A — no root-gated packages
touched)
- [x] Documentation updated if behavior changed (docs/api-guide.md:
SystemInfo Volumes section with example)

---------

Co-authored-by: Taahir Ahmed <taahm@google.com>
…gent-substrate#1061)

Snapshot upload is the largest single item in a micro-VM bake or suspend
— ~800 ms of a ~1.65 s server-side bake — and for the snapshots we
actually upload it is round-trip bound rather than byte bound.

The GCS client's resumable upload sends 16 MiB chunks one after another,
each paying a round trip. An idle micro-VM golden snapshot is ~24 MiB
compressed, so it spans two chunks and pays twice. Setting `ChunkSize`
to 64 MiB puts a typical snapshot in one request.

16MiB is reasonable for clients on poor connections sending small files.
It's not reasonable for data centers and large files.

### Measurements

Uploads through this exact streaming path (non-seekable body into a
`storage.Writer`), from a pod on a GKE worker node (c3-standard-4,
us-central1-f) using atelet's service account and the snapshot bucket.
Three runs at 24 MiB:

| chunk size | 24 MiB upload |
|---|---|
| 16 MiB (client default) | 425 / 438 / 530 ms |
| 32 MiB | 331 / 361 / 405 ms |
| **64 MiB** | **258 / 313 / 314 ms** |
| 128 MiB | 350 / 391 / 487 ms |

About 35% off an idle actor's snapshot upload. The client buffers at
most `min(object, ChunkSize)`, so a small object still costs only its
own bytes.

### What this deliberately does not do

Nothing for large snapshots. At 300 MiB a single stream measured 77–107
MiB/s for *every* chunk size from 16 to 128 MiB, because the transfer
dominates:

| config | 300 MiB |
|---|---|
| 16 / 32 / 64 / 128 MiB chunks | 82–107 MiB/s |
| composite, 2 parts | 150–163 MiB/s |
| composite, 4 parts | **233–257 MiB/s** |
| composite, 8 parts | 224–231 MiB/s |

Beating the single-stream ceiling needs parallel parts plus a compose,
which is a format change (each part has to be independently produced and
decodable), so it is left as a follow-up.

For context on why this is not a compression problem: on the same node
`zstd -1` compresses real guest memory at 549 MiB/s on two cores (167 ms
of a ~700 ms upload), while the whole pipeline moves ~105 MiB/s. Raising
the compression level trades CPU for fewer bytes on a wire that is not
the constraint at these sizes.

### Testing

- `go build`, `go vet`, `hack/verify/golangci-lint.sh` and `go test
./cmd/atelet/...` pass.
- The chunk-size effect is measured against the real GCS backend from a
real worker node, as above. I did not roll a patched atelet on the
shared cluster, so the end-to-end effect on a live suspend (~700 → ~400
ms) is inferred from the benchmark rather than observed in situ.

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR (none
needed; the rationale and numbers live in the code comment)
Removes the non-functional token/JWT mode for in-cluster ateapi clients.
Clients now always use mTLS certificates; related flags, install and
benchmark plumbing, tests, and overlays are deleted.

Validated with focused Go tests, shellcheck, and Kustomize renders.

Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
agent-substrate#954

- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
An ActorTemplate container can declare limits so it cannot starve or kill its
siblings in the same actor. Only limits are expressible: a request is a
scheduling hint, and scheduling happens at the pool level, so per-container
limits subdivide a budget that is already held.

Gated to sandboxClass microvm. gVisor applies cgroup limits at the sandbox
level, where one sentry backs every container in the actor, so a per-container
cgroup is created and then stays empty (google/gvisor#190). The gate lifts per
runtime.

Only cpu and memory are accepted and each must be greater than zero, so a
template cannot declare a limit that is silently discarded downstream. Limits
is a bounded named type because a MaxProperties marker on the field lands at
the wrong schema level for a named map, and without a bound the CEL cost
estimator rejects the whole schema.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Scalar bytes and millis rather than a ResourceList map, so ate-api-server
parses each resource.Quantity once and every consumer downstream compares
numbers.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
toAteletResources is the only place a resource.Quantity is parsed.

The wiring into the ateletpb.Container is covered by a test, verified by
deleting the line and watching it fail: without it, removing the field left
every test in the repository green while limits never left ate-api-server.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Carrying the limits as a standard OCI field means a runtime that gains
per-container enforcement picks them up with no new plumbing. A container that
declares none produces an unchanged spec.

The CFS quota is clamped to the kernel's 1ms minimum, as kubelet's
MilliCPUToQuota does: tg_set_cfs_bandwidth rejects anything smaller, so a cpu
limit under 10m would otherwise produce a spec the guest refuses at container
create, with an error naming a cgroup write rather than the template field.

Quota is derived from the period constant rather than a second literal, and the
test asserts quota/period against the declared milli-cores, so retuning the
period cannot silently change every container's share.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Four changes, all on the path from the bundle's OCI spec to the guest cgroup.

SpecToAgentPB converted only Devices and CPU.Shares out of Linux.Resources, so
a memory limit reached config.json and was dropped on the way to the agent: in
the guest memory.max read "max" and a container limited to 64Mi allocated 128MB
without being killed. It now carries Memory and CPU quota, and defaults the
period when a quota is set, because period is optional in OCI but a plain
uint64 on the wire where an unset one is indistinguishable from zero.

ensureKataCompatibleSpec applied kata's defaults only when Resources was nil,
so the moment atelet set the field the device allowlist and CPU shares would
have vanished. mergeKataResources now fills the gaps the defaults cover and
leaves everything else alone, so a field added upstream reaches the guest
rather than being silently dropped here.

checkResourceEnvelope rejects limits the guest can never satisfy, summed across
the actor's containers because they share one guest. Errors carry
InvalidArgument: ActorTemplateSpec is immutable, so the failure is permanent
and must not read as a server fault.

CreateCarrier and StartOverlayWorkload are handed the same spec, so the limits
were applied to the carrier as well as the workload. The carrier is created and
never started, so a limit there bounds nothing, and one small enough to exclude
its init fails the create with an error naming neither the limit nor the
container.

Verified on hardware: with the limit in place a container declaring 64Mi reads
67108864 in its guest cgroup and is OOM-killed allocating 128MB, while a
sibling that declares nothing keeps running.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
A non-positive limit means "unlimited" in the OCI spec, so it is not a claim on
the guest. Summing it let a negative offset a sibling's real limit and slip the
total past the envelope check: 1536Mi + 1024Mi + (-1536Mi) reads as 1024Mi
against a 2048Mi guest, so a 2560Mi overrun was accepted. cpuLimitMillis already
skipped a non-positive quota; memory now matches.

Also repoints the SandboxConfig anchor in api-guide.md, which went stale when
agent-substrate#848 renamed the section.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
agent-substrate#679 sizes the sandbox from ActorTemplate.spec.resources and stamps that size
onto every container, which overwrites the per-container limits PR agent-substrate#859 writes
into the same OCI field. Records the composition model (per-container subdivides
a declared actor envelope, no inheritance), the chosen fix (stop stamping user
containers on the micro-VM path), and the evidence for keeping over-subscription
validation at runtime: an admission-time CEL sum exceeds the cost budget by more
than 100x and takes the existing rules down with it.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Six tasks from the rebase onto the merged agent-substrate#679 through to the PR update, each
ending in a runnable test. Records that micro-VM tests are linux-only and must
run in Docker, since a green run on macOS silently skips them.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
The actor-level size sizes the VM. Stamping it onto every container replaced
each container's declared limit with the actor total, so a container that asked
for 64Mi got the whole guest and could never be OOM-killed on its own. A
container now gets a cgroup limit only when it declares one; an undeclared
container is bounded by guest RAM, which is the real ceiling.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
Nothing applies the actor-level size to a container spec any more, so the size
threaded from RunWorkload and RestoreWorkload through buildActorContainers into
ensureKataCompatibleSpec had no reader. resolveGuestMemMiB still sizes the VM.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
These were working notes for composing per-container limits with actor-level
sizing, not documentation the project publishes. The behaviour they describe is
documented in docs/api-guide.md, where readers of the API will find it.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
… the ceiling

When the actor declares its own size the guest is that limit minus the VMM
reserve, so advising a larger SandboxConfig sends the user to a knob that has no
effect. The error now names spec.resources.limits.memory and the reserve when
the actor declared a limit, and keeps the SandboxConfig advice when it did not.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
The guide said a micro-VM guest is sized by the SandboxConfig and not by the
actor, which is backwards now that the guest is sized from the declared actor
limit. Also states where over-subscription is caught, so nobody assumes
admission rejects it.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
An operator hitting a limits error or reading the sizing docs needs to be
routed to the knob that actually governs the value they hit.

A CPU-shortfall error named spec.resources.limits.memory, a field that
cannot change the vCPU count, and cited a VMM reserve that applies only to
memory. CPU shortfalls now name spec.resources.limits.cpu.

Since only ateom-gvisor reaches sizing.ApplyToOCISpec, the sizing docs
sent anyone tracing a micro-VM container's cgroup limit to a function the
micro-VM binary never calls; they now point at spec.containers[].resources.
The api-guide preamble made the same claim for both runtimes.

The atelet quota floor comment stated a threshold of 100 milli-cores while
the code, its test, the CRD doc and the api-guide all say 10.

Records that a micro-VM container's rootfs upper is a guest tmpfs charged
to its own cgroup, so writing a large file to the rootfs, /tmp or /run
OOM-kills a container that would survive the same manifest on Kubernetes.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
A container's writable rootfs is an overlay upper on host disk, so its writes
are reclaimable page cache and do not count against the container's memory
limit. The guide described the guest-tmpfs upper that preceded it, which would
send a reader chasing an OOM that cannot happen.

Records on guestEnvelope why guest memory is reduced by the VMM reserve while
CPU is not: the VMM's host processes draw on the same worker-pod CPU quota with
nothing set aside for them, so a container gets less CPU than it declared, but
CPU is compressible where memory is not.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
A non-positive quota means unlimited in the OCI spec, but it was sent as a
literal zero, which the guest applies as no CPU at all. A non-nil zero period
overwrote the CFS default set for a live quota, the unsatisfiable write the
surrounding comment warns about. Both now match cpuLimitMillis, so the
envelope check and the conversion read a spec the same way.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
internal/sizing is no longer shared: ateom-microvm sizes the VM and applies
per-container limits through the kata-agent, so comments in runsc.go, spec.go
and run.go described a topology that no longer exists. The per-container
limits section also drops a redundant requests note, folds its bold blocks
into the surrounding prose style, and documents the vCPU ceiling.

Signed-off-by: Eliran Wolff <eliranw@nvidia.com>
@eliranw
eliranw force-pushed the eliranw/microvm-container-resources branch from 9934135 to c292b22 Compare August 20, 2026 10:57
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.