## Summary
A follow up to #640 where we introduced PostgreSQL as an alternative
storage backend, selected conditionally in ateapi.
- Deleted ateredis, its tests, and its dependencies
- Removed Redis backend selection and configuration so ateapi always
connects to Postgres
- Replaced Valkey resources with Postgres in the standard and Kind
deployment paths and simplified install script
- Replaced miniredis fixtures with isolated Postgres testcontainers and
added centralized helpers for seeding resources
- Renamed Redis-specific debug flush command to backend-neutral
`debug-clear-store` in CLI
- Updated comments and docs where applicable
## Benchmarking
Extensive benchmarking have been performed to evaluate Redis vs
Postgres, and results can be found in these two documents:
-
https://docs.google.com/document/d/10K0wB6aTeFkJCL4HN3NbLJCdFGoLYdhIcqkFnqFHkKc/edit?usp=sharing
-
https://docs.google.com/document/d/12-ko_BFHcBo_nJkx9f4B7zMbiiWKC2saGhMhZG3aQ-s/edit?usp=sharing
---------
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
Having been reading this code for a while, and in light of changes we
want to make, I think something like this will help clarify.
I am not particularly attached to the name or style `RpcService` - it's
not usual Go convention but it is protobuf style. If anyone feels
strongly it could be `RPCService` or `ControlService` or something else.
@juli4n @laoj2
This needs to be a property of the storage layer. It is in PG but not in
redis. This commit moves it out of the store interface and down into
redis. It's still not CORRECT but it constrains it.
This requires a lot of tests to create the atespace before creating
other resources. These tests are wrong in the face of a correct storage
layer, anyway.
xref #989
* Removed field_mask from the API
* Added a new protoupdate package to handle replacing mutable fields.
This makes sure that unknown fields in the server are not dropped by an
update from a stale/old client.
#1011
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Part of #731 and follow up to #640. This PR makes improvements to the
Postgres store by addressing review comments on #940 and optimizing
Postgres updates as suggested in #988.
- Replaces Postgres row-locking updates for actors, templates, and
snapshot tags with bounded optimistic concurrency control using
UID/version CAS checks.
- Strengthens relational integrity with missing FKs and indexes, and
replace snapshot-tag creation pre-check with FK
- Removes unnecessary UID uniqueness constraints
- Standardize pagination and validation across stores, map malformed
tokens and invalid sizes to appropriate gRPC errors
- Improves actor resume race handling when workers disappear or
concurrent assignments exhaust their retry budget
- Improves reliability with startup retries, expired-lease cleanup,
worker-watch reconnection after malformed msg
- Expand Postgres, Redis, Contract, and Control API tests to cover new
constraints, error semantics, concurrency
---------
Signed-off-by: Jet Chiang <pokyuen.jetchiang-ext@solo.io>
The test calls `setupTest`, `namespaceForTest` and `assertGrpcError`,
which are defined in the `functionaltest` package, but the file landed
in `controlapi` declaring package `controlapi`, so the `controlapi` test
binary did not build.
This code passed on the PR branch, but it logically conflicted with a
previous refactor without merge conflict.
> 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
Part of #477.
Changes
* Implement ActorTemplate CRUD in the control API
* Added a shared generic ResourceRef behind the template and actor ref
types
Relevant:
* Following style guidances in
https://github.com/agent-substrate/substrate/issues/891
Part of #932 (PR 1 of 3). Adds the user-declarable trustBundle data
source for SystemInfo volumes (#802) and the end-to-end proof that the
projected anchors work against the MITM egress gateway. Live refresh for
running actors (PR 2) and auto-injection (PR 3) come separately.
What this adds
A SystemInfo volume data source that projects the trust anchors of a
named trust bundle to a PEM file:
volumes:
- name: trust
systemInfo:
dataSources:
- trustBundle:
name: egress-mitm.ate.dev
path: egress-ca.pem
Inspired by the Kubernetes clusterTrustBundle projected volume source,
but source-neutral: the template names a bundle; where it's fetched from
is a deployment concern, not part of the API.
Design points
- Resolution lives on the node. The wire carries only {name, path};
atelet resolves the name at write time through an informer-backed lister
on ClusterTrustBundles and writes the sanitized PEM with the temp+rename
discipline from #803 (find-paths safe). Contents refresh on every
Run/Restore. ateapi is not involved, per review discussion — the same
informer is what live refresh (PR 2) will hang off.
- Allowlist in atelet, not the CRD schema. Today only
egress-mitm.ate.dev (the egress gateway CA bundle, #823), mapped to the
ClusterTrustBundle that atecontroller's EgressMITMTrustReconciler (#946)
derives from the egress-mitm-ca-pool Secret. The signer-linked object
name stays a backend detail; the future backend registry (#932) widens
the allowlist without an API change.
- The watch is scoped to the one backing object via a metadata.name
field selector — this informer runs on every node, so an unfiltered
watch would fan every ClusterTrustBundle in the cluster out to every
atelet. RBAC can't express this (resourceNames doesn't apply to
list/watch), so the field selector is the enforcement point.
get/list/watch on clustertrustbundles moves to the atelet ClusterRole.
- No availability probe. The informer registers unconditionally; a
cluster that doesn't serve the feature-gated certificates.k8s.io/v1beta1
blocks atelet startup at cache sync, with the reflector errors naming
the missing API (hack/create-kind-cluster.sh enables the gate).
- Fail-closed. Unknown names, missing bundles, and unusable bundles fail
actor start naming the bundle — an actor that declared a trust bundle
must not start without one.
- Kubelet-parity sanitization (internal/pemutil): CERTIFICATE blocks
only, deduplicated, headers stripped, and anchors deliberately shuffled
so consumers can't grow a dependence on order.
- Schema note: dataSources MaxItems tightened 32→8 while adding the
trustBundle member. Vacuous in practice (the old schema couldn't admit
more than one entry), but flagged since it's ratchet-shaped.
E2E — delivery and consumption
Delivery (identity suite, both sandbox classes): provisions the
egress-mitm-ca-pool Secret and drives the real #946 reconciler (writing
the bundle directly isn't possible — the reconciler reverts hand-edits),
asserts the projected file byte-exact, then rotates the pool across a
suspend/resume to prove refresh-on-restore. Since the probe fixture is
shared and fail-closed, e2e.DeployProbe itself ensures the bundle exists
for whatever suite deploys it.
Consumption (new egressmitm suite, both sandbox classes): deploys the
sdsmint (MITM) egress gateway and proves an actor completes a TLS
handshake with the gateway's per-SNI minted leaf using ONLY the
projected anchors — plus a system-roots negative control that must fail.
The pair is unambiguous in both directions: the positive can't pass
under passthrough (the bundle holds no public CAs), and the negative
can't fail under passthrough.
CI: two steps appended to the existing e2e job after the standard lanes
(the gateway swap is cluster-wide and breaks passthrough assumptions):
--deploy-atenet --experimental-use-sdsmint redeploys only the atenet
components, then the egressmitm suite runs once per sandbox class.
Flake mitigation: the probe fixture pool drops from 3 workers to 2. Each
suite deploys its own copy and drives one actor at a time, so the third
worker per copy was idle memory multiplied across suites on the one-node
CI cluster — pressure that has been killing sandboxes mid-test (runsc:
signal: killed, a vanished ateom socket) on this PR and on main's
identity suite. This reduces the pressure; right-sizing e2e concurrency
or worker-pod QoS cluster-wide is follow-up material.
Not in this PR
- Live refresh for running actors (#932 PR 2) — until then, a running
actor's file is the bundle as of its last Run/Restore, and correctness
rests on overlap rotation by the bundle publisher.
- Auto-injection of the egress trust volume (#932 PR 3).
- Configurable backend registry (#932) — the allowlist is the seam it
will replace.
Fixes#643
This feature introduces the ability to delete Substrate actors from any
lifecycle state (e.g., RUNNING, PAUSED, PENDING), rather than requiring
them to be hibernated into SUSPENDED (or CRASHED) beforehand. This is
crucial for cleaning up stuck or failed actors.
#### 1. Control Plane & gRPC API (pkg/proto/ateapipb, ateapi)
• DeleteActorRequest.any_state: Added a new boolean field any_state to
DeleteActorRequest.
• When false (default): enforces the existing behavior where only actors
in ACTOR_STATE_SUSPENDED or ACTOR_STATE_CRASHED (or already
ACTOR_STATE_DELETING) can be deleted.
• When true: allows deleting an actor in any state (e.g., RUNNING,
PAUSED).
• Orchestrated Cleanup Workflow (workflow_delete.go):
1. Transitions actor state to ACTOR_STATE_DELETING.
2. Calls atelet.Terminate to stop live workloads on the node.
3. Detaches external volumes from the worker node (with fallback logic
using actor status if the ActorTemplate was deleted).
4. Releases the assigned physical worker Pod back to the WorkerPool.
5. Deletes external storage volumes through the storage plugins.
6. Finalizes removal of the actor from the persistence store.
#### 2. Node Herder Agent (atelet)
• Terminate RPC (main.go): Added a new Terminate method to AteomHerder:
• Calls ateom.TerminateWorkload on the target worker pod.
• Unmounts external volumes mounted for the actor on the node.
• Cleans up and resets actor runtime host directories (OCI bundles,
checkpoints, pid files).
#### 3. In-Pod Sandboxes (ateom-gvisor & ateom-microvm)
• TerminateWorkload RPC:
• gVisor (main.go): Stops and deletes active runsc containers, unmounts
bundle rootfs overlays, and tears down the actor's interior network
namespace.
• Micro-VM (checkpoint.go): Shuts down the Cloud Hypervisor VMM,
unmounts bundle rootfs overlays, and cleans up the network namespace.
• Emits an "Actor terminated" lifecycle log and clears the active actor
attribution so the worker can host new workloads.
#### 4. CLI (kubectl-ate)
• --any-state Flag (delete_actor.go):
kubectl-ate delete actor <actor-name> -a <atespace> --any-state
──────
## Verification
[x] Changes tested in local with
```
• go build ./...: Passed
go test ./...: Passed (all unit and functional tests pass)
make lint: Passed (no lint errors)
```
Detailed manual tests
```
### Scenario 1: Force Delete a Running Actor with --any-state
1. Create the actor:
kubectl ate create actor manual-test-1 --template=ate-demo-counter-microvm/counter-microvm -a demo
2. Resume the actor:
kubectl ate resume actor manual-test-1 -a demo
3. Force delete the running actor:
kubectl ate delete actor manual-test-1 -a demo --any-state
actor "manual-test-1" deleted
```
──────
### Scenario 2: Standard Delete a Suspended Actor
```
1. Create the actor:
kubectl ate create actor manual-test-2 --template=ate-demo-counter-microvm/counter-microvm -a demo
2. Resume the actor:
kubectl ate resume actor manual-test-2 -a demo
3. Suspend the actor:
kubectl ate suspend actor manual-test-2 -a demo
4. Standard delete the suspended actor:
kubectl ate delete actor manual-test-2 -a demo
actor "manual-test-2" deleted
```
──────
### Unit & E2E Tests
# Unit & Functional Tests
go test ./cmd/ateapi/internal/controlapi -run
"TestDeleteActorWorkflow|TestEnsureMarkedDeleting"
# E2E Tests
E2E_TEMPLATE_NAMESPACE=ate-demo-counter-microvm
E2E_TEMPLATE_NAME=counter-microvm ./hack/run-e2e-kind.sh
./internal/e2e/suites/demo -run TestActorLifecycle
./hack/run-e2e-kind.sh ./internal/e2e/suites/demo -run
TestForceDeleteActorWithExternalVolume
```
- [ x] Appropriate changes to documentation are included in the PR
Fixes#783
Adds an `image` source to `ActorTemplate`'s `VolumeSource`: a container
can mount the contents of an OCI image it does not run. This is how
tooling gets into images built by third parties without rebuilding them.
```yaml
spec:
containers:
- name: sandbox
image: docker.io/example/benchmark@sha256:...
command: ["/ate/agent"]
volumeMounts:
- name: agent
mountPath: /ate
volumes:
- name: agent
image:
reference: registry.example.com/agent@sha256:...
```
## How it works
atelet pulls the image through the existing layer cache and records the
volume's layers in the bundle's overlay spec, next to the rootfs layers.
ateom composes the volume inside the bundle — the cached layers with no
writable layer on top, so the mount is read-only — and the container
binds it at the declared path. The volume is composed per container:
containers of one actor may mount the same volume, and each gets its own
mount point inside its own bundle, all backed by the same shared layers.
On resume the volume is re-composed the same way.
References must be digest-pinned, the same rule as container images: a
snapshot is only valid against the exact bytes it was taken with.
On micro-VMs the volume rides the same read-only virtio-fs share as the
container rootfs: ateom stages each composed volume beside the rootfs on
the host, and the guest binds it into the container at the declared
path.
Fixes#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
Part of #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 #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>
Make workers a gobal resource, identified by a unique name. Worker APIs
will be implemented in a follow up change.
Part of #730
- [ x ] Tests pass
- [ x ] Appropriate changes to documentation are included in the PR
Finishes up the vision from #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>
functional_test.go was also split into one file per resource, to match
what we're doing for the RPC handlers too. See #891
I also needed to make some changes to move the tests to a separate
package:
* `NewAteletDialer` now takes `options`, and `WithDialCredentials` lets
a test build its own transport credentials. The fake atelet is reached
over insecure transport. Didn't change existing callers. This is the
only non-test change.
* Moved a few unit tests that are actually functional tests to the
respective file under functionaltest/
Fixes#891
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
ResumeActor returned `FailedPrecondition` both when the fleet had no
free worker and when the actor was not in a resumable state. Only the
former is fixable by suspending the actor to drop its node pinning, and
a client driving that recovery could not tell the two apart.
`assignWorkerAttempt()` now returns `ResourceExhausted` for
`scheduling.ErrNoCapacity`. `FailedPrecondition` was the de-facto
capacity signal, so three router consumers move with it:
- `retryable()`: `ResourceExhausted` is parked, so saturation behaves as
before. `FailedPrecondition` stays retryable on purpose. It no longer
carries saturation, but it does cover states a concurrent operation can
move the actor out of.
- `mapResumeError()`: `ResourceExhausted` now preserves the gRPC
description, keeping saturation at 503 as today. Its former 429 "rate
limited" branch was written speculatively as nothing in substrate
produced the code until now, and the fleet being full is not the caller
sending too many requests.
- `classifyOutcome()`: `no_capacity` moves to `ResourceExhausted`, and
`FailedPrecondition` gets its own label so the capacity bucket stops
collecting non-capacity errors.
`TestResumeActor_RelocatesAfterSuspendFromPaused` covers the recovery
flow this signal exists for: a PAUSED actor pinned to a full node cannot
resume, and after a suspend clears the pinning it schedules onto a
worker on another node.
Fixes#660
- `make test` clean (except the pre-existing macOS-only
`internal/atunnel` failures, untouched by this PR)
- `make fmt` clean
- `go vet ./cmd/...` clean
Fixes#609
The startup stored-worker scan (`enqueueStoredWorkers`) returned on the
first `ListWorkers` error, skipping orphan cleanup until the next
restart. The `#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
Fixes#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
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#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
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.
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
* 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.
* 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 #912 for a more detailed description of the rationale for this
change. Fixes#912
#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
No declaration added, removed, or renamed, import sets unchanged, and
every non-header line identical to what it replaced.
https://github.com/agent-substrate/substrate/issues/891
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
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 #891
- [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 a very similar fix to #829
We're also removing the precondition as the storage layer function
arguments and passing it as a wrapper around the closure functions.
https://github.com/agent-substrate/substrate/issues/763
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
This reverts the ActorTemplate valueFrom.secretKeyRef support added in
#20 (issue #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.
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.
The pause image holds the sandbox's namespaces and runs no workload
code. It is an implementation detail of the sandbox, not something actor
authors pick, so it belongs with the sandbox binaries that already moved
off the ActorTemplate onto the cluster-scoped SandboxConfig.
It now travels with those binaries end to end: resolved from the pool's
SandboxConfig, carried on ateletpb.SandboxAssets rather than
WorkloadSpec, and recorded in the per-actor sandbox record so Checkpoint
pins it into the snapshot manifest and Restore rebuilds the sandbox from
the image the snapshot was taken with (the golden's on a DATA_ON_GOLDEN
restore). A record without one is rejected outright rather than pulling
an empty image.
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Part of #791 — the last planned piece of the [implementation
plan](https://github.com/agent-substrate/substrate/issues/791#issuecomment-5226674097)
(after #810, #812, #813). #791 stays open until #817 (gVisor
Full-capture → Data-commit conversion, blocked on #790) is done; this PR
covers micro-VM fully and gVisor for scope-matched suspends. Also part
of the actor state machine (#119) and a prerequisite for system upgrade
flows (#473).
`SuspendActor` now accepts a PAUSED actor: instead of checkpointing a
running workload, ateapi dials the atelet on the node holding the pause
snapshot and has it upload the node-local files to object storage, then
finalizes as usual — durable `ActorSnapshot`, `SUSPENDED` status, node
pinning cleared.
Two commits, reviewable independently:
## Commit 1 — atelet: `UploadPausedCheckpoint` RPC (dead code until
commit 2)
- New `AteomHerder` RPC: a pure disk→object-storage copy driven by the
snapshot's self-describing manifest — no ateom involved (the sandbox is
gone).
- **Scope conversion** dispatches per sandbox class
(`narrowFullCaptureToData`): a micro-VM FULL capture narrows to a DATA
upload by carving out `durable-dir.tar` (constant hoisted to
`ateompath`, shared with ateom-microvm); gVisor returns `Unimplemented`
until split checkpoints land (#790); DATA can never widen to FULL; a
scope-less manifest (older atelet) is rejected rather than guessed at.
- **Idempotent retry**: local files gone + remote manifest present ⇒ a
previous invocation committed, succeed; gone on both sides ⇒
unrecoverable (`LOCAL_SNAPSHOT_GONE`, crashes the actor). Upload
failures stay plain retryable errors; the manifest uploads last as the
commit marker, never in parallel.
- The golden atespace is rejected at validation (fully on the
`field.ErrorList` framework): golden actors are never paused.
## Commit 2 — control plane: enable suspend from PAUSED
- `FromPaused` discriminator: PAUSED status, or SUSPENDING with no
worker assignment and a `LocalSnapshotInfo` — the field alone is stale
on resumed-from-pause RUNNING actors, so the nil-assignment conjunct is
load-bearing.
- `MarkSuspendingStep` accepts PAUSED and rejects a Data-captured pause
against a Full commit *before* the actor leaves PAUSED (an upload cannot
fabricate memory), using the `content_scope` recorded at pause (#812)
with an `onPause` fallback.
- `CallAteletSuspendStep` paused branch dials by node
(`DialForAteletOnNode`, #813): missing node record ⇒ crash (the snapshot
can never be found); unreachable atelet ⇒ retryable; atelet's
`LOCAL_SNAPSHOT_GONE` ⇒ crash via `maybeCrashActor`.
`FinalizeSuspendedStep` needed no changes thanks to the #813 hoist.
- Root-cause guard: `MarkPausingStep` rejects pausing golden-atespace
actors.
- Docs: pause states + the new `PAUSED → SUSPENDING` edge in the
architecture state diagram; glossary Suspend entry covers both origins.
## Tests
- **atelet unit**: 10 upload-helper subtests (conversion matrix,
idempotency probe, data-loss crash, retryable upload failure) via a
recording object-storage fake; validation table.
- **control-plane unit**: discriminator table, scope-rejection table
(incl. onPause fallback), paused preconditions (no node ⇒ CRASHED, no
atelet ⇒ `ErrNoAteletOnNode` + still SUSPENDING), golden-pause
rejection, prerequisite matrix updated.
- **functional (envtest)**: `TestSuspendActor_FromPaused` (upload called
with the pause snapshot name, no Checkpoint RPC, SUSPENDED, pinning
cleared, ActorSnapshot at the upload destination) +
retry-after-failed-upload (same destination on retry).
- **e2e (demo suite)**: the lifecycle driver gains a suspend-from-PAUSED
mode; three durable-dir cases — Full/Full, Data/Data, and the micro-VM
Full→Data extraction — assert memory/file counters survive the full
pause→suspend→resume journey and the node pinning is gone.
`go test -race ./...` clean (except the pre-existing macOS-only
`internal/atunnel` unix-socket-path failures, untouched by this PR),
`gofmt`/`go vet` clean, protos regenerated via `hack/protoc.sh`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
UpdateActor now takes an ActorRef, an ActorPrecondition, and a mutate
callback. The store reads the stored actor inside the WATCH transaction,
checks the precondition against that value, and hands it to mutate,
which edits it in place.
The storage layer retries up to 5 times when a concurrent write
invalidates it, re-running mutate against the newer state
I still need to migrate the other callers of store.UpdateActor to avoid
calling GetActor outside of the closure function.
#763
> It's a good idea to open an issue first for discussion.
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Fixes#554
Each workflow becomes a plain method orchestrating typed, idempotent
ensureX steps, e.g.:
```go
func (w *ActorWorkflow) ResumeActor(ctx, ref, boot) (*Actor, bool, error) {
// acquire actor lock, metrics defer — unchanged
actor, tmpl, snapSrc, err := w.loadActorForResume(ctx, ref, boot)
if actor.Status == RUNNING { return actor, false, nil } // explicit no-op
actor, err = w.ensureVolumesCreated(ctx, actor, tmpl)
actor, worker, err = w.ensureWorkerAssigned(ctx, actor, tmpl)
err = w.ensureVolumesAttached(ctx, actor, worker, tmpl)
tele, err = w.ensureAteletRestored(ctx, actor, worker, tmpl, snapSrc)
actor, err = w.finalizeRunning(ctx, ref)
return actor, true, nil
}
```
Each `ensureX`
- early-returns if its postcondition already holds, which is derived
from persisted state only, replacing `IsComplete`
- validates the state machine edge internally, replacing
`CheckPrerequisite`
- takes explicit typed inputs and returns what it produced.
- persists what it changed before returning, returning the stored copy
so the optimistic lock version threads forward. No shared
`*ResumeState`.
There is one behavioral change: worker ownership/eligibility validation
for a re-entered resume now runs before volumes are attached (it used to
run in `CallAteletRestoreStep`'s prerequisite, after attach), so a
resume that is about to crash on a reassigned/ineligible worker no
longer attaches volumes to it first.
- [X] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
Part of #791 (PR 3 of the [implementation
plan](https://github.com/agent-substrate/substrate/issues/791#issuecomment-5226674097)).
Two behavior-preserving refactors needed before suspending a PAUSED
actor, which has no worker assignment:
**Dialer** — extract `dialAteletOnNode` from `DialForWorker`'s tail
(byNode index lookup → single-atelet check → per-pod-UID connection
cache → pinned-mTLS dial) and expose `DialForNode(nodeName)` for actors
whose state is pinned to a node without a worker pod (a PAUSED actor's
local snapshot). A node with no atelet in the informer cache returns the
new sentinel `ErrNoAteletOnNode` — documented as retryable (atelet
restarting / informer lag), distinct from the crash-worthy
`ErrWorkerPodNotFound`. `DialForWorker` behavior is unchanged apart from
error wrapping in the zero-atelets case.
**FinalizeSuspendedStep** — hoist finalization (ActorSnapshot record,
`LatestSnapshot`, the `SUSPENDED` flip, clearing
`InProgressSnapshot`/`WorkerAssignment`/`LocalSnapshotInfo`) out of the
`if assignment != nil` worker-freeing branch so it always runs.
Previously, a SUSPENDING actor with no assignment was silently skipped —
the workflow reported success while the actor stayed SUSPENDING forever.
Unreachable today (`CallAteletSuspendStep`'s prerequisite crashes
nil-assignment actors first), but load-bearing for the paused-origin
suspend, where no worker exists by design.
Tests:
- `TestDialForNode`: no atelet on node → `ErrNoAteletOnNode`; two
atelets → error; happy path asserts pod-UID-pinned credentials and
connection caching.
- `TestFinalizeSuspendedStep_NoAssignment`: SUSPENDING actor with
`LocalSnapshotInfo` and an in-progress URI but no assignment →
SUSPENDED, ActorSnapshot created at the URI, `LatestSnapshot` set,
in-progress fields and `LocalSnapshotInfo` cleared.
`go test -race ./cmd/ateapi/...` (incl. the functional suite covering
the running-path suspend end-to-end), `go vet`, `gofmt` clean.
Independent of #810 and #812.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#773
Some improvements to the way we create, track and propagate snapshot
ids.
* Consolidate snapshot id and name into a single concept.
* Use consistent terminology across the whole stack (i.e. remove
confusion about prefix vs id vs name).
* Get rid of internal only storage to hide the snapshot URI.
Part of #791 (PR 2 of the [implementation
plan](https://github.com/agent-substrate/substrate/issues/791#issuecomment-5226674097)).
Adds `LocalSnapshotInfo.content_scope` and records the template's
`onPause` scope in `FinalizePausedStep`. A later suspend of a PAUSED
actor needs to know what the local pause snapshot actually captured, to
validate the requested scope conversion *before* the actor leaves PAUSED
— even if the template's `onPause` is edited while the actor sits
paused. The snapshot's on-node manifest (#810) remains the authoritative
record at upload time; this control-plane copy is for early validation
only.
- `ateapipb` proto: `SnapshotContentScope content_scope = 3` on
`LocalSnapshotInfo` + regen
- `FinalizePausedStep`: sets `ContentScope` from
`SnapshotsConfig.OnPause` when recording `LocalSnapshotInfo`
- Unset for actors paused before this field existed (consumers fall back
to the template's `onPause`)
Tests:
- New `TestFinalizePausedStep_RecordsContentScope` (OnPause
Data/Full/unset→Full, through a real store with a worker)
- `TestFinalizePausedStep_WorkerGone` now seeds the template the step
reads
- `TestPauseActor`: expected `ContentScope` added — which exposed a
latent test bug: the custom `snapshot_prefix` comparer was asymmetric
(`strings.HasPrefix(y, x)` only); go-cmp requires Comparers to be
symmetric and probes them with swapped arguments, and the new field
shifted the comparison order so the probe finally landed on it. Fixed by
matching the prefix in both directions.
Verified end-to-end on a GKE deployment: a paused actor's Valkey record
now carries `"contentScope": "SNAPSHOT_CONTENT_SCOPE_FULL"` in
`localSnapshotInfo`.
`go test -race ./cmd/ateapi/...`, `go vet`, `gofmt` clean. Independent
of #810; no dependencies.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`setup-envtest use` with no version re-resolves "latest" against the
remote release index on every run, so the envtest-backed packages needed
network even with fully cached binaries.
This PR pins the control plane to `1.36.x`, and fold the three
per-package `envtest` into `internal/testenv.Start`, which also honors
`-short`: `go test -short ./...` skips the envtest-backed packages and
is the guaranteed-offline path.
Have Claude to help me fix it
Fixes#789
- [ ] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
Closes#706
## Summary
- broker short-lived actor certificates from atelet over a same-node
mTLS Unix socket
- keep the actor private key in atunnel and renew the certificate before
expiry
- authenticate egress CONNECT using the actor certificate instead of
bearer tokens
## Testing
- `make verify`
- `go test -race ./internal/atunnel`
This PR implements the last two metrics from #433.
Right now, we can see that a resume was slow but not where.
`ate.actor.lifecycle.operation.duration` covers the whole ateapi
operation, and `atenet.router.route.duration` covers the edge, but
everything between ateapi-atelet-actors is one block that contains
fetching the manifest, downloading the snapshot, unpack the OCI image,
call to ateom.
We have `rpc.server.call.duration` that gives us the atelet restore
total time, but template, kind, and scope labels are missing, so today
we cannot really pinpoint why/where we have a regressions in latency.
In this PR I am adding per-phase histograms, here's an example of what
we can know after these changes:
```console
ateom_restore 522 ms ###############################
download 8.9 ms #
manifest_fetch 3.2 ms
oci_unpack 2.6 ms
---------------------------------------------------------
total 535 ms
```
It also fixes a gap #683 opened where a `data_on_golden` resume was
labeled identically to a plain one on the lifecycle histogram.
Things folks might want to argue with:
- total as a phase value. Partly duplicates `rpc.server.call.duration`,
but that one has no domain labels and gRPC-specifc. We can drop it, but
it's an inferior operational UX, so I'd rather have it here.
- Phases overlap, they are not a partition of total, because download
runs concurrently with the asset fetch and unpack. I called this out in
the metric description. Do not sum across phases.
- New `ate.snapshot.scope` key rather than a new `ate.snapshot.kind`
value for `data_on_golden`. A new value would collapse local and
external into one bucket, which is the biggest latency difference there
is. This does add a label to the already shipped lifecycle histogram.
Verified on kind, and all e2e suites pass, and the emitted series cover
every kind (golden, latest, local) on both metrics with no unknown
values.
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
Part of #564 (Part 3)
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR
## Description
This PR implements Part 3 of #564 by adding telemetry histogram
instrumentation for `ate.scheduler.eligible_workers` in `ateapi`. It
measures unassigned free worker capacity remaining after all scheduling
constraint filters are applied, sampled at every scheduling decision.
## Key Changes:
* Updated `Scheduler.Schedule()` to record eligible candidate workers
per pool (`recordEligibleWorkers`).
Defined `SchedulingConstraintKey` (`ate.scheduling.constraint`) and
constraint classification values (`none`, `required_nodes`, `selector`)
in `internal/ateattr/ateattr.go`.
* Added unit tests in `scheduling_test.go` covering candidate counts,
namespaced attributes, zero-capacity fleet states, empty fleets, sandbox
class mismatches, draining workers, and constraint classifications.
## 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/...`
Fixes#732
* `UpdateActorSnapshot` now carries the resource itself + `update_mask`
* `scope` is now applied via the update mask
* Moved `update_mask` to a separate file, so it can be reused by other
update RPCs.
* Added `uid` and `version` as optional guards.
- [x] Tests pass
- [x] Appropriate changes to documentation are included in the PR