@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
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Allocating a workspace without adopting or waking anything, bound to an
3
+ * allocation the host recorded first.
4
+ *
5
+ * `createKubernetesWorkspace` is create-or-adopt: a 409 turns into "this is
6
+ * mine from before", and the pod may be resumed before it returns. A host
7
+ * that persists its intent BEFORE creating something durable, and has to
8
+ * prove afterwards that what it finds is exactly what it created, needs the
9
+ * opposite verbs:
10
+ *
11
+ * - {@link resolveKubernetesExecutionProfile} — what a workspace created
12
+ * from this configuration would run, as content plus one fingerprint.
13
+ * Creates nothing. The host persists the result, then allocates.
14
+ * - {@link allocateKubernetesWorkspace} — ONE create request. A same-name
15
+ * object is a typed conflict and is never touched; the pod is never
16
+ * resumed; the answer is an acknowledgement carrying the new Sandbox's
17
+ * uid, not readiness.
18
+ * - {@link recoverKubernetesWorkspaceAllocation} — the only way out of a
19
+ * create whose reply never arrived. Read-only.
20
+ *
21
+ * ## Fingerprint canonicalisation
22
+ *
23
+ * The execution profile fingerprint is `sha256:<hex>` of
24
+ * `JSON.stringify(canonical(content))`, where `canonical` sorts the keys of
25
+ * every object by UTF-16 code unit, drops members whose value is
26
+ * `undefined`, keeps arrays in order, and `JSON.stringify` adds no
27
+ * whitespace. `content` is {@link KubernetesExecutionProfile.content}.
28
+ */
29
+
30
+ import {
31
+ KubernetesExecutionProfileDriftError,
32
+ type KubernetesObservedAllocation,
33
+ type KubernetesWorkspaceAllocation,
34
+ KubernetesWorkspaceAllocationConflictError,
35
+ type KubernetesWorkspaceAllocationMismatch,
36
+ assertAllocation,
37
+ firstAllocationMismatch,
38
+ observedAllocation,
39
+ } from './allocation.js'
40
+ import {
41
+ assertEgressPolicyIsEnforceable,
42
+ assertEgressProfileIsUsable,
43
+ assertPerSandboxEgressIsUsable,
44
+ composeAdditionalPodLabels,
45
+ perSandboxEgressLabelKey,
46
+ } from './egress-policy.js'
47
+ import {
48
+ DEFAULT_AGENT_PORT,
49
+ type KubernetesBackendInternalConfig,
50
+ buildEgressBoundary,
51
+ buildIngressVerifier,
52
+ buildSandboxBody,
53
+ clientAccess,
54
+ clientOptions,
55
+ readSandboxTemplate,
56
+ sandboxPodLabels,
57
+ } from './index.js'
58
+ import { ingressVerificationEnabled, resolveIngressEngine } from './ingress-policy.js'
59
+ import {
60
+ KubernetesAlreadyGoneError,
61
+ KubernetesApiError,
62
+ KubernetesApiTimeoutError,
63
+ type KubernetesClient,
64
+ KubernetesConflictError,
65
+ createKubernetesClient,
66
+ } from './k8s-client.js'
67
+ import {
68
+ ALLOCATION_ID_ANNOTATION_KEY,
69
+ ALLOCATION_SCOPE_ANNOTATION_KEY,
70
+ EXECUTION_PROFILE_ANNOTATION_KEY,
71
+ HOLDER_EPOCH_ANNOTATION_KEY,
72
+ POD_TEMPLATE_HASH_ANNOTATION_KEY,
73
+ type SandboxResource,
74
+ canonicalFingerprint,
75
+ readHolderEpoch,
76
+ sandboxCollectionPath,
77
+ sandboxPath,
78
+ } from './objects.js'
79
+ import { buildAdmissionFence } from './per-sandbox-policy.js'
80
+ import {
81
+ KUBERNETES_PER_SANDBOX_PROFILE_VARIANT,
82
+ perSandboxWorkspaceLabels,
83
+ withoutPerSandboxLabel,
84
+ } from './workspace-per-sandbox.js'
85
+ import {
86
+ KubernetesWorkspacePreconditionError,
87
+ assertBlockModeWorkspaceDisk,
88
+ assertHolderEpoch,
89
+ buildPodTemplateRefresh,
90
+ workspaceSandboxName,
91
+ } from './workspace.js'
92
+
93
+ /** Bumped when the shape of {@link KubernetesExecutionProfile.content} changes. */
94
+ const EXECUTION_PROFILE_CONTENT_VERSION = 1
95
+
96
+ /** `resolveKubernetesExecutionProfile`'s options. */
97
+ export interface KubernetesExecutionProfileOptions {
98
+ /** The SandboxTemplate to resolve; defaults to `config.sandboxTemplateName`. */
99
+ readonly sandboxTemplateName?: string
100
+ /**
101
+ * The workspace the profile is for. Only read when
102
+ * `config.egress.perSandbox` is configured: the per-sandbox selector
103
+ * label's VALUE is the Sandbox's name, so `podTemplateHash` is per
104
+ * workspace there. Without it that hash is taken over the template
105
+ * WITHOUT the label, and is not the revision a handle reports. The
106
+ * fingerprint never depends on it.
107
+ */
108
+ readonly workspaceId?: string
109
+ readonly signal?: AbortSignal
110
+ }
111
+
112
+ /**
113
+ * What a workspace created from this configuration would run.
114
+ *
115
+ * - `fingerprint` — `sha256:<hex>` over {@link content}; the value stored
116
+ * in the profile annotation and the one `expectedAllocation.executionProfile`
117
+ * carries.
118
+ * - `podTemplateHash` — the revision of the pod template alone, the same
119
+ * value `templateRevision` reports on a handle.
120
+ * - `variant` — `'core/no-network+per-sandbox'` when
121
+ * `config.egress.perSandbox` is configured over a core `no-network`
122
+ * baseline, and ABSENT otherwise (over another baseline the content still
123
+ * changes the fingerprint but claims no name), so the profile of every
124
+ * configuration that predates the option keeps its fingerprint byte for
125
+ * byte. Present only after the admission fence that
126
+ * bounds the host's per-sandbox writes was read and found to enforce:
127
+ * resolving the profile is how a host learns it is admitted.
128
+ * - `content` — what was fingerprinted, so a host can persist exactly it:
129
+ * the pod template after this backend's overlays, the template's volume
130
+ * claim templates, the egress policy as translated from `config.egress`
131
+ * (kind, name, manifest — config-derived, not read from the cluster), and
132
+ * the ingress verification settings.
133
+ */
134
+ export interface KubernetesExecutionProfile {
135
+ readonly fingerprint: string
136
+ readonly podTemplateHash: string
137
+ readonly sandboxTemplateName: string
138
+ readonly variant?: typeof KUBERNETES_PER_SANDBOX_PROFILE_VARIANT
139
+ readonly content: Readonly<Record<string, unknown>>
140
+ }
141
+
142
+ interface ProfilePlan {
143
+ readonly client: KubernetesClient
144
+ readonly templateName: string
145
+ readonly profile: KubernetesExecutionProfile
146
+ readonly podLabels: Readonly<Record<string, string>>
147
+ readonly workspacePodLabels: Readonly<Record<string, string>>
148
+ readonly template: Awaited<ReturnType<typeof readSandboxTemplate>>
149
+ readonly boundary: ReturnType<typeof buildEgressBoundary>
150
+ }
151
+
152
+ async function planProfile(
153
+ config: KubernetesBackendInternalConfig,
154
+ sandboxTemplateName: string | undefined,
155
+ signal: AbortSignal | undefined,
156
+ workspaceName?: string,
157
+ ): Promise<ProfilePlan> {
158
+ assertPerSandboxEgressIsUsable(config.egress)
159
+ assertEgressProfileIsUsable(config.egress)
160
+ if (config.egress) {
161
+ assertEgressPolicyIsEnforceable(
162
+ config.egress.policy,
163
+ config.egress.engine ?? 'core',
164
+ config.egress.ciliumNarrowing,
165
+ )
166
+ }
167
+ const client = createKubernetesClient(clientAccess(config), clientOptions(config))
168
+ const templateName = sandboxTemplateName ?? config.sandboxTemplateName
169
+ const boundary = buildEgressBoundary(client, config, templateName)
170
+ const template = await readSandboxTemplate(client, config.namespace, templateName, signal)
171
+ assertBlockModeWorkspaceDisk(
172
+ `SandboxTemplate ${templateName} in namespace ${config.namespace}`,
173
+ template.podTemplate,
174
+ template.volumeClaimTemplates,
175
+ )
176
+ // Per-sandbox egress: the profile is admitted only when the fence that
177
+ // bounds the host's writes verifies, read here so a configuration whose
178
+ // fence is missing or inert never yields a fingerprint to persist.
179
+ const perSandbox = config.egress?.perSandbox
180
+ if (perSandbox !== undefined) {
181
+ await buildAdmissionFence(client, perSandbox, config.namespace).assertApplied(signal)
182
+ }
183
+ const perSandboxKey = perSandboxEgressLabelKey(config.egress)
184
+ // The variant is named for what it is: a core `no-network` baseline plus
185
+ // per-sandbox widening. Over any other baseline `perSandbox` still
186
+ // changes the fingerprint (it is in the content) but claims no name, so a
187
+ // host that admits only the named variant refuses the rest.
188
+ const carriesVariant =
189
+ perSandbox !== undefined &&
190
+ config.egress?.policy.kind === 'no-network' &&
191
+ (config.egress.engine ?? 'core') === 'core'
192
+ // The label's value is the Sandbox's name, which the profile does not
193
+ // know: the fingerprinted template is built without it, and the body and
194
+ // the revision stamped on the object are built with it.
195
+ const stableLabels = composeAdditionalPodLabels(config.egress)
196
+ const podLabels = composeAdditionalPodLabels(
197
+ config.egress,
198
+ perSandboxKey !== undefined && workspaceName !== undefined
199
+ ? { [perSandboxKey]: workspaceName }
200
+ : undefined,
201
+ )
202
+ const stableRefresh = buildPodTemplateRefresh(
203
+ template,
204
+ templateName,
205
+ config.runtimeClassName,
206
+ stableLabels,
207
+ )
208
+ const refresh =
209
+ perSandboxKey !== undefined && workspaceName !== undefined
210
+ ? buildPodTemplateRefresh(template, templateName, config.runtimeClassName, podLabels)
211
+ : stableRefresh
212
+ const translated = await boundary?.translatedPolicy()
213
+ const ingressOn = ingressVerificationEnabled(config.ingress)
214
+ const content: Record<string, unknown> = {
215
+ version: EXECUTION_PROFILE_CONTENT_VERSION,
216
+ namespace: config.namespace,
217
+ sandboxTemplateName: templateName,
218
+ podTemplate: stableRefresh.podTemplate,
219
+ volumeClaimTemplates: stableRefresh.volumeClaimTemplates ?? null,
220
+ egress:
221
+ translated === undefined
222
+ ? null
223
+ : {
224
+ kind: translated.kind,
225
+ policyKind: translated.policyKind,
226
+ name: translated.name,
227
+ manifest: translated.manifest,
228
+ },
229
+ ingress: ingressOn
230
+ ? {
231
+ verified: true,
232
+ engine: resolveIngressEngine(config.ingress, config.egress?.engine),
233
+ agentPort: config.agentPort ?? DEFAULT_AGENT_PORT,
234
+ }
235
+ : { verified: false },
236
+ // Absent without perSandbox, so an existing profile's fingerprint is
237
+ // unchanged. With it, the variant name and everything the host's
238
+ // per-sandbox writes depend on: a fence renamed, a different
239
+ // permitted-hosts ConfigMap or another selector key is another
240
+ // profile, and a workspace allocated under one is not adopted under
241
+ // the other.
242
+ ...(perSandbox !== undefined && perSandboxKey !== undefined
243
+ ? {
244
+ ...(carriesVariant ? { variant: KUBERNETES_PER_SANDBOX_PROFILE_VARIANT } : {}),
245
+ perSandbox: {
246
+ engine: perSandbox.engine,
247
+ labelKey: perSandboxKey,
248
+ admissionPolicyName: perSandbox.admissionPolicyName,
249
+ admissionPolicyBindingName:
250
+ perSandbox.admissionPolicyBindingName ?? `${perSandbox.admissionPolicyName}-binding`,
251
+ hostIdentity: perSandbox.hostIdentity ?? null,
252
+ permittedHostsParam: perSandbox.permittedHostsParam ?? null,
253
+ narrowing: perSandbox.narrowing ?? null,
254
+ },
255
+ }
256
+ : {}),
257
+ }
258
+ return {
259
+ client,
260
+ templateName,
261
+ template,
262
+ boundary,
263
+ podLabels,
264
+ workspacePodLabels: sandboxPodLabels(template, templateName, podLabels),
265
+ profile: {
266
+ fingerprint: canonicalFingerprint(content),
267
+ podTemplateHash: refresh.hash,
268
+ sandboxTemplateName: templateName,
269
+ ...(carriesVariant ? { variant: KUBERNETES_PER_SANDBOX_PROFILE_VARIANT } : {}),
270
+ content,
271
+ },
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Resolve and fingerprint what a workspace created from `config` would run —
277
+ * the pod template with this backend's overlays, the template's disks, the
278
+ * egress policy translated from `config.egress` and the ingress settings —
279
+ * without creating, waking or writing anything. One GET: the
280
+ * SandboxTemplate.
281
+ *
282
+ * The host persists the result, then passes `fingerprint` as
283
+ * `allocation.executionProfile` to {@link allocateKubernetesWorkspace}, which
284
+ * refuses to create if the profile resolves differently by then.
285
+ */
286
+ export async function resolveKubernetesExecutionProfile(
287
+ config: KubernetesBackendInternalConfig,
288
+ options: KubernetesExecutionProfileOptions = {},
289
+ ): Promise<KubernetesExecutionProfile> {
290
+ options.signal?.throwIfAborted()
291
+ return (
292
+ await planProfile(
293
+ config,
294
+ options.sandboxTemplateName,
295
+ options.signal,
296
+ options.workspaceId !== undefined ? workspaceSandboxName(options.workspaceId) : undefined,
297
+ )
298
+ ).profile
299
+ }
300
+
301
+ /** {@link allocateKubernetesWorkspace}'s options. */
302
+ export interface KubernetesWorkspaceAllocateOptions {
303
+ readonly workspaceId: string
304
+ readonly allocation: KubernetesWorkspaceAllocation
305
+ /** The holder epoch to create the workspace under; see `createKubernetesWorkspace`. */
306
+ readonly epoch?: number
307
+ readonly sandboxTemplateName?: string
308
+ readonly signal?: AbortSignal
309
+ }
310
+
311
+ /**
312
+ * Acknowledgement that the API server accepted the create. It says the
313
+ * object exists and which one it is; it does not say the pod is scheduled,
314
+ * the disk is bound or the guest answers — open it with
315
+ * `openKubernetesWorkspace({ waitForReady: true })` for that.
316
+ */
317
+ export interface KubernetesWorkspaceAllocationReceipt {
318
+ readonly workspaceId: string
319
+ readonly sandboxName: string
320
+ readonly namespace: string
321
+ /** The created Sandbox's `metadata.uid`. */
322
+ readonly sandboxUid: string
323
+ /**
324
+ * What the object carries. From `allocate` the profile fingerprint is
325
+ * always present; from recovery it is whatever the annotation holds.
326
+ */
327
+ readonly allocation: KubernetesWorkspaceAllocation
328
+ readonly epoch?: number
329
+ /** The pod-template revision stored with the object. */
330
+ readonly templateRevision: string
331
+ }
332
+
333
+ /** Why a create's outcome is unknown. */
334
+ export type KubernetesAllocationUnknownReason =
335
+ | 'timeout'
336
+ | 'connection'
337
+ | 'server-error'
338
+ | 'aborted'
339
+ | 'unreadable-reply'
340
+
341
+ /**
342
+ * The create request was sent and no usable reply came back, so the object
343
+ * may or may not exist. The request was NOT retried and will not be: resolve
344
+ * it with {@link recoverKubernetesWorkspaceAllocation}.
345
+ */
346
+ export interface KubernetesWorkspaceAllocationUnknown {
347
+ readonly outcome: 'unknown'
348
+ readonly reason: KubernetesAllocationUnknownReason
349
+ readonly workspaceId: string
350
+ readonly sandboxName: string
351
+ /** What was sent, including the resolved profile fingerprint. */
352
+ readonly allocation: Required<KubernetesWorkspaceAllocation>
353
+ readonly epoch?: number
354
+ /** The failure that made it unknown. */
355
+ readonly error: unknown
356
+ }
357
+
358
+ export type KubernetesWorkspaceAllocationResult =
359
+ | { readonly outcome: 'created'; readonly receipt: KubernetesWorkspaceAllocationReceipt }
360
+ | KubernetesWorkspaceAllocationUnknown
361
+
362
+ function unknownReason(
363
+ err: unknown,
364
+ signal: AbortSignal | undefined,
365
+ ): KubernetesAllocationUnknownReason | undefined {
366
+ if (err instanceof KubernetesApiTimeoutError) return 'timeout'
367
+ if (signal?.aborted === true) return 'aborted'
368
+ if (err instanceof KubernetesApiError) {
369
+ if (err.transport === 'connect') return 'connection'
370
+ if (err.status !== undefined && err.status >= 500) return 'server-error'
371
+ }
372
+ return undefined
373
+ }
374
+
375
+ /**
376
+ * Create a workspace Sandbox, exactly once, bound to `allocation`. Never
377
+ * adopts, never resumes, never retries.
378
+ *
379
+ * Before the request, and read-only: the same egress and ingress checks
380
+ * `createKubernetesWorkspace` makes, the block-disk check, and — when
381
+ * `allocation.executionProfile` is given — that the profile resolves to it
382
+ * ({@link KubernetesExecutionProfileDriftError} otherwise). A refusal there
383
+ * sends no create.
384
+ *
385
+ * Then ONE POST, which carries the allocation annotations (see the
386
+ * `*_ANNOTATION_KEY` constants), the holder epoch when one is passed, and
387
+ * the pod-template revision:
388
+ *
389
+ * - 201 → `{ outcome: 'created', receipt }`.
390
+ * - 409 → {@link KubernetesWorkspaceAllocationConflictError}; the existing
391
+ * object was not read, touched or woken.
392
+ * - a timeout, a reset connection, a 5xx or a caller abort after the request
393
+ * was sent, or a reply with no uid → `{ outcome: 'unknown' }`. The POST is
394
+ * not repeated.
395
+ * - any other rejection (401, 403, 404, 4xx) rejects as itself: the server
396
+ * answered, and nothing was created.
397
+ */
398
+ export async function allocateKubernetesWorkspace(
399
+ config: KubernetesBackendInternalConfig,
400
+ options: KubernetesWorkspaceAllocateOptions,
401
+ ): Promise<KubernetesWorkspaceAllocationResult> {
402
+ options.signal?.throwIfAborted()
403
+ const { workspaceId, signal } = options
404
+ assertAllocation(options.allocation, 'allocateKubernetesWorkspace')
405
+ const epoch = assertHolderEpoch(options.epoch, 'allocateKubernetesWorkspace')
406
+ const name = workspaceSandboxName(workspaceId)
407
+ const plan = await planProfile(config, options.sandboxTemplateName, signal, name)
408
+ const { fingerprint } = plan.profile
409
+ if (
410
+ options.allocation.executionProfile !== undefined &&
411
+ options.allocation.executionProfile !== fingerprint
412
+ ) {
413
+ throw new KubernetesExecutionProfileDriftError(
414
+ workspaceId,
415
+ options.allocation.executionProfile,
416
+ fingerprint,
417
+ )
418
+ }
419
+ // The same pre-create checks the create-or-adopt path makes, against the
420
+ // labels this POST stamps. All GETs; a refusal leaves nothing behind.
421
+ const subject = `to allocate workspace ${workspaceId} as Sandbox ${name} in namespace ${config.namespace}`
422
+ if (config.egress) await plan.boundary?.verifyNamedObject(signal)
423
+ const verifyIngress = buildIngressVerifier(plan.client, config)
424
+ if (verifyIngress !== undefined) await verifyIngress(plan.workspacePodLabels, subject, signal)
425
+ // Without the per-sandbox label, for the reason `createKubernetesWorkspace`
426
+ // gives: the only policy selecting by it is this workspace's own.
427
+ await plan.boundary?.verifyUnion(
428
+ withoutPerSandboxLabel(plan.workspacePodLabels, perSandboxWorkspaceLabels(config.egress, name)),
429
+ subject,
430
+ signal,
431
+ )
432
+
433
+ const stored: Required<KubernetesWorkspaceAllocation> = {
434
+ id: options.allocation.id,
435
+ scopeFingerprint: options.allocation.scopeFingerprint,
436
+ executionProfile: fingerprint,
437
+ }
438
+ const body = buildSandboxBody({
439
+ namespace: config.namespace,
440
+ name,
441
+ template: plan.template,
442
+ sandboxTemplateName: plan.templateName,
443
+ podLabels: plan.podLabels,
444
+ annotations: {
445
+ ...(epoch !== undefined ? { [HOLDER_EPOCH_ANNOTATION_KEY]: String(epoch) } : {}),
446
+ [POD_TEMPLATE_HASH_ANNOTATION_KEY]: plan.profile.podTemplateHash,
447
+ [ALLOCATION_ID_ANNOTATION_KEY]: stored.id,
448
+ [ALLOCATION_SCOPE_ANNOTATION_KEY]: stored.scopeFingerprint,
449
+ [EXECUTION_PROFILE_ANNOTATION_KEY]: stored.executionProfile,
450
+ },
451
+ ...(config.runtimeClassName !== undefined ? { runtimeClassName: config.runtimeClassName } : {}),
452
+ })
453
+ const unknown = (
454
+ reason: KubernetesAllocationUnknownReason,
455
+ error: unknown,
456
+ ): KubernetesWorkspaceAllocationResult => ({
457
+ outcome: 'unknown',
458
+ reason,
459
+ workspaceId,
460
+ sandboxName: name,
461
+ allocation: stored,
462
+ ...(epoch !== undefined ? { epoch } : {}),
463
+ error,
464
+ })
465
+
466
+ let created: SandboxResource | undefined
467
+ try {
468
+ // The one and only create. `client.request` does not retry.
469
+ created = await plan.client.request<SandboxResource>(
470
+ 'POST',
471
+ sandboxCollectionPath(config.namespace),
472
+ body,
473
+ signal,
474
+ )
475
+ } catch (err) {
476
+ if (err instanceof KubernetesConflictError) {
477
+ throw new KubernetesWorkspaceAllocationConflictError(workspaceId, name)
478
+ }
479
+ const reason = unknownReason(err, signal)
480
+ if (reason === undefined) throw err
481
+ return unknown(reason, err)
482
+ }
483
+ const sandboxUid = created?.metadata?.uid
484
+ if (typeof sandboxUid !== 'string' || sandboxUid === '') {
485
+ return unknown('unreadable-reply', new Error('the create reply carried no metadata.uid'))
486
+ }
487
+ return {
488
+ outcome: 'created',
489
+ receipt: {
490
+ workspaceId,
491
+ sandboxName: name,
492
+ namespace: config.namespace,
493
+ sandboxUid,
494
+ allocation: stored,
495
+ ...(epoch !== undefined ? { epoch } : {}),
496
+ templateRevision: plan.profile.podTemplateHash,
497
+ },
498
+ }
499
+ }
500
+
501
+ /** {@link recoverKubernetesWorkspaceAllocation}'s options. */
502
+ export interface KubernetesWorkspaceAllocationRecoverOptions {
503
+ readonly workspaceId: string
504
+ /** The allocation that was sent; `executionProfile` is compared when present. */
505
+ readonly allocation: KubernetesWorkspaceAllocation
506
+ /** When given, a workspace held at a higher epoch refuses the read. */
507
+ readonly epoch?: number
508
+ readonly signal?: AbortSignal
509
+ }
510
+
511
+ /**
512
+ * What a read-only look found after an unknown create.
513
+ *
514
+ * - `created` — a Sandbox stands there carrying exactly the allocation;
515
+ * `receipt` is the one the lost reply would have been.
516
+ * - `absent` — no Sandbox stands there at the moment of the read. A create
517
+ * still in flight at the API server could land afterwards, so this is a
518
+ * point-in-time answer: allocate again with the SAME allocation is safe
519
+ * (a late landing makes it a typed conflict, and recovery then says
520
+ * `created`), a different allocation under this id is not.
521
+ * - `different` — something else stands there; `mismatch` says what differs.
522
+ */
523
+ export type KubernetesWorkspaceAllocationRecovery =
524
+ | { readonly state: 'created'; readonly receipt: KubernetesWorkspaceAllocationReceipt }
525
+ | { readonly state: 'absent'; readonly workspaceId: string; readonly sandboxName: string }
526
+ | {
527
+ readonly state: 'different'
528
+ readonly workspaceId: string
529
+ readonly sandboxName: string
530
+ readonly mismatch: KubernetesWorkspaceAllocationMismatch
531
+ readonly observed: KubernetesObservedAllocation
532
+ readonly sandboxUid: string | undefined
533
+ }
534
+
535
+ /**
536
+ * Settle an unknown create by reading: one GET of the Sandbox, compared with
537
+ * the expected allocation. Never creates, wakes or writes. A read that did
538
+ * not answer rejects as itself — it is never reported as `absent`.
539
+ */
540
+ export async function recoverKubernetesWorkspaceAllocation(
541
+ config: KubernetesBackendInternalConfig,
542
+ options: KubernetesWorkspaceAllocationRecoverOptions,
543
+ ): Promise<KubernetesWorkspaceAllocationRecovery> {
544
+ options.signal?.throwIfAborted()
545
+ const { workspaceId } = options
546
+ assertAllocation(options.allocation, 'recoverKubernetesWorkspaceAllocation')
547
+ const epoch = assertHolderEpoch(options.epoch, 'recoverKubernetesWorkspaceAllocation')
548
+ const name = workspaceSandboxName(workspaceId)
549
+ const client = createKubernetesClient(clientAccess(config), clientOptions(config))
550
+ let sandbox: SandboxResource | undefined
551
+ try {
552
+ sandbox = await client.request<SandboxResource>(
553
+ 'GET',
554
+ sandboxPath(config.namespace, name),
555
+ undefined,
556
+ options.signal,
557
+ )
558
+ } catch (err) {
559
+ if (!(err instanceof KubernetesAlreadyGoneError)) throw err
560
+ }
561
+ if (sandbox === undefined) return { state: 'absent', workspaceId, sandboxName: name }
562
+ if (epoch !== undefined) {
563
+ const held = readHolderEpoch(sandbox.metadata)
564
+ if (held.epoch === undefined || held.epoch > epoch) {
565
+ throw new KubernetesWorkspacePreconditionError(
566
+ 'recoverKubernetesWorkspaceAllocation',
567
+ workspaceId,
568
+ name,
569
+ epoch,
570
+ held.epoch,
571
+ held.annotation,
572
+ )
573
+ }
574
+ }
575
+ const mismatch = firstAllocationMismatch(sandbox, options.allocation)
576
+ const sandboxUid = sandbox.metadata?.uid
577
+ if (mismatch !== undefined || typeof sandboxUid !== 'string' || sandboxUid === '') {
578
+ return {
579
+ state: 'different',
580
+ workspaceId,
581
+ sandboxName: name,
582
+ mismatch: mismatch?.kind ?? 'allocation-id',
583
+ observed: observedAllocation(sandbox),
584
+ sandboxUid,
585
+ }
586
+ }
587
+ const observed = observedAllocation(sandbox)
588
+ return {
589
+ state: 'created',
590
+ receipt: {
591
+ workspaceId,
592
+ sandboxName: name,
593
+ namespace: config.namespace,
594
+ sandboxUid,
595
+ allocation: {
596
+ id: options.allocation.id,
597
+ scopeFingerprint: options.allocation.scopeFingerprint,
598
+ ...(observed.executionProfile !== undefined
599
+ ? { executionProfile: observed.executionProfile }
600
+ : {}),
601
+ },
602
+ ...(epoch !== undefined ? { epoch } : {}),
603
+ // Present: the template-content check above refuses an object without it.
604
+ templateRevision: sandbox.metadata?.annotations?.[POD_TEMPLATE_HASH_ANNOTATION_KEY] ?? '',
605
+ },
606
+ }
607
+ }