@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.
- package/CHANGELOG.md +47 -0
- package/README.md +20 -7
- package/dist/backends/firecracker/protocol.d.ts +46 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +31 -0
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +15 -1
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +16 -0
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/allocation.d.ts +115 -0
- package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
- package/dist/backends/kubernetes/allocation.js +130 -0
- package/dist/backends/kubernetes/allocation.js.map +1 -0
- package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +81 -41
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/identity.d.ts +129 -0
- package/dist/backends/kubernetes/identity.d.ts.map +1 -1
- package/dist/backends/kubernetes/identity.js +150 -0
- package/dist/backends/kubernetes/identity.js.map +1 -1
- package/dist/backends/kubernetes/index.d.ts +23 -1
- package/dist/backends/kubernetes/index.d.ts.map +1 -1
- package/dist/backends/kubernetes/index.js +22 -3
- package/dist/backends/kubernetes/index.js.map +1 -1
- package/dist/backends/kubernetes/objects.d.ts +76 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -1
- package/dist/backends/kubernetes/objects.js +61 -0
- package/dist/backends/kubernetes/objects.js.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
- package/dist/backends/kubernetes/sandbox.d.ts +4 -9
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
- package/dist/backends/kubernetes/sandbox.js +4 -9
- package/dist/backends/kubernetes/sandbox.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +108 -1
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js +150 -6
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
- package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-allocate.js +332 -0
- package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.js +343 -0
- package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +139 -5
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
- package/dist/backends/kubernetes/workspace.js +434 -36
- package/dist/backends/kubernetes/workspace.js.map +1 -1
- package/dist/index.d.ts +83 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/firecracker/protocol.ts +47 -0
- package/src/backends/firecracker/transport.ts +31 -0
- package/src/backends/kubernetes/allocation.ts +199 -0
- package/src/backends/kubernetes/egress-policy.ts +165 -42
- package/src/backends/kubernetes/identity.ts +210 -0
- package/src/backends/kubernetes/index.ts +61 -2
- package/src/backends/kubernetes/objects.ts +98 -0
- package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
- package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
- package/src/backends/kubernetes/sandbox.ts +4 -9
- package/src/backends/kubernetes/transport.ts +203 -4
- package/src/backends/kubernetes/workspace-allocate.ts +607 -0
- package/src/backends/kubernetes/workspace-observe.ts +590 -0
- package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
- package/src/backends/kubernetes/workspace.ts +655 -34
- 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
|
|
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<
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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))
|
|
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: {
|
|
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()
|