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