|
| 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