Skip to content

Commit 3cc2046

Browse files
committed
docs: add ADR-021 snapshot agent run isolation
Signed-off-by: Alex Yuskauskas <ayuskauskas@nvidia.com>
1 parent 7ba3ac9 commit 3cc2046

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# ADR-021: Snapshot Agent Run Isolation
2+
3+
## Status
4+
5+
**Proposed** — 2026-08-21. Addresses
6+
[#2120](https://github.com/NVIDIA/aicr/issues/2120).
7+
8+
## Decision Summary
9+
10+
Every `aicr snapshot` and `aicr validate` invocation generates a run ID and
11+
suffixes it onto every Kubernetes object it creates. User-supplied resource
12+
names are **prefixes**, never exact names, so no object aicr creates can collide
13+
with another run's.
14+
15+
Object lifecycles fall into exactly three classes — **run-owned**, **ensured**,
16+
and **delivered** — and every object the snapshot agent touches belongs to one.
17+
18+
## Context
19+
20+
`Client.CollectSnapshot` documents concurrent calls as safe and independent
21+
(`pkg/client/v1/aicr.go:1834-1836`). That promise is currently false: every run
22+
builds the same fixed object names, and the paths that create them are
23+
destructive.
24+
25+
`pkg/validator` already solved this for validation Jobs. It generates a per-run
26+
ID (`pkg/validator/v1/job_plan.go:107`), derives resource names from it
27+
(`pkg/validator/job/rbac.go:42,49`), and labels resources with `labels.RunID`
28+
already `aicr.run/run-id` (`pkg/validator/labels/labels.go:30`,
29+
`pkg/header/header.go:36`). The snapshot agent in `pkg/k8s/agent` predates that
30+
work and never adopted it. This ADR extends the existing pattern rather than
31+
introducing a new one.
32+
33+
## Problem
34+
35+
| Resource | Name today | Create path | Failure mode |
36+
|---|---|---|---|
37+
| Job | `config.JobName` (default `aicr`) | `job.go:35-55` | Deletes any existing Job of that name, waits, recreates. Run B kills run A's Job. |
38+
| ServiceAccount | `config.ServiceAccountName` (default `aicr`) | `rbac.go:81-93` | `IgnoreAlreadyExists` — shared. Cleanup deletes it, invalidating run B's projected token mid-flight. |
39+
| Role / RoleBinding | `config.ServiceAccountName` | `rbac.go:95-160` | create-or-update, then deleted by either run's cleanup. The Role grants `configmaps: create/get/update/patch` — what the agent needs to stage its result. |
40+
| ClusterRole / ClusterRoleBinding | `aicr-node-reader` | `rbac.go:162-264` | create-or-update with a rule set conditioned on `DiscoverNetwork` (`rbac.go:196-199`). A discovery run grants `pods/exec` and `nodes/patch` to a non-discovery run's identity; a non-discovery run revokes them mid-flight. |
41+
| Staging ConfigMap | `cm://<ns>/aicr-snapshot` | in-pod agent | Both runs write the same key; the winner's bytes are returned to both callers. |
42+
| Pod discovery | `app.kubernetes.io/name=aicr`, youngest live pod | `wait.go:111-149` | Logs stream from the other run's pod. |
43+
44+
Three further properties of the current code shape the decision:
45+
46+
1. **Cleanup is name-scoped and runs on the failure path.**
47+
`pkg/snapshotter/agent.go:196-207` registers the deferred `Cleanup` before
48+
`Deploy` at `:211`; `deployer.go:111-121` builds its delete list from
49+
configured names with no ownership predicate.
50+
2. **`aicr validate` races `aicr snapshot`.** Both default
51+
`--service-account-name` to `aicr` (`cli/snapshot.go:338`,
52+
`cli/validate.go:459`) and both create `aicr-node-reader`, so the two commands
53+
collide even though their Job names differ.
54+
3. **The internal staging ConfigMap is never cleaned up**, by explicit comment in
55+
`pkg/snapshotter/agent.go`.
56+
57+
## Decision
58+
59+
### 1. Reuse the existing run-ID generator
60+
61+
`GenerateRunID()` produces `<UTC timestamp>-<16 hex>`, e.g.
62+
`20260821-142233-9f3a1c0b7e2d4a55`. Promote it and the `labels` key constants out
63+
of `pkg/validator` into neutral packages (`pkg/runid`, `pkg/k8s/labels`) so
64+
`pkg/k8s/agent` does not depend on a validator-domain package, and so both
65+
subsystems share one ID format under one label key.
66+
67+
`AgentConfig.RunID` is injectable so e2e and chainsaw runs can pin a value and
68+
unit tests stay deterministic. The run ID is logged at `slog.Info` on deploy; its
69+
timestamp prefix makes runs sortable in `kubectl get` output and in orphan
70+
triage.
71+
72+
### 2. Every user-supplied name is a prefix
73+
74+
`--job-name`, `--service-account-name`, and their `pkg/config` and SDK
75+
equivalents are prefixes:
76+
77+
| Object | Name |
78+
|---|---|
79+
| Job | `<job-prefix>-<runID>`, default `aicr` (`aicr-validate` for `aicr validate`) |
80+
| ServiceAccount, Role, RoleBinding | `<sa-prefix>-<runID>`, default `aicr` |
81+
| ClusterRole / ClusterRoleBinding | `aicr-node-reader-<runID>` |
82+
| Staging ConfigMap | `aicr-snapshot-<runID>` |
83+
84+
Because nothing aicr creates can already exist:
85+
86+
- `ensureJob`'s delete-and-recreate (`job.go:35-55`) and `waitForJobDeletion` are
87+
removed, as is the create-or-update logic in `ensureRole`, `ensureRoleBinding`,
88+
`ensureClusterRole`, and `ensureClusterRoleBinding`. Those `Update` calls were
89+
the mechanism by which one run rewrote another's permissions.
90+
`ensureServiceAccount` drops `IgnoreAlreadyExists`.
91+
- An `AlreadyExists` on any create implies a 16-byte random collision or a caller
92+
pinning a duplicate `RunID`. Return `ErrCodeInternal`; it is a defensive
93+
assertion, not a supported state.
94+
- **Permission isolation is structural.** Every run has its own ServiceAccount,
95+
so RBAC additivity across concurrent runs is impossible: a `DiscoverNetwork`
96+
run's mutating grants bind to an identity no other run uses.
97+
98+
**Name length.** The run ID is 32 characters, so a prefix truncates to 30 to keep
99+
generated names within the 63-character ceiling imposed by the Job's
100+
`batch.kubernetes.io/job-name` label. If 30 proves tight, halving the random half
101+
of the run ID buys 8 more — a one-line change to the shared generator, not made
102+
here.
103+
104+
### 3. Three lifecycle classes
105+
106+
| Class | Objects | Rule |
107+
|---|---|---|
108+
| **Run-owned** | Job, ServiceAccount, Role, RoleBinding, ClusterRole, ClusterRoleBinding, staging ConfigMap | Run-ID-suffixed. Created and deleted by this run. |
109+
| **Ensured** | Namespace | Created if absent, labeled `managed-by`, never deleted, never suffixed. |
110+
| **Delivered** | Explicit `cm://namespace/name` output ConfigMap | The user's artifact. Written on purpose, never deleted, never suffixed. |
111+
112+
aicr deletes an object if and only if it is run-owned. Exactly one object kind
113+
falls in each of the other two classes.
114+
115+
### 4. The Namespace stays user-specified
116+
117+
`--namespace` has no default and names a namespace the operator chose;
118+
`ensureNamespace` (`rbac.go:30-79`) creates or labels it. It remains **ensured**
119+
and is not renamed to `aicr-<runID>`:
120+
121+
- The documented workflow writes `cm://gpu-operator/aicr-snapshot` into a
122+
namespace the operator picked. A per-run agent namespace would need
123+
cross-namespace ConfigMap write permission for that artifact, reintroducing the
124+
broad grant this ADR removes.
125+
- Pod Security Standards labels, ResourceQuotas, and the documented `aicr-agent`
126+
NetworkPolicy attach to the operator's namespace; a generated one inherits
127+
none of them.
128+
- Namespace creation needs cluster-scoped permission some callers lack, and
129+
deletion adds finalizer latency to every run.
130+
131+
### 5. Pod selection by Job ownership
132+
133+
`Deploy` records the UID returned by the Job `Create`. `findPodName` and
134+
`findOrWatchPodName` (`wait.go:111-149`) select on
135+
`app.kubernetes.io/name=aicr,aicr.run/run-id=<runID>`, then reject any pod whose
136+
`batch.kubernetes.io/controller-uid` does not match. Existing
137+
`DeletionTimestamp` / `PodFailed` / youngest-first filtering is retained as a
138+
tiebreaker. The UID is held in an `atomic.Pointer`, since it is written in
139+
`Deploy` and read from the log-streaming goroutine spawned afterwards.
140+
141+
### 6. Cleanup is ownership-scoped
142+
143+
The `Deployer` records `(kind, name, UID)` on each successful `Create`; `Cleanup`
144+
iterates that set and passes
145+
`metav1.DeleteOptions{Preconditions: &metav1.Preconditions{UID: &uid}}`. A UID
146+
mismatch is treated as "already replaced, not ours" and ignored. This matters
147+
because `Cleanup` runs on the `Deploy` failure path (Problem, note 1).
148+
149+
`Cleanup` also deletes the internal staging ConfigMap when
150+
`agentConfigMapTarget` reports the run does not own the user's output, carried by
151+
a new `agent.Config.OwnsOutputConfigMap`. This closes the leak in Problem note 3,
152+
which per-run naming would otherwise turn into one leaked object per run.
153+
`CheckPermissions` (`permissions.go:48-64`) gains the `configmaps: delete` verb
154+
this needs.
155+
156+
### 7. Labels
157+
158+
Applied to all seven objects **and to the Job's pod template** — Job
159+
`metadata.labels` do not propagate to pods, and decision 5's selector depends on
160+
them:
161+
162+
- `app.kubernetes.io/name: aicr`
163+
- `app.kubernetes.io/managed-by: aicr`
164+
- `app.kubernetes.io/component: snapshot-agent`
165+
- `aicr.run/run-id: <runID>`
166+
167+
`managed-by` is required, not decorative: `tools/cleanup:336-337` already sweeps
168+
cluster-scoped RBAC with it, and without it every per-run ClusterRole would be
169+
invisible to that tool. `component: snapshot-agent` is the **stable selector**
170+
for consumers targeting agent pods across runs, replacing `job-name`-keyed
171+
selectors whose value now changes per run.
172+
173+
### 8. Public contract and configuration schema
174+
175+
- `AgentConfig` (`pkg/client/v1/types.go:112-130`) gains `RunID string` and
176+
documents `JobName` and `ServiceAccountName` as prefixes, empty meaning "use
177+
the default". Additive; `make api-diff` stays green.
178+
- `docs/integrator/go-library.md:208-222`, which sets both fields and states they
179+
are required, is rewritten to omit them.
180+
- `CollectSnapshot`'s concurrency godoc (`aicr.go:1834-1836`) states what
181+
independence means — distinct objects, permissions, results, and cleanup — and
182+
names the one shared effect that remains: two runs targeting the same explicit
183+
`cm://` URI still overwrite one another, because that object is *delivered*.
184+
- `pkg/config`'s `spec.snapshot.agent` and `spec.validate.agent` document the same
185+
prefix semantics; shipped examples pinning `jobName`/`serviceAccountName` are
186+
updated to omit them.
187+
- The `aicr-agent` NetworkPolicy (`docs/integrator/automation.md:488-506`) moves
188+
its `podSelector` from `job-name: aicr` to `app.kubernetes.io/name: aicr` +
189+
`app.kubernetes.io/component: snapshot-agent`, shipped with this change.
190+
191+
## Non-Goals
192+
193+
- No change to the user-facing `cm://namespace/name` output contract.
194+
- No new HTTP surface; `pkg/server` exposes no snapshot endpoint.
195+
- No change to what the agent collects or to the in-pod agent binary.
196+
- No automated sweep of cluster-scoped RBAC orphaned by a hard kill.
197+
- No modification to `pkg/validator`'s run isolation; this ADR consumes its
198+
primitives after they move to neutral packages.
199+
200+
## Consequences
201+
202+
### Positive
203+
204+
- The documented concurrency contract becomes true, including for SDK callers
205+
following the current documented example.
206+
- Every existing invocation still succeeds. No new failure modes, error codes, or
207+
flags are introduced.
208+
- The `aicr validate` / `aicr snapshot` cross-command collision is fixed.
209+
- Least-privilege improves: a `DiscoverNetwork` run's mutating cluster
210+
permissions bind to an identity that exists for one run and is revoked at
211+
cleanup.
212+
- Two pre-existing leaks close: the staging ConfigMap, and cluster-scoped RBAC
213+
that `tools/cleanup` could not sweep.
214+
215+
### Negative
216+
217+
- **Object names are no longer predictable.** `kubectl logs job/aicr` stops
218+
resolving, and selectors keyed on `job-name` /
219+
`batch.kubernetes.io/job-name` match zero pods. A `podSelector` matching
220+
nothing is not an error in Kubernetes, so an out-of-tree NetworkPolicy written
221+
from the current docs silently stops fencing the agent. This is the sharpest
222+
edge in the change and needs a release-note callout.
223+
- A prefix is capped at 30 characters before truncation.
224+
- **Undocumented reliance on ServiceAccount adoption breaks silently.** A caller
225+
passing `--service-account-name` for an out-of-band ServiceAccount gets
226+
adoption today via `IgnoreAlreadyExists`; afterwards they get a fresh run-owned
227+
ServiceAccount without their annotations. Mitigation: one `Get` at deploy and a
228+
`slog.Warn` naming the generated ServiceAccount when a bare prefix-named one
229+
already exists.
230+
- Two extra cluster-scoped objects are created and deleted per run.
231+
- A hard kill still orphans a ClusterRole and ClusterRoleBinding, since
232+
cluster-scoped objects cannot have a namespaced owner.
233+
234+
### Neutral
235+
236+
- In-tree call sites hardcoding `aicr` are updated in the same change:
237+
`.github/actions/gpu-snapshot-validate/debug-snapshot-job.sh:23,27,35`,
238+
`tools/cleanup:351-354`, `tests/e2e/run.sh:1625`,
239+
`docs/user/agent-deployment.md:156,159,352,372`,
240+
`docs/integrator/automation.md:500`.
241+
- The legacy unlabeled `aicr-node-reader` pair survives upgrade unreferenced; a
242+
name-based delete for it is added to `tools/cleanup`.
243+
244+
## Alternatives Considered
245+
246+
- **Per-run namespace `aicr-<runID>`** — cascade-deletes every namespaced object,
247+
but breaks the documented output-ConfigMap workflow and discards
248+
admin-applied namespace policy. See decision 4.
249+
- **Capability-split fixed ClusterRoles with per-run bindings** — fewer objects,
250+
but turns "cleanup removes the RBAC" into "cleanup removes the binding".
251+
- **Serialize runs with a coordination Lease** — cheapest, but downgrades the
252+
contract from independent to queued and adds a killed-process-holds-lease
253+
failure mode.
254+
255+
## Implementation Deliverables
256+
257+
1. `pkg/runid` and `pkg/k8s/labels`, with `pkg/validator` switched to them.
258+
2. Prefix naming and label application in `pkg/k8s/agent`; removal of
259+
`ensureJob`'s delete-and-recreate, `waitForJobDeletion`, and the
260+
create-or-update logic in `rbac.go`.
261+
3. Created-set tracking and UID-pinned `Cleanup`, including the staging ConfigMap.
262+
4. Controller-UID pod selection in `wait.go`.
263+
5. CLI flag default removal, `AgentConfig.RunID`, godoc and config-schema updates.
264+
6. The NetworkPolicy selector change and the call-site updates listed under
265+
Consequences → Neutral.
266+
7. Tests: an overlapping-run test with one run under `DiscoverNetwork` asserting
267+
distinct objects, distinct ClusterRole rules, correct per-run logs and result
268+
bytes, and cleanup that leaves the other run intact; plus prefix truncation,
269+
ownership-scoped cleanup, the adoption-drift warning, and pod selection
270+
rejecting a foreign-run pod. The fake clientset runs no Job controller and its
271+
watch ignores label selectors, so pods are created explicitly with the
272+
required labels and selector strings are asserted directly where
273+
apiserver-side filtering cannot be simulated.

0 commit comments

Comments
 (0)