@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
|
@@ -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
|
+
}
|