@namzu/sandbox 26.0.0 → 28.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 +62 -7
  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,590 @@
1
+ /**
2
+ * Looking at a retained workspace by its exact identity, without creating,
3
+ * waking or writing anything.
4
+ *
5
+ * `createKubernetesWorkspace` is create-or-adopt: handed an id it POSTs, and
6
+ * on a 409 adopts whatever stands there and resumes it. That is the right
7
+ * verb for "give me a workspace" and the wrong one for a host that restarted
8
+ * and wants to know whether the workspace it recorded last week is still the
9
+ * workspace under that name — the question has to be answered BEFORE a pod is
10
+ * started for what might be a stranger's disk.
11
+ *
12
+ * Three verbs, all GET-only:
13
+ *
14
+ * - {@link inspectKubernetesWorkspace} — what is there, and who holds it.
15
+ * - {@link openKubernetesWorkspace} — authenticate a retained identity, and
16
+ * optionally wait, under one deadline, for its claims and its guest.
17
+ * - {@link reconcileKubernetesWorkspace} — after a restart: live, suspended,
18
+ * absent or replaced.
19
+ *
20
+ * ## Ownership and readiness are separate questions
21
+ *
22
+ * Ownership is decided by uids and by nothing else: the Sandbox's
23
+ * `metadata.uid`, each volume claim's uid, and who owns each claim. A
24
+ * `resourceVersion` that moved, a `Ready` condition that flipped and a
25
+ * condition left over from an earlier generation are the controller working,
26
+ * and none of them is ever read as a change of owner. Readiness is a bounded
27
+ * poll on top of an ownership check that runs again on every pass, so a
28
+ * replacement that lands while waiting is still a refusal.
29
+ */
30
+
31
+ import { OperationDeadline, OperationDeadlineExpired } from '../readiness.js'
32
+ import {
33
+ type KubernetesObservedAllocation,
34
+ type KubernetesWorkspaceAllocation,
35
+ KubernetesWorkspaceAllocationMismatchError,
36
+ assertAllocation,
37
+ firstAllocationMismatch,
38
+ observedAllocation,
39
+ } from './allocation.js'
40
+ import {
41
+ type KubernetesWorkspaceExpectedIdentity,
42
+ type KubernetesWorkspaceIdentity,
43
+ type KubernetesWorkspaceIdentityMismatch,
44
+ KubernetesWorkspaceIdentityMismatchError,
45
+ } from './identity.js'
46
+ import {
47
+ type KubernetesBackendInternalConfig,
48
+ clientAccess,
49
+ clientOptions,
50
+ resolveKubernetesReadiness,
51
+ } from './index.js'
52
+ import {
53
+ KubernetesAlreadyGoneError,
54
+ type KubernetesClient,
55
+ createKubernetesClient,
56
+ } from './k8s-client.js'
57
+ import {
58
+ type KubernetesCondition,
59
+ type PodResource,
60
+ READY_CONDITION,
61
+ type SandboxResource,
62
+ isPodLive,
63
+ persistentVolumeClaimPath,
64
+ podPath,
65
+ readHolderEpoch,
66
+ sandboxPath,
67
+ } from './objects.js'
68
+ import {
69
+ KubernetesWorkspacePreconditionError,
70
+ assertHolderEpoch,
71
+ workspaceSandboxName,
72
+ } from './workspace.js'
73
+
74
+ /** What every verb here takes besides its own fields. */
75
+ export interface KubernetesWorkspaceObservationOptions {
76
+ readonly signal?: AbortSignal
77
+ }
78
+
79
+ /** {@link inspectKubernetesWorkspace}'s options. */
80
+ export interface KubernetesWorkspaceInspectOptions extends KubernetesWorkspaceObservationOptions {
81
+ /**
82
+ * The allocation this host expects. A Sandbox under the id whose
83
+ * allocation annotations or copied pod template differ is refused with
84
+ * `KubernetesWorkspaceAllocationMismatchError`; absence still resolves
85
+ * `undefined`, because there is nothing to compare.
86
+ */
87
+ readonly expectedAllocation?: KubernetesWorkspaceAllocation
88
+ /**
89
+ * The holder epoch this reader acts under. A workspace held at a HIGHER
90
+ * epoch refuses it with `KubernetesWorkspacePreconditionError`: a
91
+ * superseded host must not conclude anything about a workspace that
92
+ * belongs to another process now. Nothing is written either way.
93
+ */
94
+ readonly epoch: number
95
+ }
96
+
97
+ /** What one read-only look at a workspace found. */
98
+ export interface KubernetesWorkspaceInspection {
99
+ readonly workspaceId: string
100
+ readonly sandboxName: string
101
+ readonly operatingMode: 'Running' | 'Suspended'
102
+ /**
103
+ * The disk and the guest, as the cluster reports them now.
104
+ * `volumeClaimUids` holds the claims that exist; `podUid` is the live pod
105
+ * and is `undefined` for a suspended workspace; `guestBootId` is always
106
+ * `undefined` — nothing here dials the guest.
107
+ */
108
+ readonly identity: KubernetesWorkspaceIdentity
109
+ /** The stored holder epoch; `0` for a workspace that never carried one. */
110
+ readonly holderEpoch: number
111
+ /**
112
+ * The allocation annotations as stored; every field `undefined` for a
113
+ * workspace not made by `allocateKubernetesWorkspace`.
114
+ */
115
+ readonly allocation: KubernetesObservedAllocation
116
+ /**
117
+ * The Sandbox reports `Ready` for its CURRENT generation AND a live pod
118
+ * stands under it. A `Ready: True` the controller wrote for an earlier
119
+ * generation does not count.
120
+ */
121
+ readonly ready: boolean
122
+ /** `volumeClaimTemplates` entries whose PVC does not exist (yet). */
123
+ readonly missingVolumeClaims: readonly string[]
124
+ }
125
+
126
+ /** {@link openKubernetesWorkspace}'s options. */
127
+ export interface KubernetesWorkspaceOpenOptions extends KubernetesWorkspaceObservationOptions {
128
+ readonly workspaceId: string
129
+ /** The identity the host retained. The Sandbox uid is required. */
130
+ readonly identity: KubernetesWorkspaceExpectedIdentity
131
+ /** The holder epoch this host acts under — see {@link KubernetesWorkspaceInspectOptions.epoch}. */
132
+ readonly epoch: number
133
+ /**
134
+ * The allocation this host expects, checked after the Sandbox uid: a
135
+ * mismatch is `KubernetesWorkspaceAllocationMismatchError`.
136
+ */
137
+ readonly expectedAllocation?: KubernetesWorkspaceAllocation
138
+ /**
139
+ * Poll, under ONE deadline (`readyTimeoutMs`), until every volume claim
140
+ * exists and the guest is ready. A suspended workspace is returned at
141
+ * once — opening never wakes anything, so there is nothing to wait for.
142
+ * Omitted or `false`, one pass is made and the result says what was
143
+ * missing.
144
+ */
145
+ readonly waitForReady?: boolean
146
+ }
147
+
148
+ /** {@link reconcileKubernetesWorkspace}'s options. */
149
+ export interface KubernetesWorkspaceReconcileOptions extends KubernetesWorkspaceObservationOptions {
150
+ readonly workspaceId: string
151
+ readonly identity: KubernetesWorkspaceExpectedIdentity
152
+ /** When given, a workspace held at a higher epoch refuses the reconcile. */
153
+ readonly epoch?: number
154
+ /**
155
+ * The allocation this host expects. Checked after the identity, so a
156
+ * `replaced` answer still wins; a Sandbox that IS the retained one but
157
+ * carries a different allocation or template content throws
158
+ * `KubernetesWorkspaceAllocationMismatchError`.
159
+ */
160
+ readonly expectedAllocation?: KubernetesWorkspaceAllocation
161
+ }
162
+
163
+ /**
164
+ * What stands under a retained identity now.
165
+ *
166
+ * - `live` — the same Sandbox, `Running`.
167
+ * - `suspended` — the same Sandbox, `Suspended`; its disk is intact.
168
+ * - `absent` — no Sandbox stands under the name at all.
169
+ * - `replaced` — a different Sandbox, or a different volume claim, stands
170
+ * there; `mismatch` says which. The retained workspace is gone.
171
+ */
172
+ export type KubernetesWorkspaceClosureState = 'live' | 'suspended' | 'absent' | 'replaced'
173
+
174
+ export interface KubernetesWorkspaceClosure {
175
+ readonly state: KubernetesWorkspaceClosureState
176
+ readonly workspaceId: string
177
+ readonly sandboxName: string
178
+ /** What was observed. Absent for `absent`; the STRANGER's for `replaced`. */
179
+ readonly identity?: KubernetesWorkspaceIdentity
180
+ /** Why the state is `replaced`. */
181
+ readonly mismatch?: KubernetesWorkspaceIdentityMismatch
182
+ }
183
+
184
+ /** The wait ran out before the workspace was ready. Ownership was never in doubt. */
185
+ export class KubernetesWorkspaceReadyTimeoutError extends Error {
186
+ override readonly name = 'KubernetesWorkspaceReadyTimeoutError'
187
+
188
+ constructor(
189
+ readonly workspaceId: string,
190
+ readonly sandboxName: string,
191
+ readonly timeoutMs: number,
192
+ /** What the last pass was still waiting for. */
193
+ readonly waitingFor: readonly ('volume-claims' | 'ready-condition' | 'pod')[],
194
+ /** The last pass, so a caller can see how far it got. */
195
+ readonly lastObserved: KubernetesWorkspaceInspection,
196
+ ) {
197
+ super(
198
+ `kubernetes: workspace ${workspaceId} (Sandbox ${sandboxName}) was the retained workspace on every pass but was not ready ${timeoutMs}ms later — still waiting for ${waitingFor.join(', ')}. Ownership is not in question: this is the controller not having finished, and nothing was created or woken to hurry it.`,
199
+ )
200
+ }
201
+ }
202
+
203
+ interface ObservedClaim {
204
+ readonly uid: string
205
+ readonly ownedByOtherSandbox: boolean
206
+ }
207
+
208
+ interface Observation {
209
+ readonly sandbox: SandboxResource
210
+ readonly sandboxUid: string | undefined
211
+ readonly claims: ReadonlyMap<string, ObservedClaim>
212
+ readonly claimNames: readonly string[]
213
+ readonly pod: PodResource | undefined
214
+ }
215
+
216
+ async function readOrGone<T>(
217
+ client: KubernetesClient,
218
+ path: string,
219
+ signal: AbortSignal | undefined,
220
+ ): Promise<T | undefined> {
221
+ try {
222
+ return await client.request<T>('GET', path, undefined, signal)
223
+ } catch (err) {
224
+ if (err instanceof KubernetesAlreadyGoneError) return undefined
225
+ throw err
226
+ }
227
+ }
228
+
229
+ /**
230
+ * One GET pass: the Sandbox, each of its claims, the pod behind the name.
231
+ * `undefined` is "no Sandbox"; every other failure travels out as itself, so
232
+ * a read that did not answer is never reported as a workspace that is gone.
233
+ */
234
+ async function observe(
235
+ client: KubernetesClient,
236
+ namespace: string,
237
+ name: string,
238
+ signal: AbortSignal | undefined,
239
+ ): Promise<Observation | undefined> {
240
+ const sandbox = await readOrGone<SandboxResource>(client, sandboxPath(namespace, name), signal)
241
+ if (sandbox === undefined) return undefined
242
+ const sandboxUid = sandbox.metadata?.uid
243
+ const claims = new Map<string, ObservedClaim>()
244
+ const claimNames: string[] = []
245
+ for (const entry of sandbox.spec?.volumeClaimTemplates ?? []) {
246
+ const claimName = entry?.metadata?.name
247
+ if (typeof claimName !== 'string' || claimName === '') continue
248
+ claimNames.push(claimName)
249
+ const claim = await readOrGone<{ metadata?: SandboxResource['metadata'] }>(
250
+ client,
251
+ persistentVolumeClaimPath(namespace, name, claimName),
252
+ signal,
253
+ )
254
+ const uid = claim?.metadata?.uid
255
+ if (typeof uid !== 'string' || uid === '') continue
256
+ const owners = claim?.metadata?.ownerReferences ?? []
257
+ // Only a claim that NAMES owners and names none of them this Sandbox is
258
+ // foreign; one with no owner list says nothing, and refusing it would
259
+ // refuse a cluster that simply does not report them.
260
+ const foreign =
261
+ sandboxUid !== undefined && owners.length > 0 && !owners.some((o) => o?.uid === sandboxUid)
262
+ claims.set(claimName, { uid, ownedByOtherSandbox: foreign })
263
+ }
264
+ const pod = await readOrGone<PodResource>(client, podPath(namespace, name), signal)
265
+ return { sandbox, sandboxUid, claims, claimNames, pod }
266
+ }
267
+
268
+ function operatingModeOf(sandbox: SandboxResource): 'Running' | 'Suspended' {
269
+ return sandbox.spec?.operatingMode === 'Suspended' ? 'Suspended' : 'Running'
270
+ }
271
+
272
+ /**
273
+ * `Ready` is true AND is about the object's current spec. The controller
274
+ * keeps a `Ready: True` it wrote for an earlier generation visible after a
275
+ * spec change, and reading that as the new outcome is how a wake gets
276
+ * reported ready while its pod is still being replaced.
277
+ */
278
+ function isReadyForCurrentGeneration(sandbox: SandboxResource): boolean {
279
+ const condition: KubernetesCondition | undefined = sandbox.status?.conditions?.find(
280
+ (c) => c.type === READY_CONDITION,
281
+ )
282
+ if (condition?.status !== 'True') return false
283
+ const generation = sandbox.metadata?.generation
284
+ const observed = condition.observedGeneration
285
+ return !(generation !== undefined && observed !== undefined && observed < generation)
286
+ }
287
+
288
+ function inspectionOf(
289
+ workspaceId: string,
290
+ name: string,
291
+ seen: Observation,
292
+ ): KubernetesWorkspaceInspection {
293
+ const livePod = isPodLive(seen.pod) ? seen.pod : undefined
294
+ const podUid = livePod?.metadata?.uid
295
+ const uids: Record<string, string> = {}
296
+ for (const [claimName, claim] of seen.claims) uids[claimName] = claim.uid
297
+ const mode = operatingModeOf(seen.sandbox)
298
+ return {
299
+ workspaceId,
300
+ sandboxName: name,
301
+ operatingMode: mode,
302
+ identity: {
303
+ sandboxUid: seen.sandboxUid,
304
+ volumeClaimUids: uids,
305
+ podUid: mode === 'Suspended' ? undefined : podUid,
306
+ guestBootId: undefined,
307
+ },
308
+ holderEpoch: readHolderEpoch(seen.sandbox.metadata).epoch ?? 0,
309
+ allocation: observedAllocation(seen.sandbox),
310
+ ready:
311
+ mode === 'Running' &&
312
+ isReadyForCurrentGeneration(seen.sandbox) &&
313
+ typeof podUid === 'string' &&
314
+ podUid !== '',
315
+ missingVolumeClaims: seen.claimNames.filter((n) => !seen.claims.has(n)),
316
+ }
317
+ }
318
+
319
+ interface Mismatch {
320
+ readonly kind: KubernetesWorkspaceIdentityMismatch
321
+ readonly expected: string | undefined
322
+ readonly actual: string | undefined
323
+ readonly claim?: string
324
+ }
325
+
326
+ /**
327
+ * The first way `seen` is not the retained workspace, or `undefined`.
328
+ *
329
+ * `claimUids` is every claim uid trusted so far: the retained ones, and every
330
+ * one a previous pass of the same wait saw. A claim that is not there yet is
331
+ * not a mismatch; one that is there with a DIFFERENT uid than one already
332
+ * trusted is.
333
+ */
334
+ function firstMismatch(
335
+ retained: KubernetesWorkspaceExpectedIdentity,
336
+ claimUids: Record<string, string>,
337
+ seen: Observation,
338
+ ): Mismatch | undefined {
339
+ if (seen.sandboxUid !== retained.sandboxUid) {
340
+ return { kind: 'sandbox-uid', expected: retained.sandboxUid, actual: seen.sandboxUid }
341
+ }
342
+ for (const [claimName, claim] of seen.claims) {
343
+ const trusted = claimUids[claimName]
344
+ if (trusted !== undefined && trusted !== claim.uid) {
345
+ return { kind: 'volume-claim-uid', expected: trusted, actual: claim.uid, claim: claimName }
346
+ }
347
+ if (claim.ownedByOtherSandbox) {
348
+ return {
349
+ kind: 'volume-claim-owner',
350
+ expected: retained.sandboxUid,
351
+ actual: claim.uid,
352
+ claim: claimName,
353
+ }
354
+ }
355
+ }
356
+ return undefined
357
+ }
358
+
359
+ function assertRetained(identity: KubernetesWorkspaceExpectedIdentity, where: string): void {
360
+ if (typeof identity?.sandboxUid !== 'string' || identity.sandboxUid === '') {
361
+ throw new TypeError(
362
+ `kubernetes: ${where} needs identity.sandboxUid, a non-empty string — without it there is nothing to authenticate.`,
363
+ )
364
+ }
365
+ }
366
+
367
+ function assertEpochAllows(
368
+ operation: string,
369
+ workspaceId: string,
370
+ name: string,
371
+ epoch: number,
372
+ sandbox: SandboxResource,
373
+ ): void {
374
+ const reading = readHolderEpoch(sandbox.metadata)
375
+ if (reading.epoch !== undefined && reading.epoch <= epoch) return
376
+ throw new KubernetesWorkspacePreconditionError(
377
+ operation,
378
+ workspaceId,
379
+ name,
380
+ epoch,
381
+ reading.epoch,
382
+ reading.annotation,
383
+ )
384
+ }
385
+
386
+ function assertExpectedAllocation(
387
+ workspaceId: string,
388
+ name: string,
389
+ sandbox: SandboxResource,
390
+ expected: KubernetesWorkspaceAllocation | undefined,
391
+ ): void {
392
+ if (expected === undefined) return
393
+ const mismatch = firstAllocationMismatch(sandbox, expected)
394
+ if (mismatch === undefined) return
395
+ throw new KubernetesWorkspaceAllocationMismatchError(
396
+ workspaceId,
397
+ name,
398
+ mismatch.kind,
399
+ mismatch.expected,
400
+ mismatch.actual,
401
+ )
402
+ }
403
+
404
+ function clientFor(config: KubernetesBackendInternalConfig): KubernetesClient {
405
+ return createKubernetesClient(clientAccess(config), clientOptions(config))
406
+ }
407
+
408
+ /**
409
+ * Read what stands under a workspace id: its Sandbox uid, its claim uids, its
410
+ * pod and its operating mode, and who holds it. GET requests only — nothing
411
+ * is created, woken, patched or deleted — and `undefined` when no Sandbox
412
+ * stands there.
413
+ *
414
+ * Needs `get` on `sandboxes`, `persistentvolumeclaims` and `pods`. A failed
415
+ * read rejects as itself: an unanswered request is never reported as a
416
+ * workspace that is absent.
417
+ */
418
+ export async function inspectKubernetesWorkspace(
419
+ config: KubernetesBackendInternalConfig,
420
+ workspaceId: string,
421
+ options: KubernetesWorkspaceInspectOptions,
422
+ ): Promise<KubernetesWorkspaceInspection | undefined> {
423
+ options.signal?.throwIfAborted()
424
+ const epoch = assertHolderEpoch(options.epoch, 'inspectKubernetesWorkspace')
425
+ if (epoch === undefined) {
426
+ throw new TypeError('kubernetes: inspectKubernetesWorkspace needs an epoch.')
427
+ }
428
+ if (options.expectedAllocation !== undefined) {
429
+ assertAllocation(options.expectedAllocation, 'inspectKubernetesWorkspace')
430
+ }
431
+ const name = workspaceSandboxName(workspaceId)
432
+ const seen = await observe(clientFor(config), config.namespace, name, options.signal)
433
+ if (seen === undefined) return undefined
434
+ assertEpochAllows('inspectKubernetesWorkspace', workspaceId, name, epoch, seen.sandbox)
435
+ assertExpectedAllocation(workspaceId, name, seen.sandbox, options.expectedAllocation)
436
+ return inspectionOf(workspaceId, name, seen)
437
+ }
438
+
439
+ /**
440
+ * Authenticate a retained identity against what stands under the workspace
441
+ * id, and — with `waitForReady` — wait for it to be usable. Never creates,
442
+ * wakes or writes.
443
+ *
444
+ * Refuses with {@link KubernetesWorkspaceIdentityMismatchError} when the
445
+ * Sandbox is gone or has another uid, or a claim has another uid or another
446
+ * owner, and with `KubernetesWorkspacePreconditionError` when the workspace
447
+ * is held at a higher epoch. The waiting form repeats both checks on every
448
+ * pass and retains every claim uid it has seen, so a replacement that lands
449
+ * mid-wait is a refusal rather than a longer wait. Cancelling `signal`
450
+ * rejects with its reason; running out of `readyTimeoutMs` rejects with
451
+ * {@link KubernetesWorkspaceReadyTimeoutError}. The three never share a class.
452
+ */
453
+ export async function openKubernetesWorkspace(
454
+ config: KubernetesBackendInternalConfig,
455
+ options: KubernetesWorkspaceOpenOptions,
456
+ ): Promise<KubernetesWorkspaceInspection> {
457
+ options.signal?.throwIfAborted()
458
+ const { workspaceId } = options
459
+ assertRetained(options.identity, 'openKubernetesWorkspace')
460
+ if (options.expectedAllocation !== undefined) {
461
+ assertAllocation(options.expectedAllocation, 'openKubernetesWorkspace')
462
+ }
463
+ const epoch = assertHolderEpoch(options.epoch, 'openKubernetesWorkspace')
464
+ if (epoch === undefined)
465
+ throw new TypeError('kubernetes: openKubernetesWorkspace needs an epoch.')
466
+ const name = workspaceSandboxName(workspaceId)
467
+ const client = clientFor(config)
468
+ const trusted: Record<string, string> = { ...(options.identity.volumeClaimUids ?? {}) }
469
+ const wait = options.waitForReady === true
470
+ const readiness = resolveKubernetesReadiness(config)
471
+ const deadline = new OperationDeadline(
472
+ readiness.timeoutMs,
473
+ `kubernetes workspace ${name} open`,
474
+ options.signal,
475
+ )
476
+
477
+ const pass = async (signal: AbortSignal | undefined): Promise<KubernetesWorkspaceInspection> => {
478
+ const seen = await observe(client, config.namespace, name, signal)
479
+ if (seen === undefined) {
480
+ throw new KubernetesWorkspaceIdentityMismatchError(
481
+ workspaceId,
482
+ name,
483
+ 'sandbox-absent',
484
+ options.identity.sandboxUid,
485
+ undefined,
486
+ )
487
+ }
488
+ const mismatch = firstMismatch(options.identity, trusted, seen)
489
+ if (mismatch !== undefined) {
490
+ throw new KubernetesWorkspaceIdentityMismatchError(
491
+ workspaceId,
492
+ name,
493
+ mismatch.kind,
494
+ mismatch.expected,
495
+ mismatch.actual,
496
+ mismatch.claim,
497
+ )
498
+ }
499
+ assertEpochAllows('openKubernetesWorkspace', workspaceId, name, epoch, seen.sandbox)
500
+ assertExpectedAllocation(workspaceId, name, seen.sandbox, options.expectedAllocation)
501
+ for (const [claimName, claim] of seen.claims) trusted[claimName] = claim.uid
502
+ return inspectionOf(workspaceId, name, seen)
503
+ }
504
+
505
+ if (!wait) return await pass(options.signal)
506
+
507
+ let last: KubernetesWorkspaceInspection | undefined
508
+ while (deadline.remainingMs() > 0) {
509
+ try {
510
+ last = await deadline.run((signal) => pass(signal))
511
+ } catch (err) {
512
+ if (err instanceof OperationDeadlineExpired) break
513
+ throw err
514
+ }
515
+ if (last.operatingMode === 'Suspended') return last
516
+ if (last.missingVolumeClaims.length === 0 && last.ready) return last
517
+ try {
518
+ await deadline.delay(readiness.pollIntervalMs)
519
+ } catch (err) {
520
+ if (err instanceof OperationDeadlineExpired) break
521
+ throw err
522
+ }
523
+ }
524
+ options.signal?.throwIfAborted()
525
+ if (last === undefined) {
526
+ // The deadline expired before the first pass answered. That is an
527
+ // unanswered read, not a workspace that is not ready, and it is said so.
528
+ throw new OperationDeadlineExpired(`kubernetes workspace ${name} open`)
529
+ }
530
+ throw new KubernetesWorkspaceReadyTimeoutError(
531
+ workspaceId,
532
+ name,
533
+ readiness.timeoutMs,
534
+ [
535
+ ...(last.missingVolumeClaims.length > 0 ? (['volume-claims'] as const) : []),
536
+ ...(last.identity.podUid === undefined ? (['pod'] as const) : []),
537
+ ...(last.ready ? [] : (['ready-condition'] as const)),
538
+ ],
539
+ last,
540
+ )
541
+ }
542
+
543
+ /**
544
+ * After a restart: what became of the workspace a host retained? One
545
+ * GET-only pass, answered as a closure state rather than thrown.
546
+ *
547
+ * A different Sandbox or claim is `replaced`, not an error, because for a
548
+ * host closing its books that IS the answer. A read that did not answer
549
+ * rejects as itself — it is never mapped to `absent`.
550
+ */
551
+ export async function reconcileKubernetesWorkspace(
552
+ config: KubernetesBackendInternalConfig,
553
+ options: KubernetesWorkspaceReconcileOptions,
554
+ ): Promise<KubernetesWorkspaceClosure> {
555
+ options.signal?.throwIfAborted()
556
+ const { workspaceId } = options
557
+ assertRetained(options.identity, 'reconcileKubernetesWorkspace')
558
+ if (options.expectedAllocation !== undefined) {
559
+ assertAllocation(options.expectedAllocation, 'reconcileKubernetesWorkspace')
560
+ }
561
+ const epoch = assertHolderEpoch(options.epoch, 'reconcileKubernetesWorkspace')
562
+ const name = workspaceSandboxName(workspaceId)
563
+ const seen = await observe(clientFor(config), config.namespace, name, options.signal)
564
+ if (seen === undefined) return { state: 'absent', workspaceId, sandboxName: name }
565
+ const inspection = inspectionOf(workspaceId, name, seen)
566
+ const mismatch = firstMismatch(
567
+ options.identity,
568
+ { ...(options.identity.volumeClaimUids ?? {}) },
569
+ seen,
570
+ )
571
+ if (mismatch !== undefined) {
572
+ return {
573
+ state: 'replaced',
574
+ workspaceId,
575
+ sandboxName: name,
576
+ identity: inspection.identity,
577
+ mismatch: mismatch.kind,
578
+ }
579
+ }
580
+ if (epoch !== undefined) {
581
+ assertEpochAllows('reconcileKubernetesWorkspace', workspaceId, name, epoch, seen.sandbox)
582
+ }
583
+ assertExpectedAllocation(workspaceId, name, seen.sandbox, options.expectedAllocation)
584
+ return {
585
+ state: inspection.operatingMode === 'Suspended' ? 'suspended' : 'live',
586
+ workspaceId,
587
+ sandboxName: name,
588
+ identity: inspection.identity,
589
+ }
590
+ }