@namzu/sandbox 13.0.0 → 14.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 +309 -0
- package/README.md +151 -0
- package/dist/backends/firecracker/protocol.d.ts +22 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +104 -9
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +139 -13
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
- package/dist/backends/kubernetes/egress-policy.js +314 -0
- package/dist/backends/kubernetes/egress-policy.js.map +1 -0
- package/dist/backends/kubernetes/index.d.ts +374 -0
- package/dist/backends/kubernetes/index.d.ts.map +1 -0
- package/dist/backends/kubernetes/index.js +671 -0
- package/dist/backends/kubernetes/index.js.map +1 -0
- package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
- package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
- package/dist/backends/kubernetes/k8s-client.js +246 -0
- package/dist/backends/kubernetes/k8s-client.js.map +1 -0
- package/dist/backends/kubernetes/lease.d.ts +119 -0
- package/dist/backends/kubernetes/lease.d.ts.map +1 -0
- package/dist/backends/kubernetes/lease.js +151 -0
- package/dist/backends/kubernetes/lease.js.map +1 -0
- package/dist/backends/kubernetes/objects.d.ts +282 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -0
- package/dist/backends/kubernetes/objects.js +156 -0
- package/dist/backends/kubernetes/objects.js.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
- package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
- package/dist/backends/kubernetes/privilege-probe.js +185 -0
- package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
- package/dist/backends/kubernetes/sandbox.d.ts +123 -0
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/sandbox.js +299 -0
- package/dist/backends/kubernetes/sandbox.js.map +1 -0
- package/dist/backends/kubernetes/transport.d.ts +122 -0
- package/dist/backends/kubernetes/transport.d.ts.map +1 -0
- package/dist/backends/kubernetes/transport.js +197 -0
- package/dist/backends/kubernetes/transport.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +381 -0
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace.js +1064 -0
- package/dist/backends/kubernetes/workspace.js.map +1 -0
- package/dist/index.d.ts +132 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +102 -34
- package/dist/index.js.map +1 -1
- package/dist/testing/sandbox-conformance.d.ts +193 -0
- package/dist/testing/sandbox-conformance.d.ts.map +1 -0
- package/dist/testing/sandbox-conformance.js +465 -0
- package/dist/testing/sandbox-conformance.js.map +1 -0
- package/package.json +5 -4
- package/src/backends/firecracker/protocol.ts +27 -0
- package/src/backends/firecracker/transport.ts +199 -28
- package/src/backends/kubernetes/egress-policy.ts +437 -0
- package/src/backends/kubernetes/index.ts +1012 -0
- package/src/backends/kubernetes/k8s-client.ts +352 -0
- package/src/backends/kubernetes/lease.ts +198 -0
- package/src/backends/kubernetes/objects.ts +363 -0
- package/src/backends/kubernetes/privilege-probe.ts +261 -0
- package/src/backends/kubernetes/sandbox.ts +395 -0
- package/src/backends/kubernetes/transport.ts +286 -0
- package/src/backends/kubernetes/workspace.ts +1386 -0
- package/src/index.ts +257 -35
- package/src/testing/sandbox-conformance.ts +667 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The {@link Sandbox} a kubernetes acquire hands back: the SDK contract,
|
|
3
|
+
* served over the guest agent's TCP transport, with the lease that keeps the
|
|
4
|
+
* cluster from deleting the pod out from under a long run.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `index.ts` because that file is about the CONTROL plane —
|
|
7
|
+
* claim, poll, address, release — and this one is about the DATA plane, and
|
|
8
|
+
* the two are read for different reasons.
|
|
9
|
+
*
|
|
10
|
+
* ## What it implements, and what it deliberately does not
|
|
11
|
+
*
|
|
12
|
+
* Implemented: `exec` (through the shared {@link RemoteExecutionController},
|
|
13
|
+
* so an `AbortSignal` terminates the guest process rather than abandoning
|
|
14
|
+
* the wait), `writeFile`, `readFile`, `listFiles`, `openTerminal`,
|
|
15
|
+
* `openTcpConnection`, `destroy`.
|
|
16
|
+
*
|
|
17
|
+
* Absent on purpose, because the SDK's contract says a backend that cannot
|
|
18
|
+
* honour an optional method must omit it rather than accept and ignore:
|
|
19
|
+
*
|
|
20
|
+
* - `setNetworkPolicy` — egress here is a `NetworkPolicy` attached to the
|
|
21
|
+
* pool's `SandboxTemplate`. There is no per-running-pod knob to turn, and
|
|
22
|
+
* a policy accepted and not applied is worse than one never offered: the
|
|
23
|
+
* caller stops looking.
|
|
24
|
+
* - `spawnDetached` — the guest agent has no op that starts a process and
|
|
25
|
+
* returns it running. A host asking for background jobs must be told no.
|
|
26
|
+
* - `walkFiles` — not in this batch. A host that requires bounded search
|
|
27
|
+
* refuses an absent method, which is the honest answer today.
|
|
28
|
+
*
|
|
29
|
+
* ## Terminals are owned
|
|
30
|
+
*
|
|
31
|
+
* `openTerminal` is only a compliant implementation if `destroy()` kills and
|
|
32
|
+
* awaits every terminal it returned, so open terminals are tracked and
|
|
33
|
+
* reaped before the object is released — the same thing the Firecracker
|
|
34
|
+
* backend does, for the same contract.
|
|
35
|
+
*
|
|
36
|
+
* ## Two terminal states, one `SandboxStatus`
|
|
37
|
+
*
|
|
38
|
+
* `SandboxStatus` has exactly four members and this change does not widen
|
|
39
|
+
* the SDK's union, so both ways a sandbox ends report `'destroyed'`. They
|
|
40
|
+
* are told apart by the error a later call throws:
|
|
41
|
+
* {@link KubernetesSandboxDestroyedError} (this host released it) and
|
|
42
|
+
* {@link KubernetesSandboxGoneError} (the cluster deleted it — the lease
|
|
43
|
+
* renewal found the object already gone).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type {
|
|
47
|
+
OpenTerminalOptions,
|
|
48
|
+
Sandbox,
|
|
49
|
+
SandboxDestroyOptions,
|
|
50
|
+
SandboxEnvironment,
|
|
51
|
+
SandboxExecOptions,
|
|
52
|
+
SandboxExecResult,
|
|
53
|
+
SandboxFileEntry,
|
|
54
|
+
SandboxId,
|
|
55
|
+
SandboxStatus,
|
|
56
|
+
SandboxTcpConnectOptions,
|
|
57
|
+
SandboxTcpConnection,
|
|
58
|
+
TerminalSession,
|
|
59
|
+
} from '@namzu/sdk'
|
|
60
|
+
|
|
61
|
+
import { OperationDeadline } from '../readiness.js'
|
|
62
|
+
import {
|
|
63
|
+
RemoteCancellationUnknownError,
|
|
64
|
+
type SandboxRetirementObservation,
|
|
65
|
+
} from '../remote-execution-controller.js'
|
|
66
|
+
import { KubernetesLeaseRenewal } from './lease.js'
|
|
67
|
+
import type { KubernetesAgentTransport } from './transport.js'
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How long a retirement triggered by an unconfirmed cancellation may spend
|
|
71
|
+
* deleting the object. Separate from every other clock on the path: the
|
|
72
|
+
* caller's own deadline has usually already expired by the time this runs,
|
|
73
|
+
* and reusing it would mean skipping teardown exactly when a command of
|
|
74
|
+
* unknown state is still out there.
|
|
75
|
+
*/
|
|
76
|
+
const RETIREMENT_TIMEOUT_MS = 15_000
|
|
77
|
+
|
|
78
|
+
/** Thrown by any operation on a sandbox this host already destroyed. */
|
|
79
|
+
export class KubernetesSandboxDestroyedError extends Error {
|
|
80
|
+
override readonly name = 'KubernetesSandboxDestroyedError'
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
readonly operation: string,
|
|
84
|
+
readonly sandboxName: string,
|
|
85
|
+
) {
|
|
86
|
+
super(
|
|
87
|
+
`kubernetes sandbox ${sandboxName} has been destroyed; ${operation}() cannot be admitted. Acquire a new sandbox — a destroyed one's pod, Service and claim are deleted and its agent address no longer resolves.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Thrown by any operation on a sandbox the CLUSTER removed while this
|
|
94
|
+
* handle still held it — the lease renewal PATCH came back 404/410. Distinct
|
|
95
|
+
* from {@link KubernetesSandboxDestroyedError} because nothing this host did
|
|
96
|
+
* caused it: the object expired, an operator deleted it, or the controller
|
|
97
|
+
* reaped it, and the actionable advice is different.
|
|
98
|
+
*/
|
|
99
|
+
export class KubernetesSandboxGoneError extends Error {
|
|
100
|
+
override readonly name = 'KubernetesSandboxGoneError'
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
readonly operation: string,
|
|
104
|
+
readonly sandboxName: string,
|
|
105
|
+
) {
|
|
106
|
+
super(
|
|
107
|
+
`kubernetes sandbox ${sandboxName} no longer exists on the cluster; ${operation}() cannot be admitted. Its lease renewal found the object already deleted — it expired (spec.lifecycle.shutdownTime), an operator deleted it, or the controller reaped it. Nothing this handle can do brings it back; acquire a new sandbox.`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface KubernetesSandboxBaseOptions {
|
|
113
|
+
/** The cluster's own name for the bound sandbox — also the sandbox id. */
|
|
114
|
+
readonly name: string
|
|
115
|
+
readonly rootDir: string
|
|
116
|
+
readonly transport: KubernetesAgentTransport
|
|
117
|
+
/** DELETE the object this backend created. Already-gone counts as done. */
|
|
118
|
+
readonly release: (signal?: AbortSignal) => Promise<void>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The lease half of the options: a way to move the expiry, and the expiry it
|
|
123
|
+
* is moving. Required TOGETHER, because `renew` without `ttlSeconds` is a
|
|
124
|
+
* renewal loop with nothing to stamp — it would re-stamp `now + 0`, an
|
|
125
|
+
* expiry already in the past, and hand the object straight to the
|
|
126
|
+
* controller's reaper while reporting every tick a success. A pair is the
|
|
127
|
+
* only shape that cannot be half-configured.
|
|
128
|
+
*/
|
|
129
|
+
interface KubernetesSandboxLeaseOptions {
|
|
130
|
+
/** PATCH the object's `shutdownTime` forward. See `lease.ts`. */
|
|
131
|
+
readonly renew: (shutdownTime: string, signal?: AbortSignal) => Promise<void>
|
|
132
|
+
/** The TTL acquire stamped; each renewal re-stamps exactly this much. */
|
|
133
|
+
readonly ttlSeconds: number
|
|
134
|
+
readonly onRenewalError?: (error: unknown) => void
|
|
135
|
+
/** Test seam: the renewal loop's base interval. Default: half the TTL. */
|
|
136
|
+
readonly leaseIntervalMs?: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The other arm: an object that carries no expiry, so this handle runs no
|
|
141
|
+
* renewal loop at all — the persistent workspace (`workspace.ts`), which is
|
|
142
|
+
* explicitly managed and must outlive a host that stopped renewing. A no-op
|
|
143
|
+
* `renew` would be the wrong way to say that: it would leave a timer ticking
|
|
144
|
+
* forever to do nothing. The lease fields are typed `undefined` rather than
|
|
145
|
+
* omitted so that passing one of them here is a type error and not an
|
|
146
|
+
* excess-property check a spread would slip past.
|
|
147
|
+
*/
|
|
148
|
+
interface KubernetesSandboxUnleasedOptions {
|
|
149
|
+
readonly renew?: undefined
|
|
150
|
+
readonly ttlSeconds?: undefined
|
|
151
|
+
readonly onRenewalError?: undefined
|
|
152
|
+
readonly leaseIntervalMs?: undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type KubernetesSandboxOptions = KubernetesSandboxBaseOptions &
|
|
156
|
+
(KubernetesSandboxLeaseOptions | KubernetesSandboxUnleasedOptions)
|
|
157
|
+
|
|
158
|
+
function detectEnvironment(): SandboxEnvironment {
|
|
159
|
+
// The guest runs Linux; the enum describes the host-facing shape of the
|
|
160
|
+
// worker, not the isolation technology under it. Firecracker's guest
|
|
161
|
+
// reports the same for the same reason.
|
|
162
|
+
return 'linux-namespace'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What this backend hands back: the SDK contract, with the two optional
|
|
167
|
+
* members it DOES implement narrowed to present, so a caller that composes
|
|
168
|
+
* one — `workspace.ts` wraps this handle — does not have to re-check for a
|
|
169
|
+
* method this file always defines.
|
|
170
|
+
*/
|
|
171
|
+
export type KubernetesSandboxHandle = Sandbox &
|
|
172
|
+
Required<Pick<Sandbox, 'openTerminal' | 'openTcpConnection'>>
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the handle. It does NOT run the acquire-time privilege probe — that
|
|
176
|
+
* is `create()`'s job in `index.ts`, so that a refusal can destroy this
|
|
177
|
+
* object before any caller has a reference to it, and so this function stays
|
|
178
|
+
* usable by the workspace path that runs its own probe.
|
|
179
|
+
*/
|
|
180
|
+
export function buildKubernetesSandbox(options: KubernetesSandboxOptions): KubernetesSandboxHandle {
|
|
181
|
+
// The cluster owns this name. Preserving it verbatim as the sandbox id —
|
|
182
|
+
// as the Firecracker backend preserves its orchestrator's — means a log
|
|
183
|
+
// line carrying an id is also a `kubectl get sandbox` argument.
|
|
184
|
+
const id = options.name as SandboxId
|
|
185
|
+
const transport = options.transport
|
|
186
|
+
|
|
187
|
+
type Lifecycle = 'active' | 'retiring' | 'destroyed' | 'gone'
|
|
188
|
+
let lifecycle: Lifecycle = 'active'
|
|
189
|
+
let activeExecutions = 0
|
|
190
|
+
let teardownPromise: Promise<void> | undefined
|
|
191
|
+
let teardownComplete = false
|
|
192
|
+
let retirementPromise: Promise<SandboxRetirementObservation> | undefined
|
|
193
|
+
const terminals = new Set<TerminalSession>()
|
|
194
|
+
|
|
195
|
+
// No `renew` ⇒ no expiry to move ⇒ no loop. `stop()` on the undefined
|
|
196
|
+
// case is the caller's problem to not have, which is why every use below
|
|
197
|
+
// goes through `lease?.stop()`.
|
|
198
|
+
const renew = options.renew
|
|
199
|
+
const ttlSeconds = options.ttlSeconds
|
|
200
|
+
// The type above already pairs the two. This is the runtime half of the
|
|
201
|
+
// same rule, for a caller that reached here through a cast or from
|
|
202
|
+
// JavaScript: a lease stamping `now + 0` expires the moment it is written,
|
|
203
|
+
// and every tick would report success while the controller deleted the
|
|
204
|
+
// object underneath it.
|
|
205
|
+
if (renew !== undefined && (typeof ttlSeconds !== 'number' || ttlSeconds <= 0)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`kubernetes: sandbox ${options.name} was given a lease renewal with ttlSeconds ${String(ttlSeconds)}. A renewal re-stamps shutdownTime as now + ttlSeconds, so a zero or absent TTL stamps an expiry that has already passed and the object is reaped while the loop reports every tick a success. Pass renew and a positive ttlSeconds together, or neither — an object with no expiry (a persistent workspace) runs no renewal loop.`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
// `ttlSeconds === undefined` is unreachable once `renew` is defined — the
|
|
211
|
+
// throw above saw to that — and is written out anyway because it is what
|
|
212
|
+
// narrows the field to a number for the constructor below.
|
|
213
|
+
const lease =
|
|
214
|
+
renew === undefined || ttlSeconds === undefined
|
|
215
|
+
? undefined
|
|
216
|
+
: new KubernetesLeaseRenewal({
|
|
217
|
+
ttlSeconds,
|
|
218
|
+
renew,
|
|
219
|
+
onGone: () => {
|
|
220
|
+
// The object is gone; the pod behind the address went with it.
|
|
221
|
+
// Refuse every later call by name rather than let it dial into a
|
|
222
|
+
// connect timeout with nothing to explain it.
|
|
223
|
+
if (lifecycle === 'active') lifecycle = 'gone'
|
|
224
|
+
},
|
|
225
|
+
...(options.onRenewalError !== undefined
|
|
226
|
+
? { onRenewalError: options.onRenewalError }
|
|
227
|
+
: {}),
|
|
228
|
+
...(options.leaseIntervalMs !== undefined ? { intervalMs: options.leaseIntervalMs } : {}),
|
|
229
|
+
})
|
|
230
|
+
lease?.start()
|
|
231
|
+
|
|
232
|
+
const assertAdmissible = (operation: string): void => {
|
|
233
|
+
if (lifecycle === 'active') return
|
|
234
|
+
if (lifecycle === 'gone') throw new KubernetesSandboxGoneError(operation, options.name)
|
|
235
|
+
throw new KubernetesSandboxDestroyedError(operation, options.name)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const teardown = (signal?: AbortSignal): Promise<void> => {
|
|
239
|
+
if (lifecycle === 'active') lifecycle = 'retiring'
|
|
240
|
+
lease?.stop()
|
|
241
|
+
if (teardownComplete) return Promise.resolve()
|
|
242
|
+
if (teardownPromise) return teardownPromise
|
|
243
|
+
const shared = options.release(signal).then(
|
|
244
|
+
() => {
|
|
245
|
+
teardownComplete = true
|
|
246
|
+
lifecycle = 'destroyed'
|
|
247
|
+
},
|
|
248
|
+
(error: unknown) => {
|
|
249
|
+
// A failed teardown must stay retryable; keeping the rejected
|
|
250
|
+
// promise would answer every later destroy() with the same
|
|
251
|
+
// stale failure.
|
|
252
|
+
if (teardownPromise === shared) teardownPromise = undefined
|
|
253
|
+
throw error
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
teardownPromise = shared
|
|
257
|
+
return shared
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A command whose cancellation the guest could not confirm may still be
|
|
262
|
+
* running in that pod, so the pod stops being reusable. Retire it and
|
|
263
|
+
* report whether the retirement landed, on the error the caller is about
|
|
264
|
+
* to receive.
|
|
265
|
+
*/
|
|
266
|
+
const retire = (): Promise<SandboxRetirementObservation> => {
|
|
267
|
+
if (lifecycle === 'active') lifecycle = 'retiring'
|
|
268
|
+
retirementPromise ??= new OperationDeadline(
|
|
269
|
+
RETIREMENT_TIMEOUT_MS,
|
|
270
|
+
`kubernetes sandbox ${options.name} retirement`,
|
|
271
|
+
)
|
|
272
|
+
.run(async (signal) => await teardown(signal))
|
|
273
|
+
.then(() => ({ accepted: true as const }))
|
|
274
|
+
.catch((error: unknown) => ({
|
|
275
|
+
accepted: false as const,
|
|
276
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
277
|
+
}))
|
|
278
|
+
return retirementPromise
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const runExecution = async <T>(operation: string, run: () => Promise<T>): Promise<T> => {
|
|
282
|
+
assertAdmissible(operation)
|
|
283
|
+
activeExecutions += 1
|
|
284
|
+
try {
|
|
285
|
+
return await run()
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error instanceof RemoteCancellationUnknownError) {
|
|
288
|
+
error.retirement = await retire()
|
|
289
|
+
}
|
|
290
|
+
throw error
|
|
291
|
+
} finally {
|
|
292
|
+
activeExecutions = Math.max(0, activeExecutions - 1)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
id,
|
|
298
|
+
get status(): SandboxStatus {
|
|
299
|
+
// Four members, and no new one: a cluster-side disappearance and a
|
|
300
|
+
// host-side destroy both read as 'destroyed' here and are told
|
|
301
|
+
// apart by the error a later call throws.
|
|
302
|
+
if (lifecycle !== 'active') return 'destroyed'
|
|
303
|
+
return activeExecutions > 0 ? 'busy' : 'ready'
|
|
304
|
+
},
|
|
305
|
+
rootDir: options.rootDir,
|
|
306
|
+
environment: detectEnvironment(),
|
|
307
|
+
|
|
308
|
+
async exec(
|
|
309
|
+
command: string,
|
|
310
|
+
argv?: string[],
|
|
311
|
+
opts?: SandboxExecOptions,
|
|
312
|
+
): Promise<SandboxExecResult> {
|
|
313
|
+
return await runExecution('exec', async () => await transport.exec(command, argv, opts))
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Every `tcp` request dials a fresh connection, so its envelope is
|
|
318
|
+
* also that connection's first, not-yet-authenticated frame and is
|
|
319
|
+
* bounded by the guest's pre-auth frame ceiling (8 MiB by default).
|
|
320
|
+
* The transport checks that BEFORE dialing and throws
|
|
321
|
+
* `AgentPreauthFrameTooLargeError` naming the limit; it is passed
|
|
322
|
+
* through unwrapped so a caller can catch that class and chunk,
|
|
323
|
+
* rather than having to pattern-match a message.
|
|
324
|
+
*/
|
|
325
|
+
async writeFile(path: string, content: string | Buffer): Promise<void> {
|
|
326
|
+
assertAdmissible('writeFile')
|
|
327
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
|
|
328
|
+
await transport.writeFile(path, buf)
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
async readFile(path: string): Promise<Buffer> {
|
|
332
|
+
assertAdmissible('readFile')
|
|
333
|
+
return await transport.readFile(path)
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
async openTerminal(terminalOptions: OpenTerminalOptions): Promise<TerminalSession> {
|
|
337
|
+
assertAdmissible('openTerminal')
|
|
338
|
+
const terminal = await transport.openTerminal(terminalOptions)
|
|
339
|
+
terminals.add(terminal)
|
|
340
|
+
void terminal.exited.finally(() => terminals.delete(terminal))
|
|
341
|
+
return terminal
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async openTcpConnection(
|
|
345
|
+
connectOptions: SandboxTcpConnectOptions,
|
|
346
|
+
): Promise<SandboxTcpConnection> {
|
|
347
|
+
assertAdmissible('openTcpConnection')
|
|
348
|
+
return await transport.openTcpConnection(connectOptions)
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
|
|
352
|
+
return await runExecution('listFiles', async () => {
|
|
353
|
+
// Same wire as docker/aci/firecracker: `find -printf '%p\t%s\n'`,
|
|
354
|
+
// parsed line by line, with a non-zero exit (a root that does
|
|
355
|
+
// not exist yet) mapped to "empty" as the SDK contract asks.
|
|
356
|
+
const result = await transport.exec('find', [rootPath, '-type', 'f', '-printf', '%p\t%s\n'])
|
|
357
|
+
if (result.exitCode !== 0) return []
|
|
358
|
+
const entries: SandboxFileEntry[] = []
|
|
359
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
360
|
+
if (!rawLine) continue
|
|
361
|
+
const tab = rawLine.indexOf('\t')
|
|
362
|
+
if (tab < 0) continue
|
|
363
|
+
const filePath = rawLine.slice(0, tab)
|
|
364
|
+
const size = Number.parseInt(rawLine.slice(tab + 1), 10)
|
|
365
|
+
if (!filePath || !Number.isFinite(size)) continue
|
|
366
|
+
entries.push({ path: filePath, size })
|
|
367
|
+
}
|
|
368
|
+
return entries
|
|
369
|
+
})
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async destroy(destroyOptions?: SandboxDestroyOptions): Promise<void> {
|
|
373
|
+
if (retirementPromise) {
|
|
374
|
+
const observation = await retirementPromise
|
|
375
|
+
if (observation.accepted) return
|
|
376
|
+
retirementPromise = undefined
|
|
377
|
+
}
|
|
378
|
+
// A terminal owns an interactive process tree in this pod. Stop and
|
|
379
|
+
// await every one before releasing the object, so the SDK's
|
|
380
|
+
// ownership contract is real rather than best-effort bookkeeping.
|
|
381
|
+
lifecycle = lifecycle === 'active' ? 'retiring' : lifecycle
|
|
382
|
+
lease?.stop()
|
|
383
|
+
const activeTerminals = [...terminals]
|
|
384
|
+
for (const terminal of activeTerminals) terminal.kill('SIGKILL')
|
|
385
|
+
await Promise.allSettled(activeTerminals.map((terminal) => terminal.exited))
|
|
386
|
+
terminals.clear()
|
|
387
|
+
// Deleting the claim cascades to the sandbox it adopted through the
|
|
388
|
+
// ownerReferences the controller re-parents on bind, so one DELETE
|
|
389
|
+
// retires the pod, the Service and the object. An object that is
|
|
390
|
+
// already gone counts as released — that is the state DELETE was
|
|
391
|
+
// asking for.
|
|
392
|
+
await teardown(destroyOptions?.signal)
|
|
393
|
+
},
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side transport for the kubernetes backend's guest agent: the same
|
|
3
|
+
* `agent/agent.cjs` the Firecracker tier bakes into its golden image,
|
|
4
|
+
* reached over the pod network instead of a host-local vsock/unix socket.
|
|
5
|
+
*
|
|
6
|
+
* Every byte on the wire goes through {@link VsockAgentTransport}
|
|
7
|
+
* constructed with a `{ kind: 'tcp' }` handle (`../firecracker/transport.js`):
|
|
8
|
+
* the SAME per-call dial (fresh connection, no cached socket, no cached
|
|
9
|
+
* IP — a Service-FQDN host re-resolves on every call, so a resumed pod's
|
|
10
|
+
* new address costs nothing extra), the SAME 8-hex-length framing, and
|
|
11
|
+
* the SAME token-in-envelope credential. Nothing about the wire is
|
|
12
|
+
* reimplemented here.
|
|
13
|
+
*
|
|
14
|
+
* This module supplies its OWN {@link RemoteExecutionAdapter} to the
|
|
15
|
+
* shared {@link RemoteExecutionController} — a third adapter against the
|
|
16
|
+
* same controller, alongside `http-worker-client.ts` and
|
|
17
|
+
* `firecracker/transport.ts`, each of which does this independently — so
|
|
18
|
+
* it can report where an `exec()` call's wall time actually goes: the
|
|
19
|
+
* dial(s), the reserve round trip, the execute round trip, and the small
|
|
20
|
+
* amount of local bookkeeping after the peer's own work is done
|
|
21
|
+
* ("drain"). That attribution is the whole point of `onTiming`: the
|
|
22
|
+
* Firecracker relay path has a known, unexplained fixed cost, and the
|
|
23
|
+
* sub-second warm-acquire target this backend is judged against must be
|
|
24
|
+
* measured, not guessed at.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
OpenTerminalOptions,
|
|
29
|
+
SandboxExecOptions,
|
|
30
|
+
SandboxExecResult,
|
|
31
|
+
SandboxTcpConnectOptions,
|
|
32
|
+
SandboxTcpConnection,
|
|
33
|
+
TerminalSession,
|
|
34
|
+
} from '@namzu/sdk'
|
|
35
|
+
|
|
36
|
+
import type { ExecRequest } from '../firecracker/protocol.js'
|
|
37
|
+
import {
|
|
38
|
+
type AgentRequest,
|
|
39
|
+
type SandboxAgentHandle,
|
|
40
|
+
VsockAgentTransport,
|
|
41
|
+
type VsockTransportOptions,
|
|
42
|
+
} from '../firecracker/transport.js'
|
|
43
|
+
import {
|
|
44
|
+
type RemoteExecutionAdapter,
|
|
45
|
+
RemoteExecutionController,
|
|
46
|
+
} from '../remote-execution-controller.js'
|
|
47
|
+
|
|
48
|
+
/** The one {@link SandboxAgentHandle} arm this backend ever constructs. */
|
|
49
|
+
export type KubernetesAgentHandle = Extract<SandboxAgentHandle, { kind: 'tcp' }>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Thrown when the guest agent refuses a request because the handle's
|
|
53
|
+
* token does not match what the pod is bound to. Named distinctly from
|
|
54
|
+
* {@link RemoteProtocolError} so a caller can tell "this credential is
|
|
55
|
+
* wrong" (never going to succeed by retrying) from "the wire shape was
|
|
56
|
+
* unexpected".
|
|
57
|
+
*/
|
|
58
|
+
export class KubernetesAgentUnauthorizedError extends Error {
|
|
59
|
+
constructor(
|
|
60
|
+
message = 'kubernetes tcp transport: the guest agent rejected this connection’s token (unauthorized)',
|
|
61
|
+
) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'KubernetesAgentUnauthorizedError'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True for the wire shape `agent.cjs` sends when a token is rejected. */
|
|
68
|
+
function isUnauthorized(response: unknown): boolean {
|
|
69
|
+
if (!response || typeof response !== 'object') return false
|
|
70
|
+
const value = response as { ok?: unknown; error?: unknown }
|
|
71
|
+
return value.ok === false && value.error === 'unauthorized'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One framed control request, with the guest's `unauthorized` refusal
|
|
76
|
+
* turned into {@link KubernetesAgentUnauthorizedError}.
|
|
77
|
+
*
|
|
78
|
+
* BOTH control requests go through here — `reserve-execution` and
|
|
79
|
+
* `cancel-execution` — because the two are read by the same caller for
|
|
80
|
+
* opposite reasons and neither may mistake a refusal for a blip. The
|
|
81
|
+
* cancel path is the sharper of the two: {@link RemoteExecutionController}
|
|
82
|
+
* RETRIES a failed cancellation for its whole confirm window and then
|
|
83
|
+
* reports the cancellation UNCONFIRMED, which retires the sandbox. A
|
|
84
|
+
* rejected token read as a transport failure would therefore spend that
|
|
85
|
+
* window re-sending a request that can never succeed, and end by
|
|
86
|
+
* describing a wrong credential as an ambiguous outcome.
|
|
87
|
+
*/
|
|
88
|
+
async function requestChecked(
|
|
89
|
+
wire: VsockAgentTransport,
|
|
90
|
+
request: AgentRequest,
|
|
91
|
+
signal?: AbortSignal,
|
|
92
|
+
): Promise<unknown> {
|
|
93
|
+
const response = await wire.request(request, signal)
|
|
94
|
+
if (isUnauthorized(response)) throw new KubernetesAgentUnauthorizedError()
|
|
95
|
+
return response
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One `exec()` call's wall-time breakdown. Durations are NOT a partition
|
|
100
|
+
* of a single total — `reserveMs` and `executeMs` each include their OWN
|
|
101
|
+
* dial, which is also folded into `dialMs` — this is a diagnostic
|
|
102
|
+
* breakdown for attribution, not an accounting identity. Never carries a
|
|
103
|
+
* token, a command, its arguments, or any output.
|
|
104
|
+
*/
|
|
105
|
+
export interface KubernetesTransportTiming {
|
|
106
|
+
/** Total time spent establishing TCP connections for this call. */
|
|
107
|
+
readonly dialMs: number
|
|
108
|
+
/** Time spent on the `reserve-execution` round trip (dial included). */
|
|
109
|
+
readonly reserveMs: number
|
|
110
|
+
/** Time spent on the `execute` round trip (dial included). */
|
|
111
|
+
readonly executeMs: number
|
|
112
|
+
/**
|
|
113
|
+
* Time between the execute round trip settling and `exec()` itself
|
|
114
|
+
* resolving — the shared controller's own post-execute bookkeeping
|
|
115
|
+
* (clearing timers, tearing down the observation race). Always small
|
|
116
|
+
* on the happy path; distinct from `executeMs` because it is spent
|
|
117
|
+
* locally, after the peer has nothing left to do.
|
|
118
|
+
*/
|
|
119
|
+
readonly drainMs: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface KubernetesTransportOptions extends VsockTransportOptions {
|
|
123
|
+
/**
|
|
124
|
+
* Fires once per completed `exec()` call (success or failure) with
|
|
125
|
+
* the four phase durations above. The payload is exactly those four
|
|
126
|
+
* numbers — never the token, never a command, argv, or output.
|
|
127
|
+
*/
|
|
128
|
+
readonly onTiming?: (timing: KubernetesTransportTiming) => void
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The kubernetes backend's dialable transport: a `tcp` handle plus a
|
|
133
|
+
* `RemoteExecutionAdapter` built from it, so `exec()` gets the same
|
|
134
|
+
* reserve-before-admission behaviour (cancellation, timeout ownership,
|
|
135
|
+
* "do not infer complete output on an ambiguous cancel") every other
|
|
136
|
+
* remote backend gets, while every network operation is delegated to
|
|
137
|
+
* {@link VsockAgentTransport} for the actual dial/frame/token work.
|
|
138
|
+
*/
|
|
139
|
+
export class KubernetesAgentTransport {
|
|
140
|
+
private readonly handle: KubernetesAgentHandle
|
|
141
|
+
private readonly transportOptions: VsockTransportOptions
|
|
142
|
+
private readonly onTiming?: (timing: KubernetesTransportTiming) => void
|
|
143
|
+
/** Simple pass-through operations share one transport instance. */
|
|
144
|
+
private readonly wire: VsockAgentTransport
|
|
145
|
+
|
|
146
|
+
constructor(handle: KubernetesAgentHandle, options: KubernetesTransportOptions = {}) {
|
|
147
|
+
const { onTiming, ...transportOptions } = options
|
|
148
|
+
this.handle = handle
|
|
149
|
+
this.transportOptions = transportOptions
|
|
150
|
+
this.onTiming = onTiming
|
|
151
|
+
this.wire = new VsockAgentTransport(handle, transportOptions)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Readiness probe — never requires a token; see `protocol.ts`. */
|
|
155
|
+
async healthz(signal?: AbortSignal): Promise<boolean> {
|
|
156
|
+
return await this.wire.healthz(signal)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Poll until `healthz` succeeds or the timeout elapses. */
|
|
160
|
+
async waitForReady(
|
|
161
|
+
timeoutMs: number,
|
|
162
|
+
pollIntervalMs: number,
|
|
163
|
+
signal?: AbortSignal,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
return await this.wire.waitForReady(timeoutMs, pollIntervalMs, signal)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The raw `reserve-execution` primitive, exposed directly (rather than
|
|
170
|
+
* only reachable as a side effect of `exec()`) so the reservation
|
|
171
|
+
* round trip is independently observable and testable against the
|
|
172
|
+
* real guest.
|
|
173
|
+
*/
|
|
174
|
+
async reserve(signal?: AbortSignal): Promise<unknown> {
|
|
175
|
+
return await requestChecked(this.wire, { op: 'reserve-execution' }, signal)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The raw `cancel-execution` primitive, exposed for the same reason
|
|
180
|
+
* {@link reserve} is: it is a control request with its own refusal
|
|
181
|
+
* semantics, and proving those against the real guest should not require
|
|
182
|
+
* driving a whole cancelled `exec()` to reach it.
|
|
183
|
+
*/
|
|
184
|
+
async cancel(executionId: string, signal?: AbortSignal): Promise<unknown> {
|
|
185
|
+
return await requestChecked(
|
|
186
|
+
this.wire,
|
|
187
|
+
{ op: 'cancel-execution', body: { executionId } },
|
|
188
|
+
signal,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async writeFile(path: string, content: Buffer): Promise<void> {
|
|
193
|
+
return await this.wire.writeFile(path, content)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async readFile(path: string): Promise<Buffer> {
|
|
197
|
+
return await this.wire.readFile(path)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async openTerminal(options: OpenTerminalOptions): Promise<TerminalSession> {
|
|
201
|
+
return await this.wire.openTerminal(options)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection> {
|
|
205
|
+
return await this.wire.openTcpConnection(options)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Run one command through a fresh, call-scoped adapter + controller.
|
|
210
|
+
* Fresh per call — not shared instance state — because
|
|
211
|
+
* {@link VsockAgentTransport} itself carries no cross-call connection
|
|
212
|
+
* state (it dials fresh every time), so building one per `exec()` is
|
|
213
|
+
* free and makes concurrent `exec()` calls on the same
|
|
214
|
+
* `KubernetesAgentTransport` correctly independent: each gets its own
|
|
215
|
+
* `onDial` closure and its own timing accumulator, with no shared
|
|
216
|
+
* mutable field for two in-flight calls to race on.
|
|
217
|
+
*/
|
|
218
|
+
async exec(
|
|
219
|
+
command: string,
|
|
220
|
+
argv?: string[],
|
|
221
|
+
opts?: SandboxExecOptions,
|
|
222
|
+
): Promise<SandboxExecResult> {
|
|
223
|
+
let dialMs = 0
|
|
224
|
+
let reserveMs = 0
|
|
225
|
+
let executeMs = 0
|
|
226
|
+
let executeSettledAt = 0
|
|
227
|
+
|
|
228
|
+
const timedWire = new VsockAgentTransport(this.handle, {
|
|
229
|
+
...this.transportOptions,
|
|
230
|
+
onDial: (ms) => {
|
|
231
|
+
dialMs += ms
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const adapter: RemoteExecutionAdapter<Pick<ExecRequest, 'stdin' | 'maxOutputBytes'>> = {
|
|
236
|
+
label: 'kubernetes pod-network agent',
|
|
237
|
+
reserve: async (signal) => {
|
|
238
|
+
const startedAt = Date.now()
|
|
239
|
+
try {
|
|
240
|
+
return await requestChecked(timedWire, { op: 'reserve-execution' }, signal)
|
|
241
|
+
} finally {
|
|
242
|
+
reserveMs += Date.now() - startedAt
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
// Checked exactly like `reserve` — see `requestChecked`.
|
|
246
|
+
cancel: async (executionId, signal) =>
|
|
247
|
+
await requestChecked(timedWire, { op: 'cancel-execution', body: { executionId } }, signal),
|
|
248
|
+
execute: async (executionId, cmd, execArgv, execOpts, signal, context) => {
|
|
249
|
+
const startedAt = Date.now()
|
|
250
|
+
try {
|
|
251
|
+
return await timedWire.executeStreamed(
|
|
252
|
+
{
|
|
253
|
+
...(executionId ? { executionId } : {}),
|
|
254
|
+
command: cmd,
|
|
255
|
+
args: execArgv ?? [],
|
|
256
|
+
...(execOpts?.cwd !== undefined ? { cwd: execOpts.cwd } : {}),
|
|
257
|
+
...(execOpts?.env !== undefined ? { env: execOpts.env } : {}),
|
|
258
|
+
...(execOpts?.timeout !== undefined ? { timeoutMs: execOpts.timeout } : {}),
|
|
259
|
+
...(context?.stdin !== undefined ? { stdin: context.stdin } : {}),
|
|
260
|
+
...(context?.maxOutputBytes !== undefined
|
|
261
|
+
? { maxOutputBytes: context.maxOutputBytes }
|
|
262
|
+
: {}),
|
|
263
|
+
},
|
|
264
|
+
execOpts,
|
|
265
|
+
signal,
|
|
266
|
+
)
|
|
267
|
+
} finally {
|
|
268
|
+
executeMs += Date.now() - startedAt
|
|
269
|
+
executeSettledAt = Date.now()
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const controller = new RemoteExecutionController(adapter)
|
|
275
|
+
try {
|
|
276
|
+
return await controller.exec(command, argv, opts)
|
|
277
|
+
} finally {
|
|
278
|
+
this.onTiming?.({
|
|
279
|
+
dialMs,
|
|
280
|
+
reserveMs,
|
|
281
|
+
executeMs,
|
|
282
|
+
drainMs: executeSettledAt > 0 ? Date.now() - executeSettledAt : 0,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|