@namzu/sandbox 26.0.0 → 27.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 (83) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +20 -7
  3. package/dist/backends/firecracker/protocol.d.ts +46 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js +31 -0
  6. package/dist/backends/firecracker/protocol.js.map +1 -1
  7. package/dist/backends/firecracker/transport.d.ts +15 -1
  8. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  9. package/dist/backends/firecracker/transport.js +16 -0
  10. package/dist/backends/firecracker/transport.js.map +1 -1
  11. package/dist/backends/kubernetes/allocation.d.ts +115 -0
  12. package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
  13. package/dist/backends/kubernetes/allocation.js +130 -0
  14. package/dist/backends/kubernetes/allocation.js.map +1 -0
  15. package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
  16. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  17. package/dist/backends/kubernetes/egress-policy.js +81 -41
  18. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  19. package/dist/backends/kubernetes/identity.d.ts +129 -0
  20. package/dist/backends/kubernetes/identity.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/identity.js +150 -0
  22. package/dist/backends/kubernetes/identity.js.map +1 -1
  23. package/dist/backends/kubernetes/index.d.ts +23 -1
  24. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/index.js +22 -3
  26. package/dist/backends/kubernetes/index.js.map +1 -1
  27. package/dist/backends/kubernetes/objects.d.ts +76 -0
  28. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/objects.js +61 -0
  30. package/dist/backends/kubernetes/objects.js.map +1 -1
  31. package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
  32. package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
  33. package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
  34. package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
  35. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
  36. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  37. package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
  38. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  39. package/dist/backends/kubernetes/sandbox.d.ts +4 -9
  40. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  41. package/dist/backends/kubernetes/sandbox.js +4 -9
  42. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  43. package/dist/backends/kubernetes/transport.d.ts +108 -1
  44. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  45. package/dist/backends/kubernetes/transport.js +150 -6
  46. package/dist/backends/kubernetes/transport.js.map +1 -1
  47. package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
  48. package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
  49. package/dist/backends/kubernetes/workspace-allocate.js +332 -0
  50. package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
  51. package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
  52. package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
  53. package/dist/backends/kubernetes/workspace-observe.js +343 -0
  54. package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
  55. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
  56. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
  57. package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
  58. package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
  59. package/dist/backends/kubernetes/workspace.d.ts +139 -5
  60. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  61. package/dist/backends/kubernetes/workspace.js +434 -36
  62. package/dist/backends/kubernetes/workspace.js.map +1 -1
  63. package/dist/index.d.ts +83 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +100 -6
  66. package/dist/index.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/backends/firecracker/protocol.ts +47 -0
  69. package/src/backends/firecracker/transport.ts +31 -0
  70. package/src/backends/kubernetes/allocation.ts +199 -0
  71. package/src/backends/kubernetes/egress-policy.ts +165 -42
  72. package/src/backends/kubernetes/identity.ts +210 -0
  73. package/src/backends/kubernetes/index.ts +61 -2
  74. package/src/backends/kubernetes/objects.ts +98 -0
  75. package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
  76. package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
  77. package/src/backends/kubernetes/sandbox.ts +4 -9
  78. package/src/backends/kubernetes/transport.ts +203 -4
  79. package/src/backends/kubernetes/workspace-allocate.ts +607 -0
  80. package/src/backends/kubernetes/workspace-observe.ts +590 -0
  81. package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
  82. package/src/backends/kubernetes/workspace.ts +655 -34
  83. package/src/index.ts +193 -3
@@ -46,7 +46,11 @@
46
46
  * operator's own baseline policy. So before the FIRST write, this module
47
47
  * proves an operator applied a `ValidatingAdmissionPolicy` and its binding,
48
48
  * and refuses with {@link KubernetesAdmissionFenceMissingError}, having
49
- * written nothing, if either is absent. The shipped example
49
+ * written nothing, if either is absent — and with
50
+ * {@link KubernetesAdmissionFenceInertError}, naming the field, if either
51
+ * exists and would not enforce (see `per-sandbox-fence.ts`: the same
52
+ * standard the `no-network` baseline's named-object check holds its own
53
+ * object to). The shipped example
50
54
  * (`k8s/manifests/validatingadmissionpolicy-cilium.yaml`) bounds the host
51
55
  * identity to this name prefix, an owner reference whose uid the name has to
52
56
  * match, ONE selector label whose VALUE is that owner's own name (the shape
@@ -56,6 +60,18 @@
56
60
  * kube-dns rule on port 53, no address-based or entity-based peers and no
57
61
  * ingress.
58
62
  *
63
+ * ## Verified after every write
64
+ *
65
+ * Reading the object back proves the object is the one that was sent. It says
66
+ * nothing about the BOUNDARY, which is the union of every policy selecting the
67
+ * pod: a policy applied between this sandbox's create and its grant that
68
+ * allows more is invisible to a read-back of this one. So after the read-back
69
+ * the union is enumerated again, against the baseline translation plus the
70
+ * policy just written (`unionEgressAllowance`), and if anything allows more
71
+ * the object is deleted before the error is thrown — the per-sandbox
72
+ * analogue of the baseline's "no sandbox is handed back". An empty list is not
73
+ * re-checked: a deletion only narrows.
74
+ *
59
75
  * ## Union, not replacement
60
76
  *
61
77
  * The cluster unions every policy selecting a pod, so what this writes ADDS
@@ -89,6 +105,7 @@ import {
89
105
  assertHostsFitNarrowing,
90
106
  assertUsableHost,
91
107
  buildCiliumEgressManifest,
108
+ narrowedHostPorts,
92
109
  perSandboxEgressLabelKey,
93
110
  verifyEgressPolicyApplied,
94
111
  } from './egress-policy.js'
@@ -108,6 +125,17 @@ import {
108
125
  validatingAdmissionPolicyBindingPath,
109
126
  validatingAdmissionPolicyPath,
110
127
  } from './objects.js'
128
+ import {
129
+ KubernetesAdmissionFenceInertError,
130
+ KubernetesPermittedHostsError,
131
+ KubernetesPermittedHostsParamError,
132
+ type PermittedHosts,
133
+ assertFenceEnforces,
134
+ assertPermittedHostsFenceEnforces,
135
+ assertWithinPermittedHosts,
136
+ permittedHostsFenceNames,
137
+ readPermittedHosts,
138
+ } from './per-sandbox-fence.js'
111
139
 
112
140
  /**
113
141
  * Every per-sandbox policy's name starts with this, and the shipped admission
@@ -224,6 +252,15 @@ export class KubernetesAdmissionFenceMissingError extends Error {
224
252
  */
225
253
  export { KubernetesNetworkPolicyHostError }
226
254
 
255
+ // The fence's content checks and the operator-pinned host set live in
256
+ // `per-sandbox-fence.ts`; their errors are re-exported here so every import
257
+ // path a per-sandbox refusal is caught by keeps resolving from one module.
258
+ export {
259
+ KubernetesAdmissionFenceInertError,
260
+ KubernetesPermittedHostsError,
261
+ KubernetesPermittedHostsParamError,
262
+ }
263
+
227
264
  /**
228
265
  * One `allowedHosts` entry, validated and canonicalised to the bytes a
229
266
  * `CiliumNetworkPolicy` should carry.
@@ -286,18 +323,21 @@ export interface PerSandboxPolicyFence {
286
323
  export function buildAdmissionFence(
287
324
  client: KubernetesClient,
288
325
  perSandbox: KubernetesPerSandboxEgressConfig,
326
+ namespace: string,
289
327
  ): PerSandboxPolicyFence {
290
328
  const policyName = perSandbox.admissionPolicyName
291
329
  const bindingName = perSandbox.admissionPolicyBindingName ?? `${policyName}-binding`
330
+ const param = perSandbox.permittedHostsParam
331
+ const companion = param === undefined ? undefined : permittedHostsFenceNames(perSandbox)
292
332
  let applied: Promise<void> | undefined
293
333
  const read = async (
294
334
  kind: 'ValidatingAdmissionPolicy' | 'ValidatingAdmissionPolicyBinding',
295
335
  name: string,
296
336
  path: string,
297
337
  signal?: AbortSignal,
298
- ): Promise<void> => {
338
+ ): Promise<unknown> => {
299
339
  try {
300
- await client.request('GET', path, undefined, signal)
340
+ return await client.request('GET', path, undefined, signal)
301
341
  } catch (err) {
302
342
  if (err instanceof KubernetesAlreadyGoneError) {
303
343
  throw new KubernetesAdmissionFenceMissingError(kind, name, path)
@@ -311,21 +351,47 @@ export function buildAdmissionFence(
311
351
  throw err
312
352
  }
313
353
  }
354
+ const readPair = async (
355
+ policy: string,
356
+ binding: string,
357
+ signal?: AbortSignal,
358
+ ): Promise<{
359
+ policyName: string
360
+ policy: unknown
361
+ bindingName: string
362
+ binding: unknown
363
+ }> => ({
364
+ policyName: policy,
365
+ policy: await read(
366
+ 'ValidatingAdmissionPolicy',
367
+ policy,
368
+ validatingAdmissionPolicyPath(policy),
369
+ signal,
370
+ ),
371
+ bindingName: binding,
372
+ binding: await read(
373
+ 'ValidatingAdmissionPolicyBinding',
374
+ binding,
375
+ validatingAdmissionPolicyBindingPath(binding),
376
+ signal,
377
+ ),
378
+ })
314
379
  return {
315
380
  async assertApplied(signal) {
381
+ // Existence first, then CONTENT, and both memoized together on
382
+ // success: the baseline's named-object check does the same, and a
383
+ // fence that existed but would not enforce is refused here, with
384
+ // the field that failed, before anything is written.
316
385
  applied ??= (async () => {
317
- await read(
318
- 'ValidatingAdmissionPolicy',
319
- policyName,
320
- validatingAdmissionPolicyPath(policyName),
321
- signal,
322
- )
323
- await read(
324
- 'ValidatingAdmissionPolicyBinding',
325
- bindingName,
326
- validatingAdmissionPolicyBindingPath(bindingName),
327
- signal,
328
- )
386
+ assertFenceEnforces(await readPair(policyName, bindingName, signal), namespace, perSandbox)
387
+ if (param !== undefined && companion !== undefined) {
388
+ assertPermittedHostsFenceEnforces(
389
+ await readPair(companion.policyName, companion.bindingName, signal),
390
+ namespace,
391
+ perSandbox,
392
+ param,
393
+ )
394
+ }
329
395
  })().catch((err: unknown) => {
330
396
  applied = undefined
331
397
  throw err
@@ -352,6 +418,41 @@ export interface PerSandboxPolicySetterOptions {
352
418
  * config, through the one resolver.
353
419
  */
354
420
  readonly selectorValue: string
421
+ /**
422
+ * The post-write union check: given the policy just written and read back,
423
+ * prove that the union of every policy selecting the pod is still the
424
+ * baseline plus that policy, and throw if any other one allows more.
425
+ * Absent when the deployment opted out of union verification
426
+ * (`egress.verify: 'named-object-only'`), exactly as the create path's
427
+ * union check is.
428
+ */
429
+ readonly verifyUnion?: (
430
+ written: KubernetesTranslatedEgressPolicy,
431
+ signal?: AbortSignal,
432
+ ) => Promise<void>
433
+ }
434
+
435
+ /**
436
+ * Raised when a per-sandbox policy was written, FAILED a post-write check, and
437
+ * the attempt to remove it failed too. The refusal that triggered the removal
438
+ * is `cause`; the policy may still be in force, and the message says so,
439
+ * because the one thing a caller must not conclude is that the refusal left the
440
+ * cluster as it found it.
441
+ */
442
+ export class KubernetesPerSandboxPolicyNotRemovedError extends Error {
443
+ override readonly name = 'KubernetesPerSandboxPolicyNotRemovedError'
444
+
445
+ constructor(
446
+ readonly policyName: string,
447
+ readonly path: string,
448
+ readonly refusal: unknown,
449
+ readonly removal: unknown,
450
+ ) {
451
+ super(
452
+ `kubernetes: the per-sandbox policy ${policyName} (${path}) failed verification after it was written (${refusal instanceof Error ? refusal.message : String(refusal)}) and removing it failed too (${removal instanceof Error ? removal.message : String(removal)}). It may still be in force: delete it by hand. The sandbox's own destruction also removes it, because the policy is owned by the sandbox's claim.`,
453
+ { cause: refusal },
454
+ )
455
+ }
355
456
  }
356
457
 
357
458
  /**
@@ -366,7 +467,7 @@ export interface PerSandboxPolicySetterOptions {
366
467
  export function buildPerSandboxPolicySetter(
367
468
  options: PerSandboxPolicySetterOptions,
368
469
  ): (policy: SandboxNetworkPolicy) => Promise<void> {
369
- const { client, fence, namespace, egress, owner, selectorValue } = options
470
+ const { client, fence, namespace, egress, owner, selectorValue, verifyUnion } = options
370
471
  const perSandbox = egress.perSandbox
371
472
  const labelKey = perSandboxEgressLabelKey(egress)
372
473
  if (labelKey === undefined) {
@@ -419,7 +520,27 @@ export function buildPerSandboxPolicySetter(
419
520
  // gone means its owner was deleted underneath the call and this
420
521
  // sandbox is already being destroyed. The refusal is still right; only
421
522
  // its advice belongs to the other caller.
422
- await verifyEgressPolicyApplied(client, translated)
523
+ //
524
+ // Then the boundary, not only the object: what the pod can now reach is
525
+ // the union of every policy selecting it, and the read-back above says
526
+ // nothing about a policy applied between this sandbox's create and this
527
+ // grant. A failure of EITHER check removes what was just written, the
528
+ // way the baseline's create-path checks release the claim they were
529
+ // about to hand back: an unverified grant left in force is the state
530
+ // both checks exist to prevent. A policy that held an earlier,
531
+ // verified list is removed too — falling back to the baseline is the
532
+ // narrower of the two wrong answers.
533
+ try {
534
+ await verifyEgressPolicyApplied(client, translated)
535
+ await verifyUnion?.(translated)
536
+ } catch (refusal) {
537
+ try {
538
+ await remove()
539
+ } catch (removal) {
540
+ throw new KubernetesPerSandboxPolicyNotRemovedError(name, path, refusal, removal)
541
+ }
542
+ throw refusal
543
+ }
423
544
  }
424
545
 
425
546
  const remove = async (): Promise<void> => {
@@ -486,6 +607,20 @@ export function buildPerSandboxPolicySetter(
486
607
  await remove()
487
608
  return
488
609
  }
610
+ // The operator-pinned set, read per call: it is the operator's to
611
+ // edit, and a stale copy would refuse (or grant) against a list that
612
+ // is no longer the reviewed one. Only a grant is checked — a removal
613
+ // can only narrow, and the companion policy does not cover DELETE
614
+ // for the same reason, so a missing parameter never blocks a revoke.
615
+ if (perSandbox.permittedHostsParam !== undefined) {
616
+ const param = perSandbox.permittedHostsParam
617
+ const permitted: PermittedHosts = await readPermittedHosts(client, param)
618
+ assertWithinPermittedHosts(param, permitted, hosts, (host) =>
619
+ perSandbox.narrowing === undefined
620
+ ? undefined
621
+ : narrowedHostPorts(host, perSandbox.narrowing),
622
+ )
623
+ }
489
624
  await write(translate(hosts))
490
625
  })
491
626
  queue = run.then(
@@ -26,15 +26,10 @@
26
26
  * Presence follows configuration alone, never a probe of the cluster. See
27
27
  * `per-sandbox-policy.ts`.
28
28
  *
29
- * A WORKSPACE handle never carries it: `KubernetesWorkspace`'s create path
30
- * builds its handle through `buildKubernetesSandbox` without this option,
31
- * because it composes no per-sandbox pod label and tracks no owner uid for
32
- * one. That is a property of the PATH and not of the configuration, so it
33
- * is not answered by omission: `createKubernetesWorkspace` refuses a
34
- * config carrying `egress.perSandbox` outright
35
- * (`KubernetesWorkspacePerSandboxEgressConfigError`, before any request)
36
- * rather than letting the option declare a capability the path never
37
- * serves — see `workspace.ts`.
29
+ * A WORKSPACE handle carries it on the same condition, built in
30
+ * `workspace.ts` rather than here: the `Sandbox` is the owner, the pod
31
+ * template carries the selector label, and a resume re-proves the fence —
32
+ * see `workspace-per-sandbox.ts`.
38
33
  *
39
34
  * Absent on purpose, because the SDK's contract says a backend that cannot
40
35
  * honour an optional method must omit it rather than accept and ignore:
@@ -43,8 +43,11 @@ import {
43
43
  type ExecRequest,
44
44
  ExecResultAccumulator,
45
45
  FLUSH_FEATURE,
46
+ GUEST_MISMATCH_REFUSAL,
46
47
  type GuestReplyIdentity,
48
+ PINNED_GUEST_FEATURE,
47
49
  QUIESCE_FEATURE,
50
+ QUIESCE_IDLE_FEATURE,
48
51
  type QuiesceScope,
49
52
  type QuiescedProcess,
50
53
  SESSIONS_FEATURE,
@@ -124,11 +127,63 @@ export class KubernetesAgentRetiringError extends Error {
124
127
 
125
128
  constructor(
126
129
  message = 'kubernetes tcp transport: the guest agent has fenced itself (agent_retiring) because an earlier process group’s shutdown could not be confirmed. A command of unknown state may still be running in that pod and nothing on this side can end it: only a new pod clears the fence, and until the pod is replaced every call except healthz and cancel-execution meets this same refusal — reads, writes, terminals and tcp connections included. Nothing was changed on the cluster by this refusal and this handle was not retired. On a persistent workspace, call suspend() and then resume() when you are ready for the live terminals and background processes in that pod to go down; on a task sandbox, destroy() it and take another.',
130
+ /**
131
+ * The agent process that is fenced, when the refusal named it. Only a
132
+ * request sent through a pinned view carries one: it is how a caller
133
+ * tells "the guest I pinned is the one that is fenced" (the same id)
134
+ * from "a different guest stands there" (a `guest_mismatch`).
135
+ */
136
+ readonly guestBootId?: string,
127
137
  ) {
128
138
  super(message)
129
139
  }
130
140
  }
131
141
 
142
+ /**
143
+ * The guest agent refused a pinned request before dispatching it because its
144
+ * own boot id is not the one the request was pinned to. Nothing ran.
145
+ *
146
+ * `guestBootId` is the id of the agent that answered, so a caller can tell a
147
+ * replacement (a new id) from a refusal that has nothing to do with identity.
148
+ * `retiring` is true when that agent is also fenced.
149
+ */
150
+ export class KubernetesAgentGuestMismatchError extends Error {
151
+ override readonly name = 'KubernetesAgentGuestMismatchError'
152
+
153
+ constructor(
154
+ readonly guestBootId: string | undefined,
155
+ readonly retiring = false,
156
+ ) {
157
+ super(
158
+ `kubernetes tcp transport: the guest agent refused a pinned request before dispatching it (${GUEST_MISMATCH_REFUSAL}): its boot id is ${guestBootId ?? 'unreported'}, not the one the request was pinned to. Nothing ran.`,
159
+ )
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The fresh guest boot id carried by a pinned refusal, wherever in an error's
165
+ * cause chain it sits — the control-plane path throws the class above, and
166
+ * every other path throws a plain `Error` whose message is the guest's error
167
+ * text, `guest_mismatch:<id>`.
168
+ */
169
+ export function guestMismatchOf(
170
+ error: unknown,
171
+ ): { readonly guestBootId: string | undefined; readonly retiring: boolean } | undefined {
172
+ let current: unknown = error
173
+ for (let depth = 0; depth < 8 && current instanceof Error; depth += 1) {
174
+ if (current instanceof KubernetesAgentGuestMismatchError) {
175
+ return { guestBootId: current.guestBootId, retiring: current.retiring }
176
+ }
177
+ const prefix = `${GUEST_MISMATCH_REFUSAL}:`
178
+ if (current.message.startsWith(prefix)) {
179
+ const id = current.message.slice(prefix.length).trim()
180
+ return { guestBootId: id === '' ? undefined : id, retiring: false }
181
+ }
182
+ current = current.cause
183
+ }
184
+ return undefined
185
+ }
186
+
132
187
  /**
133
188
  * Thrown when the agent's address could not be RESOLVED — the dial never
134
189
  * reached a socket because the name has no answer here.
@@ -350,7 +405,7 @@ function refusedWith(response: unknown, error: string): boolean {
350
405
  * output is quoted into a message, does not equal it. The chain is walked
351
406
  * because the retry wrapper and the stream paths wrap rather than replace.
352
407
  */
353
- function isUnauthorizedRefusal(error: unknown): boolean {
408
+ export function isUnauthorizedRefusal(error: unknown): boolean {
354
409
  let current: unknown = error
355
410
  for (let depth = 0; depth < 8 && current instanceof Error; depth += 1) {
356
411
  if (current instanceof KubernetesAgentUnauthorizedError) return true
@@ -461,7 +516,19 @@ async function requestChecked(
461
516
  ): Promise<unknown> {
462
517
  const response = await wire.request(request, signal)
463
518
  if (isUnauthorized(response)) throw new KubernetesAgentUnauthorizedError()
464
- if (isAgentRetiring(response)) throw new KubernetesAgentRetiringError()
519
+ if (isAgentRetiring(response)) {
520
+ throw new KubernetesAgentRetiringError(undefined, guestBootIdOf(response))
521
+ }
522
+ if (response && typeof response === 'object') {
523
+ const value = response as { ok?: unknown; error?: unknown; retiring?: unknown }
524
+ if (
525
+ value.ok === false &&
526
+ typeof value.error === 'string' &&
527
+ value.error.startsWith(`${GUEST_MISMATCH_REFUSAL}:`)
528
+ ) {
529
+ throw new KubernetesAgentGuestMismatchError(guestBootIdOf(response), value.retiring === true)
530
+ }
531
+ }
465
532
  return response
466
533
  }
467
534
 
@@ -1314,6 +1381,76 @@ export class KubernetesQuiesceUnconfirmedError extends Error {
1314
1381
  }
1315
1382
  }
1316
1383
 
1384
+ /** One thing the guest found running when an idle-only quiesce looked. */
1385
+ export interface KubernetesQuiesceBusyProcess {
1386
+ readonly pid: number
1387
+ /** The kernel's short name for the executable, never its command line. */
1388
+ readonly command: string
1389
+ /** The `/proc` state letter (`R`, `S`, `D`, `Z`...). `Z` here is a zombie leader with a live thread. */
1390
+ readonly state: string
1391
+ }
1392
+
1393
+ /** A start the agent's registries hold that `/proc` may not show yet. */
1394
+ export interface KubernetesQuiesceBusyRegistryEntry {
1395
+ readonly source: 'execution' | 'session'
1396
+ readonly state: string
1397
+ }
1398
+
1399
+ /**
1400
+ * Thrown by `quiesce({ onlyIfIdle: true })` when the guest is not idle.
1401
+ *
1402
+ * An answer, not a failure: nothing was signalled, marked or stopped, the
1403
+ * guest is exactly as it was, and `busy` says what was seen so the caller can
1404
+ * decide to retry later. `busy` is capped (the guest names at most 32
1405
+ * processes); `busyTotal` counts them all.
1406
+ */
1407
+ export class KubernetesQuiesceBusyError extends Error {
1408
+ override readonly name = 'KubernetesQuiesceBusyError'
1409
+
1410
+ constructor(
1411
+ readonly busy: readonly KubernetesQuiesceBusyProcess[],
1412
+ readonly busyTotal: number,
1413
+ readonly registries: readonly KubernetesQuiesceBusyRegistryEntry[],
1414
+ message: string,
1415
+ ) {
1416
+ super(message)
1417
+ }
1418
+ }
1419
+
1420
+ function busyDetail(reply: Record<string, unknown>): {
1421
+ busy: KubernetesQuiesceBusyProcess[]
1422
+ busyTotal: number
1423
+ registries: KubernetesQuiesceBusyRegistryEntry[]
1424
+ } {
1425
+ const rows = (value: unknown): Record<string, unknown>[] =>
1426
+ Array.isArray(value)
1427
+ ? value.filter(
1428
+ (entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === 'object',
1429
+ )
1430
+ : []
1431
+ const busy = rows(reply.busy).flatMap((row): KubernetesQuiesceBusyProcess[] =>
1432
+ typeof row.pid === 'number'
1433
+ ? [
1434
+ {
1435
+ pid: row.pid,
1436
+ command: typeof row.command === 'string' ? row.command : '',
1437
+ state: typeof row.state === 'string' ? row.state : '',
1438
+ },
1439
+ ]
1440
+ : [],
1441
+ )
1442
+ const registries = rows(reply.registries).flatMap((row): KubernetesQuiesceBusyRegistryEntry[] =>
1443
+ row.source === 'execution' || row.source === 'session'
1444
+ ? [{ source: row.source, state: typeof row.state === 'string' ? row.state : '' }]
1445
+ : [],
1446
+ )
1447
+ return {
1448
+ busy,
1449
+ busyTotal: typeof reply.busyTotal === 'number' ? reply.busyTotal : busy.length,
1450
+ registries,
1451
+ }
1452
+ }
1453
+
1317
1454
  /** What the guest stopped, and how widely it was allowed to look. */
1318
1455
  export interface KubernetesQuiesceReport {
1319
1456
  /** Every process signalled, with the last signal it was actually sent. */
@@ -1479,6 +1616,8 @@ export class KubernetesAgentTransport {
1479
1616
  private readonly refreshHandle?: (signal?: AbortSignal) => Promise<KubernetesAgentHandle>
1480
1617
  /** Simple pass-through operations share one transport instance. */
1481
1618
  private wire: VsockAgentTransport
1619
+ /** What {@link pinnedTo} builds a sibling from: everything but the hooks that follow or report a pod. */
1620
+ private readonly siblingOptions: KubernetesTransportOptions
1482
1621
 
1483
1622
  constructor(handle: KubernetesAgentHandle, options: KubernetesTransportOptions = {}) {
1484
1623
  const { onTiming, refreshHandle, onGuestReply, ...transportOptions } = options
@@ -1508,9 +1647,34 @@ export class KubernetesAgentTransport {
1508
1647
  }
1509
1648
  this.onTiming = onTiming
1510
1649
  this.refreshHandle = refreshHandle
1650
+ this.siblingOptions = { ...transportOptions, ...(onTiming !== undefined ? { onTiming } : {}) }
1511
1651
  this.wire = new VsockAgentTransport(handle, this.wireOptions(handle))
1512
1652
  }
1513
1653
 
1654
+ /**
1655
+ * A separate transport for the guest this one is bound to RIGHT NOW,
1656
+ * whose every request is pinned to the agent process `guestBootId`.
1657
+ *
1658
+ * Never rebinds: it has no `refreshHandle`, so a refused token or a dead
1659
+ * address ends the call where it stands, and it holds its own copy of the
1660
+ * handle, so a rebind of THIS transport later cannot move it onto another
1661
+ * pod. It reports no guest identity back either, so a pinned call cannot
1662
+ * move the observer a workspace uses to detect restarts. Pair it with
1663
+ * {@link supportsPinnedGuest}: against an agent without the feature the
1664
+ * `pinned` op is refused as unknown, and nothing is dispatched.
1665
+ */
1666
+ pinnedTo(guestBootId: string): KubernetesAgentTransport {
1667
+ return new KubernetesAgentTransport(
1668
+ { ...this.handle },
1669
+ { ...this.siblingOptions, pinnedGuestBootId: guestBootId },
1670
+ )
1671
+ }
1672
+
1673
+ /** Whether the guest serves the `pinned` op; see {@link PINNED_GUEST_FEATURE}. */
1674
+ async supportsPinnedGuest(signal?: AbortSignal): Promise<boolean> {
1675
+ return (await this.wire.guestFeatures(signal)).includes(PINNED_GUEST_FEATURE)
1676
+ }
1677
+
1514
1678
  /**
1515
1679
  * The shared transport's options for ONE wire, with the reply observer
1516
1680
  * bound to the pod that wire is talking to.
@@ -2164,17 +2328,31 @@ export class KubernetesAgentTransport {
2164
2328
  * process would not stop, and the reply names its pid.
2165
2329
  */
2166
2330
  async quiesce(
2167
- options: { readonly graceMs?: number } = {},
2331
+ options: { readonly graceMs?: number; readonly idleOnly?: boolean } = {},
2168
2332
  signal?: AbortSignal,
2169
2333
  ): Promise<KubernetesQuiesceReport> {
2334
+ const idleOnly = options.idleOnly === true
2335
+ if (idleOnly && options.graceMs !== undefined) {
2336
+ throw new TypeError(
2337
+ 'kubernetes: quiesce({ onlyIfIdle: true }) signals nothing, so graceMs has no meaning with it',
2338
+ )
2339
+ }
2170
2340
  await this.assertQuiesceSupported(signal)
2341
+ // Checked BEFORE anything is sent: an agent without the feature
2342
+ // ignores `idleOnly` and would run the stopping quiesce.
2343
+ if (idleOnly && !(await this.supportsQuiesceIdle(signal))) {
2344
+ throw this.quiesceIdleUnsupported()
2345
+ }
2171
2346
  const reply = (await this.withRebind(
2172
2347
  async () =>
2173
2348
  await requestChecked(
2174
2349
  this.wire,
2175
2350
  {
2176
2351
  op: 'quiesce',
2177
- body: { ...(options.graceMs !== undefined ? { graceMs: options.graceMs } : {}) },
2352
+ body: {
2353
+ ...(idleOnly ? { idleOnly: true } : {}),
2354
+ ...(options.graceMs !== undefined ? { graceMs: options.graceMs } : {}),
2355
+ },
2178
2356
  },
2179
2357
  signal,
2180
2358
  ),
@@ -2186,6 +2364,15 @@ export class KubernetesAgentTransport {
2186
2364
  // is one image, not two, so it is the unsupported error rather than a
2187
2365
  // second name for the same fact — see {@link assertQuiesceSupported}.
2188
2366
  if (refusal.startsWith('unknown_op')) throw this.quiesceUnsupported()
2367
+ if (idleOnly && refusal === 'quiesce_busy') {
2368
+ const seen = busyDetail(reply)
2369
+ throw new KubernetesQuiesceBusyError(
2370
+ seen.busy,
2371
+ seen.busyTotal,
2372
+ seen.registries,
2373
+ `kubernetes: the guest is not idle (${seen.busyTotal} process(es), ${seen.registries.length} registry entr${seen.registries.length === 1 ? 'y' : 'ies'}). Nothing was signalled, marked or stopped; retry later.`,
2374
+ )
2375
+ }
2189
2376
  const detail = typeof reply.message === 'string' ? `: ${reply.message}` : ''
2190
2377
  throw new KubernetesQuiesceUnconfirmedError(
2191
2378
  refusal,
@@ -2274,6 +2461,18 @@ export class KubernetesAgentTransport {
2274
2461
  return (await this.wire.guestFeatures(signal)).includes(QUIESCE_FEATURE)
2275
2462
  }
2276
2463
 
2464
+ /** Whether the guest's `quiesce` accepts `idleOnly`; see {@link QUIESCE_IDLE_FEATURE}. */
2465
+ async supportsQuiesceIdle(signal?: AbortSignal): Promise<boolean> {
2466
+ return (await this.wire.guestFeatures(signal)).includes(QUIESCE_IDLE_FEATURE)
2467
+ }
2468
+
2469
+ private quiesceIdleUnsupported(): KubernetesQuiesceUnsupportedError {
2470
+ return new KubernetesQuiesceUnsupportedError(
2471
+ QUIESCE_IDLE_FEATURE,
2472
+ `kubernetes: this workspace's guest agent does not advertise the '${QUIESCE_IDLE_FEATURE}' healthz feature, so it would ignore the idle-only request and run the ordinary quiesce, which signals and kills what is running. Nothing was sent. Rebuild the workspace image from this Namzu release, or call quiesce() without onlyIfIdle if stopping the work is acceptable.`,
2473
+ )
2474
+ }
2475
+
2277
2476
  private async assertQuiesceSupported(signal?: AbortSignal): Promise<void> {
2278
2477
  if (await this.supportsQuiesce(signal)) return
2279
2478
  throw this.quiesceUnsupported()