@namzu/sandbox 13.0.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +1147 -0
  2. package/README.md +447 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts.map +1 -1
  7. package/dist/backends/docker/index.js +19 -1
  8. package/dist/backends/docker/index.js.map +1 -1
  9. package/dist/backends/firecracker/index.d.ts.map +1 -1
  10. package/dist/backends/firecracker/index.js +12 -2
  11. package/dist/backends/firecracker/index.js.map +1 -1
  12. package/dist/backends/firecracker/protocol.d.ts +481 -8
  13. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  14. package/dist/backends/firecracker/protocol.js +136 -0
  15. package/dist/backends/firecracker/protocol.js.map +1 -1
  16. package/dist/backends/firecracker/transport.d.ts +642 -14
  17. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  18. package/dist/backends/firecracker/transport.js +1307 -34
  19. package/dist/backends/firecracker/transport.js.map +1 -1
  20. package/dist/backends/kubernetes/egress-policy.d.ts +1296 -0
  21. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  22. package/dist/backends/kubernetes/egress-policy.js +2458 -0
  23. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  24. package/dist/backends/kubernetes/identity.d.ts +193 -0
  25. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  26. package/dist/backends/kubernetes/identity.js +147 -0
  27. package/dist/backends/kubernetes/identity.js.map +1 -0
  28. package/dist/backends/kubernetes/index.d.ts +1019 -0
  29. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  30. package/dist/backends/kubernetes/index.js +1756 -0
  31. package/dist/backends/kubernetes/index.js.map +1 -0
  32. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  33. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  34. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  35. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  36. package/dist/backends/kubernetes/k8s-client.d.ts +334 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  38. package/dist/backends/kubernetes/k8s-client.js +553 -0
  39. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  40. package/dist/backends/kubernetes/lease.d.ts +145 -0
  41. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  42. package/dist/backends/kubernetes/lease.js +201 -0
  43. package/dist/backends/kubernetes/lease.js.map +1 -0
  44. package/dist/backends/kubernetes/objects.d.ts +702 -0
  45. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  46. package/dist/backends/kubernetes/objects.js +518 -0
  47. package/dist/backends/kubernetes/objects.js.map +1 -0
  48. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.js +407 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  52. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  53. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  54. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  55. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  56. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  57. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  58. package/dist/backends/kubernetes/rbac.js +177 -0
  59. package/dist/backends/kubernetes/rbac.js.map +1 -0
  60. package/dist/backends/kubernetes/sandbox.d.ts +190 -0
  61. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  62. package/dist/backends/kubernetes/sandbox.js +433 -0
  63. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  64. package/dist/backends/kubernetes/transport.d.ts +1048 -0
  65. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  66. package/dist/backends/kubernetes/transport.js +2093 -0
  67. package/dist/backends/kubernetes/transport.js.map +1 -0
  68. package/dist/backends/kubernetes/workspace.d.ts +1512 -0
  69. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  70. package/dist/backends/kubernetes/workspace.js +3703 -0
  71. package/dist/backends/kubernetes/workspace.js.map +1 -0
  72. package/dist/backends/remote-execution-controller.d.ts +14 -0
  73. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  74. package/dist/backends/remote-execution-controller.js.map +1 -1
  75. package/dist/index.d.ts +350 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +344 -34
  78. package/dist/index.js.map +1 -1
  79. package/dist/testing/sandbox-conformance.d.ts +227 -0
  80. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  81. package/dist/testing/sandbox-conformance.js +896 -0
  82. package/dist/testing/sandbox-conformance.js.map +1 -0
  83. package/package.json +5 -4
  84. package/src/backends/aci-standby-pool/index.ts +16 -1
  85. package/src/backends/docker/index.ts +22 -1
  86. package/src/backends/firecracker/index.ts +14 -2
  87. package/src/backends/firecracker/protocol.ts +541 -6
  88. package/src/backends/firecracker/transport.ts +1687 -64
  89. package/src/backends/kubernetes/egress-policy.ts +3448 -0
  90. package/src/backends/kubernetes/identity.ts +261 -0
  91. package/src/backends/kubernetes/index.ts +2670 -0
  92. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  93. package/src/backends/kubernetes/k8s-client.ts +742 -0
  94. package/src/backends/kubernetes/lease.ts +254 -0
  95. package/src/backends/kubernetes/objects.ts +983 -0
  96. package/src/backends/kubernetes/per-sandbox-policy.ts +542 -0
  97. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  98. package/src/backends/kubernetes/rbac.ts +192 -0
  99. package/src/backends/kubernetes/sandbox.ts +593 -0
  100. package/src/backends/kubernetes/transport.ts +2895 -0
  101. package/src/backends/kubernetes/workspace.ts +5640 -0
  102. package/src/backends/remote-execution-controller.ts +14 -0
  103. package/src/index.ts +838 -35
  104. package/src/testing/sandbox-conformance.ts +1202 -0
@@ -0,0 +1,983 @@
1
+ /**
2
+ * Wire shapes and paths for the agent-sandbox CRDs this backend touches.
3
+ *
4
+ * Every field name here was read off the CRDs a real cluster serves —
5
+ * `kubectl get crd -o json` against agent-sandbox v1.0.2 on a kind cluster —
6
+ * and cross-checked against the upstream `sandbox_types.go` /
7
+ * `sandboxclaim_types.go` doc comments. Where the two disagree the served CRD
8
+ * wins, because it is what the API server validates against: the Go source on
9
+ * upstream main has already moved `Sandbox`'s `shutdownTime` /
10
+ * `shutdownPolicy` under a `lifecycle` block, and v1beta1 as served still
11
+ * carries them at the top of `spec`.
12
+ *
13
+ * The shapes are deliberately partial. This backend reads four fields out of a
14
+ * Sandbox status and writes three into a claim spec; typing the rest of a
15
+ * PodSpec would be a vendored copy of the core API that goes stale on its own
16
+ * schedule. A pod template read from a SandboxTemplate is carried through as
17
+ * an opaque record for exactly that reason — it is copied, never interpreted.
18
+ *
19
+ * Two groups, both `v1beta1` and both singular-versioned today:
20
+ * - `agents.x-k8s.io` → sandboxes
21
+ * - `extensions.agents.x-k8s.io` → sandboxtemplates, sandboxwarmpools,
22
+ * sandboxclaims
23
+ */
24
+
25
+ import { createHash } from 'node:crypto'
26
+
27
+ /** Group serving the `Sandbox` kind. */
28
+ export const SANDBOX_API_GROUP = 'agents.x-k8s.io'
29
+ /** Group serving `SandboxTemplate`, `SandboxWarmPool` and `SandboxClaim`. */
30
+ export const SANDBOX_EXTENSIONS_API_GROUP = 'extensions.agents.x-k8s.io'
31
+ /** The only version either group serves in agent-sandbox v1.0.2. */
32
+ export const SANDBOX_API_VERSION = 'v1beta1'
33
+
34
+ /** `status.conditions[].type` both kinds report readiness under. */
35
+ export const READY_CONDITION = 'Ready'
36
+
37
+ // The controller also reports a `Suspended` condition, and this backend
38
+ // deliberately does NOT model or read it. Upstream's own `sandbox_types.go`
39
+ // says why: "the controller does not currently remove this condition when the
40
+ // Sandbox is resumed, so a stale Suspended condition may linger after
41
+ // operatingMode returns to Running. Consumers should treat Ready as the
42
+ // authoritative signal and not infer the live operating state from the mere
43
+ // presence of this condition." A suspend that waited on it would return on the
44
+ // True left behind by the previous suspend, while the guest was still running
45
+ // and still writing. `workspace.ts` waits on the pod instead — see
46
+ // `isPodStopped` below.
47
+
48
+ export interface KubernetesObjectMeta {
49
+ readonly name?: string
50
+ readonly namespace?: string
51
+ readonly uid?: string
52
+ /**
53
+ * RFC 3339, written by the API server on admission and never by a client.
54
+ * Read only to report how old a workspace is — see
55
+ * `workspace.ts`'s `listKubernetesWorkspaces`.
56
+ */
57
+ readonly creationTimestamp?: string
58
+ /**
59
+ * The object's version as this read saw it, written by the API server on
60
+ * every write and never by a client.
61
+ *
62
+ * It is here for exactly one use and no other: the fallback `test` clause
63
+ * of a holder-epoch patch aimed at an object that does not carry the
64
+ * annotation yet, and the `preconditions.resourceVersion` of a fenced
65
+ * DELETE — both INSIDE the one read-write pair that read it. It is never
66
+ * stored on a handle, never carried across calls and never streamed, so
67
+ * the "no watch, no informers, no resourceVersion tracking" invariant
68
+ * `k8s-client.ts` states still holds. See {@link buildHolderEpochPatch}.
69
+ */
70
+ readonly resourceVersion?: string
71
+ /**
72
+ * Set the moment a DELETE is accepted, long before the object goes away.
73
+ * A pod that carries one is on its way out and must never be bound to —
74
+ * see {@link isPodLive}.
75
+ */
76
+ readonly deletionTimestamp?: string
77
+ readonly labels?: Readonly<Record<string, string>>
78
+ readonly annotations?: Readonly<Record<string, string>>
79
+ }
80
+
81
+ /** `metav1.Condition`, as both CRDs embed it. */
82
+ export interface KubernetesCondition {
83
+ readonly type: string
84
+ readonly status: 'True' | 'False' | 'Unknown'
85
+ readonly reason?: string
86
+ readonly message?: string
87
+ readonly lastTransitionTime?: string
88
+ }
89
+
90
+ /**
91
+ * True only for an explicit `status: 'True'`. An absent condition, an
92
+ * `Unknown` and a `False` are all "not yet", never "assume so" — the
93
+ * controller writes `Unknown` while it is still deciding.
94
+ */
95
+ export function isConditionTrue(
96
+ conditions: readonly KubernetesCondition[] | undefined,
97
+ type: string,
98
+ ): boolean {
99
+ return conditions?.some((c) => c.type === type && c.status === 'True') === true
100
+ }
101
+
102
+ /**
103
+ * `SandboxClaim.spec.lifecycle`.
104
+ *
105
+ * `shutdownTime` is the only one of the three that bounds a claim whose owner
106
+ * disappeared: the controller deletes the claim's resources once the wall
107
+ * clock reaches it, whatever the claim is doing. `ttlSecondsAfterFinished`
108
+ * reads like the leak guard and is not one — upstream's own comment says "the
109
+ * timer starts from the mirrored Finished condition's LastTransitionTime", so
110
+ * a claim whose host crashed before finishing never starts that clock.
111
+ */
112
+ export interface SandboxClaimLifecycle {
113
+ /** RFC 3339. Absolute expiry; the claim never expires without it. */
114
+ readonly shutdownTime?: string
115
+ /**
116
+ * What happens to the claim OBJECT at expiry. `Retain` (the CRD default)
117
+ * deletes the Sandbox, Pod and Service but leaves the claim behind, so a
118
+ * host that crashes daily accumulates claims forever.
119
+ */
120
+ readonly shutdownPolicy?: 'Delete' | 'DeleteForeground' | 'Retain'
121
+ readonly ttlSecondsAfterFinished?: number
122
+ }
123
+
124
+ /**
125
+ * `SandboxClaim.spec`. `warmPoolRef` is REQUIRED by the CRD, which is why
126
+ * there is no such thing as a pool-less claim and the no-pool path has to
127
+ * create a Sandbox directly.
128
+ *
129
+ * `env` and `volumeClaimTemplates` exist on this spec and are deliberately
130
+ * absent from this type: setting either forces the claim to cold-start rather
131
+ * than adopt a warm pool sandbox, which is the one thing the warm path exists
132
+ * to avoid. A field that cannot be named cannot be set by accident.
133
+ *
134
+ * `additionalPodMetadata` is the exception, and the reason the rule above is
135
+ * about COLD STARTS rather than about claim-time metadata in general: labels
136
+ * are merged into an adopted warm sandbox without one. Measured on
137
+ * agent-sandbox v1.0.2 — two claims out of one two-replica pool, each
138
+ * carrying a different label value, both binding a replica that already
139
+ * existed, and the controller patching the label onto the running pod and
140
+ * into the Sandbox's own podTemplate. See `egress-policy.ts`'s profile
141
+ * support.
142
+ */
143
+ export interface SandboxClaimResourceSpec {
144
+ readonly warmPoolRef: { readonly name: string }
145
+ readonly lifecycle?: SandboxClaimLifecycle
146
+ /**
147
+ * Labels (and annotations, which this backend never sets) the controller
148
+ * merges onto the pod it binds. Warm-safe — see the type comment.
149
+ */
150
+ readonly additionalPodMetadata?: {
151
+ readonly labels?: Readonly<Record<string, string>>
152
+ }
153
+ }
154
+
155
+ /**
156
+ * `SandboxClaim.status`. `sandbox` is the whole reason the claim path reads
157
+ * status back: an adopted pool sandbox keeps the generated name the pool gave
158
+ * it, so the bound object is routinely NOT named after the claim.
159
+ */
160
+ export interface SandboxClaimResourceStatus {
161
+ readonly conditions?: readonly KubernetesCondition[]
162
+ readonly sandbox?: {
163
+ readonly name?: string
164
+ readonly podIPs?: readonly string[]
165
+ readonly serviceFQDN?: string
166
+ }
167
+ }
168
+
169
+ export interface SandboxClaimResource {
170
+ readonly apiVersion?: string
171
+ readonly kind?: string
172
+ readonly metadata?: KubernetesObjectMeta
173
+ readonly spec?: SandboxClaimResourceSpec
174
+ readonly status?: SandboxClaimResourceStatus
175
+ }
176
+
177
+ /**
178
+ * A `GET` of the claims COLLECTION. Read by `readKubernetesTaskCapacity` (a
179
+ * count) and `releaseKubernetesTaskSandboxes` (the names to `DELETE`) — both
180
+ * in `index.ts`. Same "no watch, no `continue`" shape as
181
+ * {@link SandboxListResource}, for the same reason: this backend does no
182
+ * watch at all.
183
+ */
184
+ export interface SandboxClaimListResource {
185
+ readonly items?: readonly SandboxClaimResource[]
186
+ }
187
+
188
+ /**
189
+ * `SandboxWarmPool.spec`/`.status`, read by `readKubernetesTaskCapacity`
190
+ * alone — the first place in this backend that reads a `SandboxWarmPool`
191
+ * rather than only naming one in a claim's `warmPoolRef`. Partial in the
192
+ * same way every other shape here is: `replicas` and `readyReplicas` are the
193
+ * two fields a capacity read needs, off the exact same object
194
+ * `k8s/scripts/acquire-p50.mjs` already polls by hand.
195
+ */
196
+ export interface SandboxWarmPoolResource {
197
+ readonly metadata?: KubernetesObjectMeta
198
+ readonly spec?: {
199
+ readonly replicas?: number
200
+ }
201
+ readonly status?: {
202
+ readonly readyReplicas?: number
203
+ }
204
+ }
205
+
206
+ /**
207
+ * `podTemplate` on a Sandbox or a SandboxTemplate. `spec` is a core `PodSpec`,
208
+ * carried opaquely: this backend copies one from a template into a Sandbox and
209
+ * overlays at most `runtimeClassName`.
210
+ */
211
+ export interface SandboxPodTemplate {
212
+ readonly metadata?: KubernetesObjectMeta
213
+ readonly spec: Readonly<Record<string, unknown>>
214
+ }
215
+
216
+ /**
217
+ * One `spec.volumeClaimTemplates` entry.
218
+ *
219
+ * Partial in the same way every other shape here is: a workspace's disk is
220
+ * COPIED verbatim from the `SandboxTemplate` that declares it, and only the
221
+ * two fields this backend has to reason about are named — the entry's own
222
+ * `metadata.name`, which is how the controller wires the mount (StatefulSet
223
+ * style: the PVC is created as `<entry name>-<sandbox name>` and no explicit
224
+ * `volumes:` entry is needed in the podTemplate), and `spec.volumeMode`,
225
+ * which decides whether the guest gets a raw block device or a filesystem
226
+ * passthrough. The index signatures carry everything else across untouched.
227
+ */
228
+ export interface SandboxVolumeClaimTemplate {
229
+ readonly metadata?: KubernetesObjectMeta
230
+ readonly spec?: {
231
+ readonly volumeMode?: string
232
+ readonly [field: string]: unknown
233
+ }
234
+ readonly [field: string]: unknown
235
+ }
236
+
237
+ export interface SandboxResourceSpec {
238
+ readonly operatingMode?: 'Running' | 'Suspended'
239
+ readonly podTemplate: SandboxPodTemplate
240
+ /** Create a headless Service, and with it a `status.serviceFQDN`. */
241
+ readonly service?: boolean
242
+ readonly shutdownPolicy?: 'Delete' | 'Retain'
243
+ /** RFC 3339, top-level on v1beta1 Sandbox (NOT under `lifecycle`). */
244
+ readonly shutdownTime?: string
245
+ /**
246
+ * CEL-immutable on the served CRD ("volumeClaimTemplates is immutable"),
247
+ * which is why a workspace's disk has to be in the spec from creation and
248
+ * cannot be attached to a sandbox that is already running.
249
+ */
250
+ readonly volumeClaimTemplates?: readonly SandboxVolumeClaimTemplate[]
251
+ }
252
+
253
+ /**
254
+ * `Sandbox.status`. Note what is NOT here: a pod name. The backing pod is
255
+ * named after the Sandbox itself in v1.0.2, and `selector` — a serialised
256
+ * label selector, e.g. `agents.x-k8s.io/sandbox-name-hash=<hash>` — is the
257
+ * only thing in the API that finds the pod without relying on that.
258
+ */
259
+ export interface SandboxResourceStatus {
260
+ readonly conditions?: readonly KubernetesCondition[]
261
+ readonly nodeName?: string
262
+ readonly podIPs?: readonly string[]
263
+ readonly selector?: string
264
+ readonly service?: string
265
+ readonly serviceFQDN?: string
266
+ }
267
+
268
+ export interface SandboxResource {
269
+ readonly apiVersion?: string
270
+ readonly kind?: string
271
+ readonly metadata?: KubernetesObjectMeta
272
+ readonly spec?: SandboxResourceSpec
273
+ readonly status?: SandboxResourceStatus
274
+ }
275
+
276
+ /**
277
+ * A `GET` of the sandboxes COLLECTION. `items` is the only field anything
278
+ * here reads: this backend does no watch, so `metadata.resourceVersion` and
279
+ * `continue` have nothing to feed — the namespace a deployment gives its
280
+ * sandboxes holds tens of objects, not the thousands that would make a page
281
+ * boundary a real answer rather than a truncated one.
282
+ */
283
+ export interface SandboxListResource {
284
+ readonly items?: readonly SandboxResource[]
285
+ }
286
+
287
+ export interface SandboxTemplateResource {
288
+ readonly metadata?: KubernetesObjectMeta
289
+ readonly spec?: {
290
+ readonly podTemplate?: SandboxPodTemplate
291
+ readonly service?: boolean
292
+ readonly volumeClaimTemplates?: readonly SandboxVolumeClaimTemplate[]
293
+ }
294
+ }
295
+
296
+ /**
297
+ * `metadata.uid` is the per-instance agent bind token; `deletionTimestamp`
298
+ * and `phase` exist only to answer "is this the pod that uid belongs to, or
299
+ * the one being deleted?" — see {@link isPodLive}.
300
+ *
301
+ * `podIP` is read by the `pod-ip` address mode only, and deliberately from
302
+ * the SAME object the uid comes from: an address taken from one pod and a
303
+ * token taken from another is the mismatch that reports as a flat
304
+ * `unauthorized` with nothing pointing at the pod that was replaced in
305
+ * between. Both spellings are carried because a dual-stack cluster fills
306
+ * `podIPs` and single-stack clusters have always filled `podIP`; the API
307
+ * server sets `podIP` to the first entry of `podIPs` on every cluster that
308
+ * sets either, so {@link readPodIP} prefers it and falls back.
309
+ */
310
+ export interface PodResource {
311
+ readonly metadata?: KubernetesObjectMeta
312
+ readonly status?: {
313
+ readonly phase?: string
314
+ readonly podIP?: string
315
+ readonly podIPs?: readonly { readonly ip?: string }[]
316
+ /**
317
+ * `status.conditions` — read on ONE path only, and never on a healthy
318
+ * one: after an acquire has already run out of readiness budget,
319
+ * `PodScheduled=False` with reason `Unschedulable` is what separates
320
+ * "the cluster has no room" from "the sandbox is just slow". Nothing
321
+ * waits on a pod condition: `Ready` here is the kubelet's view of the
322
+ * container, and readiness on this backend is the Sandbox's own
323
+ * `Ready`, which is what {@link isConditionTrue} is called with
324
+ * everywhere else. See `index.ts`'s `diagnoseUnreadyPod`.
325
+ */
326
+ readonly conditions?: readonly KubernetesCondition[]
327
+ /**
328
+ * `status.containerStatuses` — read on the same one path, for the same
329
+ * one question. A container stuck in `waiting` with an image-pull
330
+ * reason is a permanent failure wearing the clothes of a slow start,
331
+ * and it is the only one of those this backend can name from the API.
332
+ */
333
+ readonly containerStatuses?: readonly PodContainerStatus[]
334
+ }
335
+ }
336
+
337
+ /** The single `status.containerStatuses` field the diagnosis above reads. */
338
+ export interface PodContainerStatus {
339
+ readonly name?: string
340
+ readonly state?: {
341
+ readonly waiting?: { readonly reason?: string; readonly message?: string }
342
+ }
343
+ }
344
+
345
+ /** The pod's own address, whichever of the two fields this cluster fills. */
346
+ export function readPodIP(pod: PodResource | undefined): string | undefined {
347
+ const status = pod?.status
348
+ if (typeof status?.podIP === 'string' && status.podIP !== '') return status.podIP
349
+ for (const entry of status?.podIPs ?? []) {
350
+ if (typeof entry?.ip === 'string' && entry.ip !== '') return entry.ip
351
+ }
352
+ return undefined
353
+ }
354
+
355
+ export interface PodListResource {
356
+ readonly items?: readonly PodResource[]
357
+ }
358
+
359
+ /**
360
+ * A pod whose uid is still worth binding to: not being deleted, and not in a
361
+ * phase it cannot leave.
362
+ *
363
+ * The case this exists for is resume. A resumed sandbox's pod keeps the
364
+ * SAME NAME and gets a new uid and a new IP, so while the outgoing pod is
365
+ * terminating a `GET` by that name answers with the pod on its way out, and a
366
+ * list by the sandbox's selector returns every pod still carrying its labels,
367
+ * that one included. Binding to the terminating pod's uid produces a token the
368
+ * new agent refuses, and the failure arrives as a flat `unauthorized` with
369
+ * nothing pointing at the race that caused it.
370
+ */
371
+ export function isPodLive(pod: PodResource | undefined): boolean {
372
+ if (!pod?.metadata) return false
373
+ // `!= null`, not `!== undefined`: an explicit JSON null would otherwise
374
+ // read as "terminating" and make a perfectly healthy pod unbindable. The
375
+ // API server omits the field rather than nulling it, so this never fires
376
+ // against a real cluster — it costs nothing not to depend on that.
377
+ if (pod.metadata.deletionTimestamp != null) return false
378
+ const phase = pod.status?.phase
379
+ return phase !== 'Succeeded' && phase !== 'Failed'
380
+ }
381
+
382
+ /**
383
+ * A pod whose containers have stopped: it is still an object, and nothing in
384
+ * it is executing any more.
385
+ *
386
+ * NOT the negation of {@link isPodLive}, and the gap between the two is the
387
+ * whole point. A terminating pod — `deletionTimestamp` set, phase still
388
+ * `Running` — is not live (never bind to it: its uid is about to stop being
389
+ * a valid token) and not stopped either (its process is still running, and on
390
+ * a workspace it is still writing to the caller's block device until it exits
391
+ * or `terminationGracePeriodSeconds` runs out). A suspend that treated the
392
+ * timestamp as "gone" would resolve mid-drain and promise a quiesced disk it
393
+ * had not waited for.
394
+ */
395
+ export function isPodStopped(pod: PodResource | undefined): boolean {
396
+ const phase = pod?.status?.phase
397
+ return phase === 'Succeeded' || phase === 'Failed'
398
+ }
399
+
400
+ /**
401
+ * Backend-owned pod label naming which `SandboxTemplate` a Sandbox's pod was
402
+ * built from.
403
+ *
404
+ * agent-sandbox's OWN template-adoption controller selects pods by a
405
+ * controller-owned label, `agents.x-k8s.io/sandbox-template-ref-hash` — but
406
+ * that label is written only onto a Sandbox ADOPTED out of a
407
+ * `SandboxWarmPool` (the controller re-parents ownership and re-labels on
408
+ * bind). A Sandbox this backend POSTs directly (the pool-less path in
409
+ * `index.ts`'s `buildSandboxBody`) is never adopted, so it never gets that
410
+ * label — a direct Sandbox's pod would carry nothing a `NetworkPolicy`
411
+ * could reliably select it by. This backend writes its own label instead, on
412
+ * every Sandbox it creates, pooled or direct, so `egress-policy.ts`'s
413
+ * translated `NetworkPolicy` has one selector that always matches.
414
+ *
415
+ * `namzu.ai` matches the published domain (`packages/cli/package.json`'s
416
+ * `homepage`); there is no pre-existing Kubernetes label or annotation
417
+ * prefix anywhere in this repo to follow instead — the closest existing
418
+ * convention, `NAMZU_AGENT_*` / `NAMZU_SANDBOX_*` env vars, is not a
419
+ * label-safe shape.
420
+ *
421
+ * W8's `SandboxTemplate` manifests MUST set this same label (value = the
422
+ * template's own name) on their `podTemplate.metadata.labels`, so a POOLED
423
+ * sandbox's pod carries it too — the pool's pods are built from that
424
+ * template's `podTemplate` directly, not through `buildSandboxBody`, so
425
+ * nothing here can put it there for them. Skipping that step means a
426
+ * translated `NetworkPolicy`'s `podSelector` matches only sandboxes this
427
+ * backend created directly and none of the pooled ones — see
428
+ * `docs/sdk/kubernetes-sandbox.md`'s egress section.
429
+ */
430
+ export const SANDBOX_TEMPLATE_LABEL_KEY = 'sandbox.namzu.ai/template'
431
+
432
+ /**
433
+ * Backend-owned annotation naming when a Sandbox's `spec.operatingMode` was
434
+ * last changed BY THIS BACKEND, RFC 3339.
435
+ *
436
+ * It exists because nothing already on the object answers the question, and
437
+ * an inventory that never wakes a workspace is the reason to ask it: a
438
+ * retention pass deleting the workspaces nobody has resumed for a month reads
439
+ * this and `metadata.creationTimestamp` and nothing else.
440
+ *
441
+ * The obvious candidate is the controller's own `Suspended` condition and its
442
+ * `lastTransitionTime`, and upstream's `sandbox_types.go` rules it out in the
443
+ * same breath it documents it: "the controller does not currently remove this
444
+ * condition when the Sandbox is resumed", so after a resume the condition is
445
+ * still True and its timestamp still names the suspend that preceded it. The
446
+ * `Ready` condition's timestamp is no better — it moves for every pod that
447
+ * comes and goes, a crash-restart included, and a workspace whose pod
448
+ * restarted has not changed operating mode at all.
449
+ *
450
+ * So the two patches that DO change the mode stamp the moment they were sent,
451
+ * and the value is exactly that: the host's clock at the moment it asked, not
452
+ * the cluster's at the moment it applied. It is an inventory column, never a
453
+ * lock or an ordering, and nothing in this backend reads it back to make a
454
+ * decision. A Sandbox whose mode has never been changed since it was created
455
+ * carries no annotation at all, and is reported without one rather than with
456
+ * a guess.
457
+ *
458
+ * Same prefix as {@link SANDBOX_TEMPLATE_LABEL_KEY}, for the same reason.
459
+ */
460
+ export const OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY = 'sandbox.namzu.ai/operating-mode-changed-at'
461
+
462
+ /**
463
+ * Backend-owned annotation carrying the HOLDER EPOCH: a decimal integer the
464
+ * host raises whenever authority over this workspace moves to another
465
+ * process.
466
+ *
467
+ * A workspace id is a name, not a lock, and a host that drives one workspace
468
+ * from more than one process has to decide which of them may suspend, resume
469
+ * or delete it. Checking its own epoch and then calling `suspend()` does not
470
+ * close the race, because the write that follows is a separate request and
471
+ * the API server accepts it. So the epoch is stored HERE, on the object every
472
+ * lifecycle write targets, and every such write carries it as a condition in
473
+ * the same request — see {@link buildHolderEpochPatch}.
474
+ *
475
+ * The rule: a write carrying epoch `e` applies when the stored epoch is `<=
476
+ * e`, and sets the stored epoch to `e` in the same request. A stored epoch
477
+ * greater than `e` refuses it. An object with NO annotation reads as 0, so
478
+ * every workspace created before this existed accepts its first
479
+ * epoch-carrying write.
480
+ *
481
+ * It is the caller's number, never this backend's: nothing here invents,
482
+ * increments or persists an epoch of its own, and a call that passes none
483
+ * sends exactly the requests it always sent.
484
+ *
485
+ * Same prefix as {@link SANDBOX_TEMPLATE_LABEL_KEY} and
486
+ * {@link OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY}, for the same reason.
487
+ */
488
+ export const HOLDER_EPOCH_ANNOTATION_KEY = 'sandbox.namzu.ai/holder-epoch'
489
+
490
+ /**
491
+ * Backend-owned annotation carrying a hash of the pod template a Sandbox was
492
+ * last built with.
493
+ *
494
+ * A Sandbox's `spec.podTemplate` is a COPY of the SandboxTemplate's, taken
495
+ * once, and the controller rebuilds every replacement pod from that copy
496
+ * rather than from the template — so a workspace kept for weeks runs the pod
497
+ * spec it was created with, and an edit to the template (a new image tag, a
498
+ * memory limit, a grace period, an env entry) reaches only workspaces created
499
+ * after it. Nothing on the object answers "is this copy still the template's
500
+ * current one": the two are separate objects with separate
501
+ * `resourceVersion`s, and comparing the templates field by field on every
502
+ * open would be a second, weaker copy of the overlay rules.
503
+ *
504
+ * So the value is a hash of exactly what was written: the template's
505
+ * `podTemplate` AFTER this backend's own overlays (the template label and the
506
+ * configured `runtimeClassName`), which is the object the Sandbox carries.
507
+ * Hashing before the overlays would report drift on every workspace whose
508
+ * RuntimeClass this backend chose.
509
+ *
510
+ * Written by the workspace paths only — the create POST and the refresh patch
511
+ * — and read back as `templateRevision` on a handle. A task sandbox is
512
+ * ephemeral and has nothing to drift from, so its create body is unchanged.
513
+ * A workspace created before this existed carries no annotation and reports
514
+ * `templateRevision: undefined`, which reads honestly as "unknown", never as
515
+ * "current".
516
+ *
517
+ * Same prefix as {@link SANDBOX_TEMPLATE_LABEL_KEY}, for the same reason.
518
+ */
519
+ export const POD_TEMPLATE_HASH_ANNOTATION_KEY = 'sandbox.namzu.ai/pod-template-hash'
520
+
521
+ /** Object keys in code-unit order, so the hash does not depend on a locale. */
522
+ function compareKeys(left: string, right: string): number {
523
+ if (left < right) return -1
524
+ return left > right ? 1 : 0
525
+ }
526
+
527
+ /**
528
+ * The same JSON with its object keys sorted, recursively.
529
+ *
530
+ * `JSON.stringify` preserves insertion order, and the two pod templates this
531
+ * hash has to compare never have the same one: one comes back from the API
532
+ * server's own serialisation of a stored object and the other is built here
533
+ * by spreading a template's members into a fresh object. Without this, every
534
+ * comparison would report drift that does not exist.
535
+ */
536
+ function canonicalJson(value: unknown): unknown {
537
+ if (Array.isArray(value)) return value.map(canonicalJson)
538
+ if (typeof value !== 'object' || value === null) return value
539
+ const canonical: Record<string, unknown> = {}
540
+ for (const key of Object.keys(value as Record<string, unknown>).sort(compareKeys)) {
541
+ const member = (value as Record<string, unknown>)[key]
542
+ if (member === undefined) continue
543
+ canonical[key] = canonicalJson(member)
544
+ }
545
+ return canonical
546
+ }
547
+
548
+ /**
549
+ * `sha256:<hex>` over the pod template, for
550
+ * {@link POD_TEMPLATE_HASH_ANNOTATION_KEY}.
551
+ *
552
+ * It is an identity, not a checksum of anything security-relevant: two hosts
553
+ * running the same release against the same template must compute the same
554
+ * string, and a template edit must change it. Nothing here compares it
555
+ * against a value an untrusted party chose.
556
+ */
557
+ export function podTemplateHash(podTemplate: SandboxPodTemplate): string {
558
+ const digest = createHash('sha256')
559
+ .update(JSON.stringify(canonicalJson(podTemplate)))
560
+ .digest('hex')
561
+ return `sha256:${digest}`
562
+ }
563
+
564
+ /**
565
+ * One RFC 6902 operation, in the only three shapes this backend sends.
566
+ *
567
+ * `test` is the condition, `add` is every mutation. `add` rather than
568
+ * `replace` throughout: RFC 6902 §4.1 says that on a JSON object member `add`
569
+ * creates the member when it is missing and replaces its value when it is
570
+ * present, while §4.3's `replace` fails outright on a missing one — and
571
+ * `spec.operatingMode` is absent on a Sandbox that has never been suspended,
572
+ * as is the epoch annotation on every workspace created before this release.
573
+ * A `replace` would turn both of those ordinary cases into a rejected patch.
574
+ *
575
+ * So where a design or an issue says the wire carries `replace /spec/…`, this
576
+ * is that write: on a member that is already there the two operations are the
577
+ * same write, and on one that is not, only this one lands.
578
+ */
579
+ export interface JsonPatchOperation {
580
+ readonly op: 'test' | 'add'
581
+ readonly path: string
582
+ readonly value: unknown
583
+ }
584
+
585
+ /**
586
+ * One JSON Pointer reference token (RFC 6901 §3): `~` becomes `~0` and `/`
587
+ * becomes `~1`, in that order — the reverse order would turn a literal `~1`
588
+ * into a slash.
589
+ *
590
+ * An annotation key always contains a `/` (`sandbox.namzu.ai/holder-epoch`),
591
+ * so the pointer to one is unusable without this.
592
+ */
593
+ export function escapeJsonPointerSegment(token: string): string {
594
+ return token.replace(/~/g, '~0').replace(/\//g, '~1')
595
+ }
596
+
597
+ /** Pointer to one annotation on an object's own metadata. */
598
+ export function annotationPointer(key: string): string {
599
+ return `/metadata/annotations/${escapeJsonPointerSegment(key)}`
600
+ }
601
+
602
+ /** What one read of an object saw about its holder epoch. */
603
+ export interface HolderEpochReading {
604
+ /**
605
+ * The stored epoch: the annotation parsed, or `0` when there is none.
606
+ *
607
+ * `undefined` means the annotation is PRESENT and is not a decimal
608
+ * integer, which no version of this backend writes. It is reported as
609
+ * unreadable rather than as 0 on purpose: reading a value this code does
610
+ * not understand as "nobody holds this workspace" would let a write
611
+ * overwrite a fence somebody else established, which is the one thing the
612
+ * annotation exists to prevent.
613
+ */
614
+ readonly epoch?: number
615
+ /**
616
+ * The annotation exactly as stored, and the value the `test` clause
617
+ * carries. Absent when the object has no such annotation.
618
+ */
619
+ readonly annotation?: string
620
+ /** `metadata.annotations` existed at all — decides which `add` is sent. */
621
+ readonly hasAnnotations: boolean
622
+ /** `metadata.resourceVersion`, for the fallback `test` and a fenced DELETE. */
623
+ readonly resourceVersion?: string
624
+ }
625
+
626
+ /** Decimal, no sign, no padding, no exponent — what this backend writes. */
627
+ const HOLDER_EPOCH_PATTERN = /^(?:0|[1-9][0-9]*)$/
628
+
629
+ /** Read {@link HOLDER_EPOCH_ANNOTATION_KEY} off an object's metadata. */
630
+ export function readHolderEpoch(meta: KubernetesObjectMeta | undefined): HolderEpochReading {
631
+ const annotations = meta?.annotations
632
+ const resourceVersion =
633
+ typeof meta?.resourceVersion === 'string' && meta.resourceVersion !== ''
634
+ ? meta.resourceVersion
635
+ : undefined
636
+ const stored = annotations?.[HOLDER_EPOCH_ANNOTATION_KEY]
637
+ const base = {
638
+ hasAnnotations: annotations !== undefined,
639
+ ...(resourceVersion !== undefined ? { resourceVersion } : {}),
640
+ }
641
+ if (typeof stored !== 'string') return { ...base, epoch: 0 }
642
+ if (!HOLDER_EPOCH_PATTERN.test(stored)) return { ...base, annotation: stored }
643
+ const parsed = Number(stored)
644
+ if (!Number.isSafeInteger(parsed)) return { ...base, annotation: stored }
645
+ return { ...base, epoch: parsed, annotation: stored }
646
+ }
647
+
648
+ /**
649
+ * Read {@link POD_TEMPLATE_HASH_ANNOTATION_KEY} off an object's metadata.
650
+ *
651
+ * Absent — an object created before this existed, or one an older release
652
+ * refreshed — is `undefined`, which reads as "unknown" everywhere it is
653
+ * consumed. It is deliberately never compared as an empty string: a
654
+ * revision nobody recorded is not a revision that differs.
655
+ */
656
+ export function readPodTemplateHash(meta: KubernetesObjectMeta | undefined): string | undefined {
657
+ const stored = meta?.annotations?.[POD_TEMPLATE_HASH_ANNOTATION_KEY]
658
+ return typeof stored === 'string' && stored !== '' ? stored : undefined
659
+ }
660
+
661
+ /** A stored epoch of `epoch` or lower lets a write carrying `epoch` through. */
662
+ export function holderEpochAllows(reading: HolderEpochReading, epoch: number): boolean {
663
+ return reading.epoch !== undefined && reading.epoch <= epoch
664
+ }
665
+
666
+ /** What {@link buildHolderEpochPatch} is asked to write, and under what condition. */
667
+ export interface HolderEpochPatchInput {
668
+ /** The metadata the GET returned. The condition is built from THIS read. */
669
+ readonly reading: HolderEpochReading
670
+ /**
671
+ * The epoch the write carries, and the one it stores.
672
+ *
673
+ * ABSENT is the unfenced conditional write: no epoch clause is tested and
674
+ * no epoch annotation is written, and {@link tests} then carries the whole
675
+ * condition — which is why an epoch-less call with no `tests` is refused
676
+ * below rather than sent unconditionally. A workspace refresh is the one
677
+ * caller: its condition is `spec.operatingMode`, and a host that has not
678
+ * opted into the fence must not acquire one by asking for a new pod
679
+ * template.
680
+ */
681
+ readonly epoch?: number
682
+ /**
683
+ * Further `test` clauses composed into the SAME body.
684
+ *
685
+ * This is the seam a second condition uses instead of a second request: a
686
+ * write that also has to assert, say, `spec.operatingMode` passes its
687
+ * clause here and the object is still written under one atomic patch. Two
688
+ * call sites each sending their own conditional patch would be two writes
689
+ * and two chances to lose a race between them.
690
+ */
691
+ readonly tests?: readonly JsonPatchOperation[]
692
+ /** Written to `spec.operatingMode`; omitted leaves the mode alone. */
693
+ readonly operatingMode?: 'Running' | 'Suspended'
694
+ /**
695
+ * RFC 3339 stamp for {@link OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY}.
696
+ * Passed only by a write that actually CHANGES the mode — the annotation
697
+ * says when the mode last changed, and a write that merely restamps the
698
+ * epoch has not changed it.
699
+ */
700
+ readonly operatingModeChangedAt?: string
701
+ /**
702
+ * Further annotations written in the SAME body, merged with the epoch's
703
+ * and the mode stamp's rather than sent after them.
704
+ *
705
+ * {@link POD_TEMPLATE_HASH_ANNOTATION_KEY} is the only caller: the hash
706
+ * has to land with the pod template it describes, or a patch that applied
707
+ * half of the pair would leave the object claiming a revision it is not
708
+ * running.
709
+ */
710
+ readonly annotations?: Readonly<Record<string, string>>
711
+ /**
712
+ * Written to `spec.podTemplate`, replacing it WHOLE — which is the reason
713
+ * this is a JSON Patch at all. A merge patch recurses into maps, so a
714
+ * `nodeSelector` entry the template dropped would survive in the object
715
+ * and the pod would keep a constraint nobody can see in the template any
716
+ * more.
717
+ */
718
+ readonly podTemplate?: SandboxPodTemplate
719
+ }
720
+
721
+ /**
722
+ * The one conditional-write builder this backend has, and the only place a
723
+ * JSON Patch body is composed.
724
+ *
725
+ * Every clause is decided from ONE read, and the whole thing goes up as one
726
+ * request: the condition and the mutation are in the same body, so there is
727
+ * no window between checking and writing for another holder to fit into.
728
+ *
729
+ * Three shapes, decided by what that read saw:
730
+ *
731
+ * - the object carries the epoch annotation ⇒ `test` it by its exact stored
732
+ * string, then `add` the new value over it;
733
+ * - the object carries annotations but not this one ⇒ `test`
734
+ * `/metadata/resourceVersion` instead, then `add` the member;
735
+ * - the object carries no `metadata.annotations` at all ⇒ the same
736
+ * `resourceVersion` test, then `add` the map whole, because there is no
737
+ * member to add one to.
738
+ *
739
+ * The `resourceVersion` fallback is the migration case and nothing more: it
740
+ * fires once, on a workspace created before this release, and from the first
741
+ * epoch write onwards the annotation is what is tested. That matters because
742
+ * a controller status write moves `resourceVersion` without touching the
743
+ * annotation, and under the annotation test those are simply not conditions
744
+ * this write is interested in.
745
+ *
746
+ * A call carrying NO epoch skips all three: it tests only what
747
+ * {@link HolderEpochPatchInput.tests} carries and writes no epoch annotation,
748
+ * which is how a workspace refresh conditions itself on `spec.operatingMode`
749
+ * without fencing a host that never asked for a fence.
750
+ *
751
+ * Every `test` precedes every mutation, which RFC 6902 requires of a
752
+ * condition: operations apply in order, so a `test` written after an `add`
753
+ * would be testing this patch's own work. Mutations go up in a fixed order —
754
+ * annotations, then `spec.podTemplate`, then `spec.operatingMode` — so the
755
+ * body a given input produces is one body and a test can assert it.
756
+ */
757
+ export function buildHolderEpochPatch(input: HolderEpochPatchInput): readonly JsonPatchOperation[] {
758
+ const { reading, epoch } = input
759
+ const epochPointer = annotationPointer(HOLDER_EPOCH_ANNOTATION_KEY)
760
+ const tests: JsonPatchOperation[] = []
761
+ if (epoch === undefined) {
762
+ // Unfenced: the caller's own clauses are the whole condition, and an
763
+ // unconditional JSON patch is refused rather than sent. Every caller
764
+ // on this branch is conditioning on something — a refresh on
765
+ // `spec.operatingMode` — and one that passed nothing would be asking
766
+ // for a blind write in the one builder that exists to prevent them.
767
+ if ((input.tests ?? []).length === 0) {
768
+ throw new Error(
769
+ 'kubernetes: cannot build a conditional patch that carries neither a holder epoch nor a test clause — there is nothing to condition the write on.',
770
+ )
771
+ }
772
+ } else if (reading.annotation !== undefined) {
773
+ tests.push({ op: 'test', path: epochPointer, value: reading.annotation })
774
+ } else if (reading.resourceVersion !== undefined) {
775
+ tests.push({ op: 'test', path: '/metadata/resourceVersion', value: reading.resourceVersion })
776
+ } else {
777
+ // Unreachable from every call site here — each one builds from an
778
+ // object it just read, and the API server sets `resourceVersion` on
779
+ // every object it serves. It throws rather than sending an
780
+ // UNCONDITIONAL patch, because a fenced write that quietly stopped
781
+ // being fenced is the defect this whole path exists to prevent.
782
+ throw new Error(
783
+ 'kubernetes: cannot build a holder-epoch patch from an object that carries neither the holder-epoch annotation nor a metadata.resourceVersion — there is nothing to condition the write on.',
784
+ )
785
+ }
786
+ tests.push(...(input.tests ?? []))
787
+
788
+ const mutations: JsonPatchOperation[] = []
789
+ // Every annotation this body writes, in one place: the epoch when the
790
+ // write is fenced, the mode stamp when the mode moves, and whatever else
791
+ // the caller is landing atomically with them.
792
+ const annotations: Record<string, string> = {
793
+ ...(epoch !== undefined ? { [HOLDER_EPOCH_ANNOTATION_KEY]: String(epoch) } : {}),
794
+ ...(input.operatingModeChangedAt !== undefined
795
+ ? { [OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY]: input.operatingModeChangedAt }
796
+ : {}),
797
+ ...input.annotations,
798
+ }
799
+ const keys = Object.keys(annotations)
800
+ if (keys.length > 0) {
801
+ if (!reading.hasAnnotations) {
802
+ // No member to add one to, so the map goes up whole — and a whole
803
+ // map REPLACES whatever is there, so this one operation is the only
804
+ // mutation in this builder that can erase another writer's work.
805
+ // The window is real: between the GET that saw no annotations and
806
+ // this patch, another process can stamp one without moving
807
+ // `spec.operatingMode` — a suspend carrying a holder epoch onto an
808
+ // already-Suspended object does exactly that — and an unconditional
809
+ // whole-map `add` would silently unfence the workspace it took.
810
+ // `resourceVersion` is what closes it. The fenced path already
811
+ // tests it on this branch (an object with no annotations has no
812
+ // epoch annotation to test); this adds the same clause for an
813
+ // UNFENCED caller, whose own clauses are about `spec` and say
814
+ // nothing about `metadata`.
815
+ if (!tests.some((test) => test.path === '/metadata/resourceVersion')) {
816
+ if (reading.resourceVersion === undefined) {
817
+ throw new Error(
818
+ 'kubernetes: cannot add a metadata.annotations map to an object that carries no metadata.resourceVersion — a whole-map write with nothing to condition it on would overwrite annotations written since the read.',
819
+ )
820
+ }
821
+ tests.push({
822
+ op: 'test',
823
+ path: '/metadata/resourceVersion',
824
+ value: reading.resourceVersion,
825
+ })
826
+ }
827
+ mutations.push({ op: 'add', path: '/metadata/annotations', value: annotations })
828
+ } else {
829
+ for (const key of keys) {
830
+ mutations.push({ op: 'add', path: annotationPointer(key), value: annotations[key] })
831
+ }
832
+ }
833
+ }
834
+ if (input.podTemplate !== undefined) {
835
+ mutations.push({ op: 'add', path: '/spec/podTemplate', value: input.podTemplate })
836
+ }
837
+ if (input.operatingMode !== undefined) {
838
+ mutations.push({ op: 'add', path: '/spec/operatingMode', value: input.operatingMode })
839
+ }
840
+ return [...tests, ...mutations]
841
+ }
842
+
843
+ /** `{ [SANDBOX_TEMPLATE_LABEL_KEY]: sandboxTemplateName }`, as a matchLabels-ready object. */
844
+ export function sandboxTemplateLabel(
845
+ sandboxTemplateName: string,
846
+ ): Readonly<Record<string, string>> {
847
+ return { [SANDBOX_TEMPLATE_LABEL_KEY]: sandboxTemplateName }
848
+ }
849
+
850
+ /** Core `NetworkPolicy` — a stock resource, no CRD. */
851
+ export const CORE_NETWORK_POLICY_API_GROUP = 'networking.k8s.io'
852
+ export const CORE_NETWORK_POLICY_API_VERSION = 'v1'
853
+
854
+ /**
855
+ * Cilium's FQDN-capable policy CRD. Only reached when
856
+ * `KubernetesEgressConfig.engine` is explicitly `'cilium'` — see
857
+ * `egress-policy.ts`.
858
+ */
859
+ export const CILIUM_NETWORK_POLICY_API_GROUP = 'cilium.io'
860
+ export const CILIUM_NETWORK_POLICY_API_VERSION = 'v2'
861
+
862
+ function segment(value: string): string {
863
+ return encodeURIComponent(value)
864
+ }
865
+
866
+ export function claimCollectionPath(namespace: string): string {
867
+ return `/apis/${SANDBOX_EXTENSIONS_API_GROUP}/${SANDBOX_API_VERSION}/namespaces/${segment(namespace)}/sandboxclaims`
868
+ }
869
+
870
+ export function claimPath(namespace: string, name: string): string {
871
+ return `${claimCollectionPath(namespace)}/${segment(name)}`
872
+ }
873
+
874
+ /** The claims collection, narrowed to a `labelSelector` — same shape as {@link podListPath}. */
875
+ export function claimListPath(namespace: string, labelSelector: string): string {
876
+ return `${claimCollectionPath(namespace)}?labelSelector=${encodeURIComponent(labelSelector)}`
877
+ }
878
+
879
+ export function warmPoolPath(namespace: string, name: string): string {
880
+ return `/apis/${SANDBOX_EXTENSIONS_API_GROUP}/${SANDBOX_API_VERSION}/namespaces/${segment(namespace)}/sandboxwarmpools/${segment(name)}`
881
+ }
882
+
883
+ export function sandboxCollectionPath(namespace: string): string {
884
+ return `/apis/${SANDBOX_API_GROUP}/${SANDBOX_API_VERSION}/namespaces/${segment(namespace)}/sandboxes`
885
+ }
886
+
887
+ export function sandboxPath(namespace: string, name: string): string {
888
+ return `${sandboxCollectionPath(namespace)}/${segment(name)}`
889
+ }
890
+
891
+ export function sandboxTemplatePath(namespace: string, name: string): string {
892
+ return `/apis/${SANDBOX_EXTENSIONS_API_GROUP}/${SANDBOX_API_VERSION}/namespaces/${segment(namespace)}/sandboxtemplates/${segment(name)}`
893
+ }
894
+
895
+ /**
896
+ * The PVC the controller creates for one `volumeClaimTemplates` entry:
897
+ * `<entry name>-<sandbox name>`, in the Sandbox's own namespace.
898
+ *
899
+ * Written out here rather than derived at each call site because it is a
900
+ * NAME the controller owns, not one this backend chooses — a release that
901
+ * changes it breaks every read of it at once, and the one place to notice
902
+ * that is a function whose whole body is the convention.
903
+ */
904
+ export function persistentVolumeClaimPath(
905
+ namespace: string,
906
+ sandboxName: string,
907
+ claimTemplateName: string,
908
+ ): string {
909
+ return `/api/v1/namespaces/${segment(namespace)}/persistentvolumeclaims/${segment(
910
+ `${claimTemplateName}-${sandboxName}`,
911
+ )}`
912
+ }
913
+
914
+ export function podPath(namespace: string, name: string): string {
915
+ return `/api/v1/namespaces/${segment(namespace)}/pods/${segment(name)}`
916
+ }
917
+
918
+ /** The whole pods collection, unfiltered. Read by `readKubernetesTaskCapacity` alone. */
919
+ export function podCollectionPath(namespace: string): string {
920
+ return `/api/v1/namespaces/${segment(namespace)}/pods`
921
+ }
922
+
923
+ export function podListPath(namespace: string, labelSelector: string): string {
924
+ return `/api/v1/namespaces/${segment(namespace)}/pods?labelSelector=${encodeURIComponent(labelSelector)}`
925
+ }
926
+
927
+ export function networkPolicyCollectionPath(namespace: string): string {
928
+ return `/apis/${CORE_NETWORK_POLICY_API_GROUP}/${CORE_NETWORK_POLICY_API_VERSION}/namespaces/${segment(namespace)}/networkpolicies`
929
+ }
930
+
931
+ export function networkPolicyPath(namespace: string, name: string): string {
932
+ return `${networkPolicyCollectionPath(namespace)}/${segment(name)}`
933
+ }
934
+
935
+ export function ciliumNetworkPolicyCollectionPath(namespace: string): string {
936
+ return `/apis/${CILIUM_NETWORK_POLICY_API_GROUP}/${CILIUM_NETWORK_POLICY_API_VERSION}/namespaces/${segment(namespace)}/ciliumnetworkpolicies`
937
+ }
938
+
939
+ export function ciliumNetworkPolicyPath(namespace: string, name: string): string {
940
+ return `${ciliumNetworkPolicyCollectionPath(namespace)}/${segment(name)}`
941
+ }
942
+
943
+ /**
944
+ * Core Kubernetes' own admission-policy resources — `ValidatingAdmissionPolicy`
945
+ * and its binding, both CLUSTER-scoped and both stock since v1.30, so a
946
+ * cluster that serves this group needs nothing installed.
947
+ *
948
+ * Read by exactly one caller: the per-sandbox policy fence
949
+ * (`per-sandbox-policy.ts`), which refuses to write a `CiliumNetworkPolicy`
950
+ * at all unless an operator has applied the policy that bounds what this
951
+ * host may write. Never written by this backend — the fence is the operator's
952
+ * object, reviewed by whoever has cluster-admin, and a host that could create
953
+ * its own fence would not have one.
954
+ */
955
+ export const ADMISSION_REGISTRATION_API_GROUP = 'admissionregistration.k8s.io'
956
+ export const ADMISSION_REGISTRATION_API_VERSION = 'v1'
957
+
958
+ export function validatingAdmissionPolicyPath(name: string): string {
959
+ return `/apis/${ADMISSION_REGISTRATION_API_GROUP}/${ADMISSION_REGISTRATION_API_VERSION}/validatingadmissionpolicies/${segment(name)}`
960
+ }
961
+
962
+ export function validatingAdmissionPolicyBindingPath(name: string): string {
963
+ return `/apis/${ADMISSION_REGISTRATION_API_GROUP}/${ADMISSION_REGISTRATION_API_VERSION}/validatingadmissionpolicybindings/${segment(name)}`
964
+ }
965
+
966
+ /**
967
+ * An `ownerReferences` entry, as this backend writes it.
968
+ *
969
+ * `controller` and `blockOwnerDeletion` are deliberately ABSENT rather than
970
+ * written as `false`. `blockOwnerDeletion: true` would make the API server's
971
+ * `OwnerReferencesPermissionEnforcement` admission plugin demand `update` on
972
+ * the OWNER's `finalizers` subresource — a verb no Role in this repo grants
973
+ * and no host needs — so a field whose only legal value here is `false` is
974
+ * one the body is better off not carrying at all. Garbage collection does not
975
+ * need either field: a dependent whose owners are all gone is deleted, owning
976
+ * controller or not.
977
+ */
978
+ export interface KubernetesOwnerReference {
979
+ readonly apiVersion: string
980
+ readonly kind: string
981
+ readonly name: string
982
+ readonly uid: string
983
+ }