Skip to content

DO NOT MERGE: dual-stack, stack plus the egress DNS family fix - #6

Open
ygao-g wants to merge 82 commits into
mainfrom
dual-stack-probe-fix
Open

DO NOT MERGE: dual-stack, stack plus the egress DNS family fix#6
ygao-g wants to merge 82 commits into
mainfrom
dual-stack-probe-fix

Conversation

@ygao-g

@ygao-g ygao-g commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Same stack as #4 (agent-substrate#979 + agent-substrate#1057 + agent-substrate#753) plus the two commits that move atenet-egress off dns_lookup_family: V4_ONLY.

lubingtan and others added 30 commits August 5, 2026 20:58
…rate#916)

Since we only have ListWorkers so far, this is just a file rename. The
rest of the handlers should be added to this file, instead of new
handler-specific files, as per agent-substrate#891

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

No declaration added, removed, or renamed, import sets unchanged, and
every non-header line identical to what it replaced.

agent-substrate#891

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

agent-substrate#891

No declaration added, removed, or renamed, import sets
unchanged, and every non-header line is identical to what it replaced.



- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
…g`. (agent-substrate#914)

* Remove the ActorSnapshotRef message.
* Add a `GetActorSnapshotTag` method for the `ActorSnapshotTag`
resource. The request should (as all Get requests) have a single
`ObjectRef` tag field.
* Change `GetActorSnapshotRequest` to have a single `ObjectRef` snapshot
field.
* Add a new field to `Actor,` called `source_snapshot`, which the
following schema:

```
message ActorSnapshotSource {
  // If specified, the actor will fork from this snapshot on the first resume.
  // Immutable.
  ObjectRef tag = 1;

  // A reference to the snapshot the tag resolved to at creation time.
  // Output only.
  ObjectRef snapshot = 2;

  // UID of the snapshot the tag resolved to at creation time.
  // Output only.
  string snapshot_uid = 3;
}
```

See agent-substrate#912 for a more detailed description of the rationale for this
change. Fixes agent-substrate#912
…rate#919)

AIP-193 - we should return the details of ErrorInfo instead of a
rebuilt, following how `InternalServerUnaryInterceptor` already did.

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

Verified in local kind cluster. Fix agent-substrate#925

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

Unifies Actor, ActorTemplate and ActorTemplateVersion resource refs
…#898)

Fixes agent-substrate#897


- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
First piece of agent-substrate#896 (Phase 1 of agent-substrate#550): `GetActiveWorkloadStats`, a
parameterless sibling to `GetWorkloadStats` for a scraper that
enumerates ateoms and holds no worker-to-actor mapping.

## Contract

* An **available** ateom answers an empty `stats` list, not an error —
an idle worker is a normal thing for a scraper to find.
* An **executing** ateom answers the same sample the keyed read would
give, wrapped in a one-element list.
* `FAILED_PRECONDITION` keeps the meaning it has on `GetWorkloadStats`:
executing, but no numbers yet (mid-boot, guest not answering) — skip
this sample, take the next one.
* Consumers MUST attribute each sample solely from the identity echoed
inside it, never from a mapping they hold: with no asserted uid, the
response is the only statement of who was measured. This rule is on the
RPC's doc comment.

## Implementation

Both runtimes share the measurement half of their existing
`GetWorkloadStats`, extracted as `sampleSandbox` (gVisor) /
`sampleGuest` (micro-VM) — the discovery handler is attribution-load,
empty-if-nil, same helper, re-check. No change to what is measured or
how.

The one deliberate semantic split: a lifecycle transition underneath the
lock-free read is `NOT_FOUND` on the keyed read (the caller asserted an
actor that is now gone; its mapping wants re-resolving) but
`FAILED_PRECONDITION` on the discovery read (there is no requested actor
to disown — the numbers just cannot be attributed to any single actor
this tick).

Naming note: agent-substrate#896's sketch called this `GetCurrentActiveWorkloadStats`;
"current" was redundant with "active", so it landed shorter.

## Testing

* Both runtimes: available → empty list; executing mid-boot (no sandbox
cgroup / no guest target) → `FAILED_PRECONDITION`; and a cross-check
pinning that the discovery sample is identical to the keyed sample
against the same fixture — one measurement, two addressing modes.
* `make verify` clean through the proto checks (`go-generate.sh`
confirms the regenerated `ateom.pb.go` / `ateom_grpc.pb.go` are
canonical).

Part of agent-substrate#896, toward agent-substrate#550.
…substrate#937)

agent-substrate#914 got merged in
between the time I sent
agent-substrate#898 for review and its
submission.

agent-substrate#898 wasn't rebased, so tests broke.
…#929)

AGENTS.md points to README instructions for a gVisor patch that no
longer exist. Removes the stale pointer.
added configurable JWT authentication to ateapi.
…ods. (agent-substrate#947)

* Align ActorSnapshot/ActorSnapshotTag store methods to other CRUD
methods.
* Add common struct types for List<Type> methods to carry parameters and
returned pages.
* Sort methods in store interface to group methods for each resource.
* Remove pessimistic locking from ActorSnapshot/ActorSnapshotTag RPC
handlers.
* Align CreateActorSnapshotTag method to API guideline. It should take
just the resource, no extra top level field.
…ate#955)

RunContractTests is getting large and resource tests are not always
grouped together. So, let's start with splitting them into separate
functions. As they grow, we may consider moving them to their own files,
like we did for functional tests in
agent-substrate#918

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

- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
added TestActorEgressHTTPS e2e test
…gent-substrate#679)

Actors previously ran in sandboxes sized to the whole node; there was no
way to declare how much CPU/memory a given actor should get. This adds
an explicit, immutable sizing knob on the ActorTemplate and plumbs it
into the sandbox's OCI spec for both the gVisor and micro-VM runtimes.

**API** — `ActorTemplate.spec.resources`
(`*corev1.ResourceRequirements`). The `limits` size the sandbox and are
baked into the immutable spec; the CRD and generated code are
regenerated accordingly.

**internal/sizing** (shared by both runtimes) — new `SandboxSize` value
(`FromLimits` / `VCPUs` / `ApplyToOCISpec`). `ApplyToOCISpec` writes CPU
quota+period and the memory limit onto the OCI spec, and is a no-op when
neither dimension is set, so 0 means "unconstrained". `VCPUs` rounds
milliCPU up to whole
vCPUs.

**Plumbing** — ateapi reads the template limits (`actorResourceLimits` →
`tmpl.Spec.Resources`) and supplies `CpuMilli`/`MemoryBytes` over the
actor RPCs (ateapi → atelet → ateom); ateom applies them — gVisor via
the cgroup leaf (`runsc --cpu-num-from-quota` provisions the sentry vCPU
count), micro-VM via the guest VmConfig. The two fields are carried on
the proto messages.

**Scheduling** — worker capacity is taken from the WorkerPool's
per-worker limits and advertised on the Worker; the scheduler only
places an actor on a worker whose capacity >= the actor's declared
limits. A missing worker or actor
dimension is treated as unconstrained, so placement is never blocked by
absent data.

**Docs & demos** — document the model in `api-guide.md`; the counter,
sandbox, and micro-VM demos declare actor limits, and their WorkerPool
comments now describe the real model (worker limits size the worker pod
+ advertise scheduling capacity; the sandbox itself is sized by
`ActorTemplate.spec.resources`).

- [x] Tests pass (unit tests for sizing + scheduling; e2e suite in
`internal/e2e/suites/sizing` resumes an actor and asserts, via the probe
fixture's `/resources` endpoint, that the running sandbox observes the
declared CPU/memory from the inside)
- [x] Appropriate changes to documentation are included in the PR
…nt-substrate#809)

ateom runs inside the worker pod that hosts the actor, and exported OTLP
straight to the collector over the pod's network. This adds a node-local
relay: ateom pushes OTLP/gRPC over a unix socket that atelet serves and
forwards to the collector, so a worker pod needs no network path of its
own to export spans and metrics.

Four things motivate it:

- Blast radius. The pod runs untrusted agent code, so allowing it egress
to the collector makes the collector reachable to anything that escapes
the sandbox. A unix socket cannot leave the node.
- Connection count. Worker pods are heavily oversubscribed; N ateoms per
node each held their own collector connection. They collapse into
atelet's single per-node one.
- Interference. ateom transparently redirects actor egress to its own
atunnel listener, and its own outbound traffic has to stay clear of the
rules it installs. A unix socket is not IP traffic.
- Shutdown loss. Teardown frees the actor's network and then the pod
goes away, which is when the spans describing teardown are still queued
in the batch processor. atelet outlives the worker pod.

The relay forwards the OTLP request verbatim rather than decoding and
re-exporting, so each ateom's own resource (service.name,
service.instance.id) survives instead of being absorbed into atelet's.

It is best-effort: an ateom that finds no socket at startup logs it and
exports directly to OTEL_EXPORTER_OTLP_ENDPOINT as before, so this is a
no-op for a cluster running an older atelet. atelet likewise declines to
serve a relay when no collector is configured, since it would accept
spans only to drop them. Both halves stay off with
--otlp-relay-socket="".

The socket lives in ateompath.BasePath, the hostPath already mounted at
the same path into atelet and into every ateom pod, so no new volume or
controller change is needed.

Also includes:
  - End-to-end tests covering the full serverboot-to-collector path.
  - Observability documentation updates for Jaeger tracing.

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

`MergeSparseOverlay` ended with a rename onto an existing `outFile`.
Renaming over an existing file makes
[ext4](https://github.com/torvalds/linux/blob/v6.17/Documentation/admin-guide/ext4.rst#L317-L328)
(`data=ordered`) write back the renamed file's dirty pages, and the
scratch file carries the whole merged memory image, so the merge pays a
full flush of it. `MergeDeltaIntoBase` (see
[link](https://github.com/agent-substrate/substrate/blob/a4121a4ca438603481f1799c85928c78a4ba9a6a/cmd/ateom-microvm/internal/ch/merge.go#L150-L158))
already unlinks first for this reason. On the counter demo the merge
goes from ~1140 ms to ~115 ms.

This is a no-op today, `MergeSparseOverlay` only runs when base and
delta land on different filesystems. It becomes the common path in the
follow-up that routes hardlinked restores through it, and landing first
keeps that change from carrying a 1.1 s suspend regression.
added CONNECT support for atenet ingress to support arbitrary actor ports
The step went in preemptively alongside the micro-VM e2e (agent-substrate#327), not in
response to a run running out of space. Deleting `/usr/share/dotnet`,
`/usr/local/lib/android`, `/opt/ghc` and the CodeQL toolcache took
24-88s per matrix leg across the last few main runs, which is pure
wall-clock on every PR.

Easy to put back if a run does hit ENOSPC.
EnableIPv4Forwarding now also writes
/proc/sys/net/ipv6/conf/all/forwarding so actor IPv6 traffic (including
DNS queries) is routed between the actor veth and pod eth0 instead of
being dropped by ip6_forward() on dual-stack / IPv6-only clusters.

Factor the sysctl write into writeSysctlIfUnset preserving the original
read-only remount/restore behavior, and add unit coverage for its fast
paths.

Fixes: agent-substrate#945
IPv6 sysctls are absent on kernels with IPv6 disabled (e.g. some
containers set net.ipv6.conf.* only when IPv6 is enabled). Treat a
missing path as 'nothing to enable' instead of forcing a remount and
failing, matching the documented behavior.
This makes it clear(er) which methods of store.Interface are used in
which paths.

No functional changes

@juli4n @laoj2 as a first step towards splitting the validation logic
out of the RPC layer and out of the storage layer. This at least gives
us targets for what to pay attention to first.
…-substrate#953)

ate.actor.crashes, ate.actor.lifecycle.operation.duration and
ate.scheduler.assignment.duration named a WorkerPool by name alone. A
WorkerPool is namespaced, so same-named pools in different namespaces
merged into one series, and the three could not join the instruments
that already carry both keys.

ateattr.WorkerPoolAttributes now builds the pair, and omits both keys
when no pool is assigned, so a crash before the actor reached a worker
no longer reports an empty-string pool. This changes the series identity
of the three instruments.

Fixes agent-substrate#951

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

Fixes agent-substrate#608

`crashActor` collected the `releaseWorker` error but still marked the
actor `CRASHED` and cleared its `WorkerAssignment`, stranding the
still-assigned worker with no actor left to reclaim it. On a release
failure, return instead so the actor stays intact and the workflow's
retry reclaims the worker (`releaseWorker` is idempotent).

- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
The egress gateway's Envoy config lives as one inline string in the
atenet-egress ConfigMap, which Kustomize can replace but cannot patch
into, so the sdsmint variant is a whole second manifest rather than an
overlay. The flag picks between the two.

deploy_ate_system and deploy_atenet apply whichever manifest the flag
selects. delete_atenet deletes both, because teardown has to clean up an
install made under the other setting and either file may declare
resources the other does not.

atenet-egress-with-sdsmint.yaml starts as a copy of atenet-egress.yaml,
so the flag is a no-op until the two diverge.
…ate#772)

Fixes agent-substrate#609

The startup stored-worker scan (`enqueueStoredWorkers`) returned on the
first `ListWorkers` error, skipping orphan cleanup until the next
restart. The `agent-substrate#674` workqueue only retries per-key reconciles, not this
initial scan, so retry the scan with capped `wait.Backoff` until it
succeeds or the context is cancelled.

- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
IP_FAMILY selects ipv4, ipv6 or dual and becomes networking.ipFamily, leaving
kind's per-family subnet defaults alone. The script also recreates a pre-IPv6
"kind" Docker network, fails fast if the daemon has IPv6 off, sets proxy_ndp
alongside proxy_arp for gVisor pod-to-pod traffic, and repoints an ipv6
kubeconfig from [::1] at localhost so a client outside the Docker host can
still reach the apiserver.

Tested on kind with all three families: node InternalIPs, Service ClusterIPs
and pod IPs land in the requested families, pod-to-pod and CoreDNS work on
them, and pods still pull through the local registry.
sairajp-rewind and others added 29 commits August 18, 2026 20:40
…nce of DurDir (agent-substrate#907)

# Benchmark DurDir

Fixes agent-substrate#673

Adds a `DurDirUser` load-generation workload that exercises the
DurableDir
suspend/resume loop end to end, verifies every served byte against a
SHA-256
digest, and emits separable latency percentiles for each step.

## The loop

per VU, first iteration: create -> resume -> WriteDisk ->
ReadDisk+verify
steady state: suspend -> resume -> ReadDisk+verify (cold)
                                      -> ReadDisk+verify (warm)
                                      -> WriteDisk (overwrite, TRUNCATE)

Under `onCommit: Data` the container cold-boots from the OCI image and
process
memory is discarded, so a matching digest after resume can only have
come from
the restored DurableDir. That is the durability assertion.

## Results

All six scenarios ran on GKE/gvisor at 1 VU for 1m each: `Data` vs
`Full`
snapshot scope, explicit vs implicit resume, and a 5/10/64 MiB size
sweep.
**Zero failures across every run**, so every served byte matched its
digest in
all six.

**Snapshot growth over repeated overwrites:** `SuspendActor` latency
stayed flat
across consecutive overwrite-and-suspend cycles on the same DurableDir
volume.
The loop overwrites with `WRITE_MODE_TRUNCATE`, so the file is exactly X
bytes
after every write and the captured directory contents are the same size
every
cycle. Nothing accumulates across suspends. That is the growth question
the
issue asks about.

Latency percentiles per step are in the run artifacts.

## Change surface

- **glutton:** `WriteDisk` returns size + sha256; new `ReadDisk` with a
`READ_MODE_DIGEST_ONLY` mode for measuring restore cost without paying
wire
  transfer; disk RPCs exposed over HTTP mode.
- **manifests:** two new ActorTemplates, `glutton-durdir-{data,full}`,
with a
  `durableDir` volume and `onPause: Full` / `onCommit: {Data,Full}`.
- **boomer:** shared actor-lifecycle plumbing extracted from the ping
task, then
  a `DurDirUser` task on top of it; a general `resume_mode` knob.
- **harnesses:** `--workload` selects the task at deploy time;
`durdir.py` stub,
  typed dynconfig flags, six nightly scenarios.

The two boomer commits above are incremental extractions made as the
second
workload landed. The final package layout for
`internal/benchmarking/boomer`
lands as a follow-up PR.

## Testing

- `go build ./...`, `go test -race ./cmd/benchmarking/...
./internal/benchmarking/...`
  clean, no race warnings.
- `hack/verify-all.sh`: all nine checks pass. Python protos regenerated,
tree
  clean.
- Both ActorTemplates reach `Ready`; golden snapshots confirmed in the
bucket.
- **Manual durability proof:** 200 MiB payload written, suspended,
resumed, and
re-read with matching sha256 across every read. Process memory discarded
and
  container cold-booted in between.
- **Scale validation:** DurableDir persistence verified up to 1 GiB with
0
  failures.
- **Regression gate:** `glutton_baseline_5_users` ran 919 requests with
0
failures and 7 ms ping latency post-rebase. No regression on the
existing
  benchmark.

## Deliberately out of scope

- **Image size over time:** `ActorSnapshot` has no size field, so there
is
nothing for a client to read. The issue permits deferring this.
`SuspendActor`
latency is the available proxy; `atelet.snapshot.size` is the
server-side one.
- **boomer package restructure:** landing as a follow-up so this PR's
files stay
  reviewable in place.
- **RAM-backed variant:** glutton already has `WriteRAM`; small
follow-up.
…#1046)

#### benchmarking: right-size actor memory, default 256Mi

Benchmark `ActorTemplates` declared no resources, so microvm actors fell
through to the 2 GiB kata default guest — and everything the guest
kernel caches rides along in the memory snapshot, inflating snapshot
size and suspend/resume latency, which the benchmarks then measure.

Set `spec.resources.limits.memory` on the `glutton` and `sleep`
templates, parameterized as `ACTOR_MEMORY` with a `256Mi` default (the
smallest size microvm admits: 128Mi VMM reserve + 128Mi guest floor),
and thread it through the deploy chain:
- `--actor-memory` on `workloads/deploy.sh` and `deploy_locust.sh`
- `--benchmark-actor-memory` on `install-ate.sh`
- Optional per-test `actorMemory` field in the automation's `tests.yaml`
for future `WriteRAM` stress suites.
The in-memory version counter restarted at 1 on every router boot; if
Envoy reconnected still holding an identical version string the
snapshot cache saw a match and skipped the push, stranding Envoy on
pre-restart config. Versions now carry a per-process epoch (unix
seconds plus a random suffix) so no incarnation repeats an earlier
one's strings, even across clock jumps. Fixes agent-substrate#617.
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
)

Part of agent-substrate#212

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

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

## Summary

- add bounded, validated labels and annotations to
`WorkerPoolPodTemplate`
- propagate that metadata to the generated Deployment and worker pod
template
- reserve the controller-owned `ate.dev/worker-pool` label
- regenerate the WorkerPool CRD and deepcopy code
- add API validation, controller tests, and documentation

I chose intentionally to avoid exposing a complete `PodTemplateSpec` as
per the discussion in the agent-substrate#212.
…substrate#1075)

Fixes agent-substrate#957

`ate.actor.lifecycle.operation.duration` carried the pool pair on
suspend and pause only when they failed, so per-pool dashboards saw
those two operations exclusively as failures.

Both workflows record the histogram from a defer that reads the `actor`
variable, and the happy path reassigns it to the finalized record. The
finalize step commits the new state and the cleared `WorkerAssignment`
in one update, so the defer found no assignment and dropped both keys. A
failure returns the pre-finalize record, which still names the worker.

Both now snapshot `lifecycleOpAttrs(...)` just before the finalize step
— the same snapshot-before-clear crash.go does for the crash counter.
Paths that end earlier keep the current computation.

`delete` stays without a pool: it only runs from SUSPENDED or CRASHED,
which already released the worker, so there is none to name.

TestLifecycleOpPoolAttributesOnSuccess drives a real suspend and pause
through the gRPC service; both subtests fail without the fix.

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

Add an fd00:169:254::/126 point-to-point pair alongside the existing IPv4
addresses, with a matching ::/0 default route, and move the actor nftables
table from ip to inet so a single table carries both families. Each payload
match now guards on NFPROTO to stay off the other family's packets, and
teardown lists the inet family too: naming the wrong family there dumps
empty, takes the "already clean" path, and silently leaks the table.

Assign the IPv6 addresses with IFA_F_NODAD instead of writing the accept_dad
sysctl. The ateom container is unprivileged, so containerd mounts /proc/sys
read-only and the write failed with EROFS, taking SetupActorNetwork and every
actor start down with it on both sandbox classes. A root-gated assertion pins
the flag; the existing tests run as real root, where the sysctl is writable
and the bug is invisible.
…bled

An IPv4-only cluster leaves net.ipv6.conf.all.disable_ipv6=1 in the worker
pod netns, which is the default on IPv4-only GKE, and netlink there rejects
the veth's IPv6 address with EPERM. The assignment sits on the path of every
SetupActorNetwork call site, so actor startup went from working to failing
outright and the actor never left ResumeGoldenActor. Gate the IPv6 address
and default route on a per-link disable_ipv6 read, leaving the interior
IPv4-only on those clusters instead of failing.

A root-gated test covers a netns with IPv6 disabled; reverting the gate
reproduces the EPERM against it.
LinkIPv6Enabled answers whether the kernel will accept an IPv6 address on
a link, not whether the cluster routes IPv6. IPv4-only kind leaves
disable_ipv6 at 0, so the actor got fd00:169:254::2 and a ::/0 route it
could not use, Go's destination sorting preferred the AAAA of any
dual-stack host, and the egress fetch died mid-response -- the IPv4 e2e
job has been red since. Pair the capability read with a check that the
worker pod's own eth0 carries a global IPv6 address, and the actor stays
IPv4-only wherever the pod is.

Decide it once in the pod netns and pass it into ConfigureActorVeth. The
interior namespace is created fresh, so its own sysctl always said IPv6
was available whatever the pod's families were. Root-gated tests cover a
pod without IPv6 and a pod whose new links have IPv6 disabled per link;
dropping either half of the check reproduces its failure.
The actor table moved from the ip family to inet so one table can carry
both address families, but the teardown only ever listed inet. An nftables
table name is unique per family, so an ip table left behind by an earlier
ateom is invisible to every later cleanup and keeps redirecting alongside
the inet table installed next to it. Teardown now sweeps both.
podHasGlobalIPv6 gates the actor's IPv6 on the worker pod having a global
address of its own, and the doc comment reads as though that settles
whether the actor can reach anything. It does not: IsGlobalUnicast is true
for a ULA, and dual-stack kind hands pods a ULA with no path off the host.
Nothing runs a cluster carrying both address families, so two things go
unexercised: code that reads one address where there are two, and IPv4
continuing to work once IPv6 does. Neither single-family lane can see
either, because on each of them the one address present is the right one.

Opt-in rather than a gate, two ways. A pull request labelled ci/dual-stack
gets a run, forks included. A dispatch takes a list of pull requests and
merges them onto main first, because the changes that make IPv6 work do
nothing apart -- one publishes an AAAA, another gives the router an address
to publish -- so only a stack of them shows the feature working. The stack
lives for the length of the run, so there is no branch to rebase and no
provenance to hand-maintain. Part of agent-substrate#246.
Egress DNS was pinned to V4_ONLY at six sites across two manifests, so
an actor on an IPv6-only cluster could not resolve anything: the name
came back empty and the connection failed before Envoy ever dialled.

ALL rather than AUTO. Despite the name, AUTO is a legacy alias for "V6
preferred" and does not fall back, so a name published with both an A
and an unroutable AAAA -- the shape a dual-stack kind cluster produces
-- makes exactly one connection attempt, to the AAAA, and 503s when it
times out. ALL resolves both families and lets Happy Eyeballs pick the
one that works.
The egress dns_lookup_family is set at six sites across two manifests
and no Go test reads either file, so a seventh site added without it,
or one of the six reverted, would pass CI and only surface as an actor
that cannot resolve anything on a single-family cluster.

Fails on a V4_ONLY, V6_ONLY or AUTO value anywhere under
manifests/ate-install, and on a dns_cache_config that leaves the family
unset. Every manifest is scanned rather than the two that carry egress
clusters today, so a new variant of atenet-egress.yaml is covered the
day it is added.
@ygao-g ygao-g added the ci/dual-stack opt-in dual-stack kind e2e lane label Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/dual-stack opt-in dual-stack kind e2e lane

Projects

None yet

Development

Successfully merging this pull request may close these issues.