@namzu/sandbox 6.1.0 → 7.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 +30 -0
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
- package/dist/backends/aci-standby-pool/index.js +70 -31
- package/dist/backends/aci-standby-pool/index.js.map +1 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +105 -36
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/backends/firecracker/index.d.ts.map +1 -1
- package/dist/backends/firecracker/index.js +43 -25
- package/dist/backends/firecracker/index.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +3 -3
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +82 -40
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/readiness.d.ts +43 -0
- package/dist/backends/readiness.d.ts.map +1 -0
- package/dist/backends/readiness.js +150 -0
- package/dist/backends/readiness.js.map +1 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +20 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/backends/aci-standby-pool/index.ts +99 -30
- package/src/backends/docker/index.ts +135 -44
- package/src/backends/firecracker/index.ts +49 -21
- package/src/backends/firecracker/transport.ts +87 -38
- package/src/backends/readiness.ts +173 -0
- package/src/egress/proxy.ts +18 -3
- package/src/index.ts +27 -0
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
SANDBOX_DEFAULT_TRANSCRIPTS_PATH,
|
|
35
35
|
SANDBOX_DEFAULT_UPLOADS_PATH,
|
|
36
36
|
type Sandbox,
|
|
37
|
+
type SandboxDestroyOptions,
|
|
37
38
|
type SandboxEnvironment,
|
|
38
39
|
type SandboxExecOptions,
|
|
39
40
|
type SandboxExecResult,
|
|
@@ -56,6 +57,13 @@ import {
|
|
|
56
57
|
type SandboxBackend,
|
|
57
58
|
type SandboxBackendOptions,
|
|
58
59
|
} from '../../index.js'
|
|
60
|
+
import {
|
|
61
|
+
OperationDeadline,
|
|
62
|
+
OperationDeadlineExpired,
|
|
63
|
+
probeHttpHealth,
|
|
64
|
+
resolveReadinessOptions,
|
|
65
|
+
runFailureCleanup,
|
|
66
|
+
} from '../readiness.js'
|
|
59
67
|
|
|
60
68
|
/**
|
|
61
69
|
* Backend-specific tuning. Most hosts use the defaults; advanced
|
|
@@ -173,11 +181,20 @@ const WORKER_PORT_INSIDE_CONTAINER = 2024
|
|
|
173
181
|
* `create()` call.
|
|
174
182
|
*/
|
|
175
183
|
export function buildDockerBackend(config: DockerBackendInternalConfig): SandboxBackend {
|
|
184
|
+
const readiness = resolveReadinessOptions(
|
|
185
|
+
'docker',
|
|
186
|
+
config.readyTimeoutMs,
|
|
187
|
+
config.readyPollIntervalMs,
|
|
188
|
+
{
|
|
189
|
+
timeoutMs: DEFAULT_READY_TIMEOUT_MS,
|
|
190
|
+
pollIntervalMs: DEFAULT_READY_POLL_MS,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
176
193
|
return {
|
|
177
194
|
tier: 'container',
|
|
178
195
|
name: 'docker',
|
|
179
196
|
async create(options: SandboxBackendOptions) {
|
|
180
|
-
return await spawnDockerSandbox(config, options)
|
|
197
|
+
return await spawnDockerSandbox(config, options, readiness)
|
|
181
198
|
},
|
|
182
199
|
}
|
|
183
200
|
}
|
|
@@ -375,7 +392,9 @@ const PROXY_HOST_ALIAS = 'namzu-egress'
|
|
|
375
392
|
async function spawnDockerSandbox(
|
|
376
393
|
config: DockerBackendInternalConfig,
|
|
377
394
|
options: SandboxBackendOptions,
|
|
395
|
+
readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
|
|
378
396
|
): Promise<Sandbox> {
|
|
397
|
+
options.signal?.throwIfAborted()
|
|
379
398
|
const resolvedLayout = config.layout
|
|
380
399
|
const id = generateSandboxId()
|
|
381
400
|
const docker = config.dockerBinary ?? DEFAULT_DOCKER_BINARY
|
|
@@ -387,7 +406,13 @@ async function spawnDockerSandbox(
|
|
|
387
406
|
let egressProxy: RunningEgressProxy | undefined
|
|
388
407
|
if (needsEgressProxy(options.egress) && options.egress) {
|
|
389
408
|
const policy = options.egress
|
|
390
|
-
|
|
409
|
+
try {
|
|
410
|
+
egressProxy = await new EgressProxy(egressProxyOptions(config, policy)).listen()
|
|
411
|
+
options.signal?.throwIfAborted()
|
|
412
|
+
} catch (error) {
|
|
413
|
+
await egressProxy?.close().catch(() => undefined)
|
|
414
|
+
throw error
|
|
415
|
+
}
|
|
391
416
|
}
|
|
392
417
|
|
|
393
418
|
const hostReachability = config.hostReachability ?? 'host-port'
|
|
@@ -407,7 +432,7 @@ async function spawnDockerSandbox(
|
|
|
407
432
|
network,
|
|
408
433
|
hostReachability,
|
|
409
434
|
options.egress,
|
|
410
|
-
await inspectNetworkInternalFlag(docker, network),
|
|
435
|
+
await inspectNetworkInternalFlag(docker, network, options.signal),
|
|
411
436
|
)
|
|
412
437
|
} catch (err) {
|
|
413
438
|
// The allowlist kinds start a proxy above, and this is outside the
|
|
@@ -425,12 +450,13 @@ async function spawnDockerSandbox(
|
|
|
425
450
|
// the EACCES bug in sibling-container setups (the consumer owns
|
|
426
451
|
// the host filesystem, the spawned backend can't reach it from
|
|
427
452
|
// inside its own container's mount namespace). Clean break.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
453
|
+
async function cleanupOnFailure(signal: AbortSignal) {
|
|
454
|
+
// The name is known before `docker run`. Remove by name even when the
|
|
455
|
+
// client process was interrupted before it reported success: the daemon
|
|
456
|
+
// may already have committed the container. This is best-effort
|
|
457
|
+
// reconciliation; an external daemon that commits after this delete still
|
|
458
|
+
// needs its ordinary label/name reaper.
|
|
459
|
+
const removeContainer = runOnceQuiet(docker, ['rm', '-f', containerName], signal)
|
|
434
460
|
// The proxy starts BEFORE the container and its only other close is
|
|
435
461
|
// in `destroy()`, which a create that never returned can never
|
|
436
462
|
// reach. So every failure between the two — a daemon that is down, a
|
|
@@ -440,8 +466,11 @@ async function spawnDockerSandbox(
|
|
|
440
466
|
// event-loop handle, and a retry loop left one per attempt. That is
|
|
441
467
|
// exactly the invariant this file states where the proxy is started:
|
|
442
468
|
// it must not outlive the thing it was filtering for.
|
|
443
|
-
|
|
469
|
+
// Start both teardown arms before awaiting either. A stuck runtime must
|
|
470
|
+
// not prevent the proxy from releasing its credential-bearing listener.
|
|
471
|
+
const closeProxy = egressProxy?.close().catch(() => undefined) ?? Promise.resolve()
|
|
444
472
|
egressProxy = undefined
|
|
473
|
+
await Promise.all([removeContainer, closeProxy])
|
|
445
474
|
}
|
|
446
475
|
|
|
447
476
|
let hostPort: number
|
|
@@ -543,16 +572,15 @@ async function spawnDockerSandbox(
|
|
|
543
572
|
|
|
544
573
|
args.push(config.image)
|
|
545
574
|
|
|
546
|
-
await runOnce(docker, args)
|
|
547
|
-
containerStarted = true
|
|
548
|
-
|
|
575
|
+
await runOnce(docker, args, options.signal)
|
|
549
576
|
if (hostReachability === 'host-port') {
|
|
550
|
-
hostPort = await readMappedPort(docker, containerName)
|
|
577
|
+
hostPort = await readMappedPort(docker, containerName, options.signal)
|
|
551
578
|
baseUrl = `http://127.0.0.1:${hostPort}`
|
|
552
579
|
await waitForWorkerReady(
|
|
553
580
|
baseUrl,
|
|
554
|
-
|
|
555
|
-
|
|
581
|
+
readiness.timeoutMs,
|
|
582
|
+
readiness.pollIntervalMs,
|
|
583
|
+
options.signal,
|
|
556
584
|
)
|
|
557
585
|
} else {
|
|
558
586
|
// container-network: connect by container DNS name on the
|
|
@@ -561,12 +589,13 @@ async function spawnDockerSandbox(
|
|
|
561
589
|
baseUrl = `http://${containerName}:${WORKER_PORT_INSIDE_CONTAINER}`
|
|
562
590
|
await waitForWorkerReady(
|
|
563
591
|
baseUrl,
|
|
564
|
-
|
|
565
|
-
|
|
592
|
+
readiness.timeoutMs,
|
|
593
|
+
readiness.pollIntervalMs,
|
|
594
|
+
options.signal,
|
|
566
595
|
)
|
|
567
596
|
}
|
|
568
597
|
} catch (err) {
|
|
569
|
-
await cleanupOnFailure
|
|
598
|
+
await runFailureCleanup(cleanupOnFailure)
|
|
570
599
|
throw err
|
|
571
600
|
}
|
|
572
601
|
|
|
@@ -651,7 +680,11 @@ async function spawnDockerSandbox(
|
|
|
651
680
|
if (!res.ok) {
|
|
652
681
|
throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
|
|
653
682
|
}
|
|
654
|
-
const json = (await res.json()) as {
|
|
683
|
+
const json = (await res.json()) as {
|
|
684
|
+
ok: boolean
|
|
685
|
+
content?: string
|
|
686
|
+
error?: string
|
|
687
|
+
}
|
|
655
688
|
if (!json.ok || typeof json.content !== 'string') {
|
|
656
689
|
throw new Error(json.error ?? 'read-file: no content')
|
|
657
690
|
}
|
|
@@ -662,9 +695,9 @@ async function spawnDockerSandbox(
|
|
|
662
695
|
return await listFilesViaWorker(baseUrl, rootPath)
|
|
663
696
|
},
|
|
664
697
|
|
|
665
|
-
async destroy(): Promise<void> {
|
|
698
|
+
async destroy(options?: SandboxDestroyOptions): Promise<void> {
|
|
666
699
|
status = 'destroyed'
|
|
667
|
-
await runOnceQuiet(docker, ['rm', '-f', containerName])
|
|
700
|
+
await runOnceQuiet(docker, ['rm', '-f', containerName], options?.signal)
|
|
668
701
|
// The proxy holds real credentials and a live allowlist. Leaving
|
|
669
702
|
// it listening after the sandbox it was filtering for is gone
|
|
670
703
|
// means a loopback port that still stamps a token onto anything
|
|
@@ -686,13 +719,21 @@ async function spawnDockerSandbox(
|
|
|
686
719
|
* process could grab it in the meantime). Letting Docker
|
|
687
720
|
* allocate and reading the mapping back is race-free.
|
|
688
721
|
*/
|
|
689
|
-
async function readMappedPort(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
722
|
+
async function readMappedPort(
|
|
723
|
+
docker: string,
|
|
724
|
+
containerName: string,
|
|
725
|
+
signal?: AbortSignal,
|
|
726
|
+
): Promise<number> {
|
|
727
|
+
const inspectOutput = await runOnce(
|
|
728
|
+
docker,
|
|
729
|
+
[
|
|
730
|
+
'inspect',
|
|
731
|
+
'--format',
|
|
732
|
+
`{{(index (index .NetworkSettings.Ports "${WORKER_PORT_INSIDE_CONTAINER}/tcp") 0).HostPort}}`,
|
|
733
|
+
containerName,
|
|
734
|
+
],
|
|
735
|
+
signal,
|
|
736
|
+
)
|
|
696
737
|
const port = Number(inspectOutput.trim())
|
|
697
738
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
698
739
|
throw withHint(
|
|
@@ -883,18 +924,25 @@ async function waitForWorkerReady(
|
|
|
883
924
|
baseUrl: string,
|
|
884
925
|
timeoutMs: number,
|
|
885
926
|
pollMs: number,
|
|
927
|
+
signal?: AbortSignal,
|
|
886
928
|
): Promise<void> {
|
|
887
|
-
const deadline =
|
|
929
|
+
const deadline = new OperationDeadline(timeoutMs, 'docker worker readiness', signal)
|
|
888
930
|
let lastError: unknown
|
|
889
|
-
while (
|
|
931
|
+
while (deadline.remainingMs() > 0) {
|
|
890
932
|
try {
|
|
891
|
-
const
|
|
892
|
-
if (
|
|
893
|
-
lastError = new Error(`healthz HTTP ${
|
|
933
|
+
const result = await deadline.run((signal) => probeHttpHealth(`${baseUrl}/healthz`, signal))
|
|
934
|
+
if (result.ok) return
|
|
935
|
+
lastError = new Error(`healthz HTTP ${result.status}`)
|
|
894
936
|
} catch (err) {
|
|
895
937
|
lastError = err
|
|
938
|
+
if (err instanceof OperationDeadlineExpired) break
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
await deadline.delay(pollMs)
|
|
942
|
+
} catch (err) {
|
|
943
|
+
if (err instanceof OperationDeadlineExpired) break
|
|
944
|
+
throw err
|
|
896
945
|
}
|
|
897
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
898
946
|
}
|
|
899
947
|
// A hint attached at the throw site, where the cause is actually known.
|
|
900
948
|
// The container runtime's own message says a request failed; it cannot
|
|
@@ -910,22 +958,40 @@ async function waitForWorkerReady(
|
|
|
910
958
|
)
|
|
911
959
|
}
|
|
912
960
|
|
|
913
|
-
function runOnce(binary: string, args: string[]): Promise<string> {
|
|
961
|
+
function runOnce(binary: string, args: string[], signal?: AbortSignal): Promise<string> {
|
|
914
962
|
return new Promise((resolve, reject) => {
|
|
963
|
+
signal?.throwIfAborted()
|
|
915
964
|
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
916
965
|
let stdout = ''
|
|
917
966
|
let stderr = ''
|
|
967
|
+
let settled = false
|
|
968
|
+
const finish = (error?: unknown, value?: string) => {
|
|
969
|
+
if (settled) return
|
|
970
|
+
settled = true
|
|
971
|
+
signal?.removeEventListener('abort', abort)
|
|
972
|
+
child.removeAllListeners('error')
|
|
973
|
+
child.removeAllListeners('close')
|
|
974
|
+
if (error !== undefined) reject(error)
|
|
975
|
+
else resolve(value ?? '')
|
|
976
|
+
}
|
|
977
|
+
const abort = () => {
|
|
978
|
+
child.kill('SIGKILL')
|
|
979
|
+
child.unref()
|
|
980
|
+
finish(signal?.reason ?? new Error('operation aborted'))
|
|
981
|
+
}
|
|
918
982
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
919
983
|
stdout += chunk.toString('utf8')
|
|
920
984
|
})
|
|
921
985
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
922
986
|
stderr += chunk.toString('utf8')
|
|
923
987
|
})
|
|
924
|
-
child.on('error',
|
|
988
|
+
child.on('error', (error) => finish(error))
|
|
925
989
|
child.on('close', (code) => {
|
|
926
|
-
if (code === 0)
|
|
927
|
-
else
|
|
990
|
+
if (code === 0) finish(undefined, stdout.trim())
|
|
991
|
+
else finish(new Error(`${binary} ${args.join(' ')} exited ${code}: ${stderr.trim()}`))
|
|
928
992
|
})
|
|
993
|
+
if (signal?.aborted) abort()
|
|
994
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
929
995
|
})
|
|
930
996
|
}
|
|
931
997
|
|
|
@@ -938,19 +1004,44 @@ function runOnce(binary: string, args: string[]): Promise<string> {
|
|
|
938
1004
|
* a boundary" — instead of surfacing a docker CLI error that says nothing
|
|
939
1005
|
* about the egress policy that prompted the lookup.
|
|
940
1006
|
*/
|
|
941
|
-
async function inspectNetworkInternalFlag(
|
|
1007
|
+
async function inspectNetworkInternalFlag(
|
|
1008
|
+
docker: string,
|
|
1009
|
+
network: string,
|
|
1010
|
+
signal?: AbortSignal,
|
|
1011
|
+
): Promise<string> {
|
|
942
1012
|
try {
|
|
943
|
-
return await runOnce(
|
|
1013
|
+
return await runOnce(
|
|
1014
|
+
docker,
|
|
1015
|
+
['network', 'inspect', '--format', '{{.Internal}}', network],
|
|
1016
|
+
signal,
|
|
1017
|
+
)
|
|
944
1018
|
} catch {
|
|
1019
|
+
signal?.throwIfAborted()
|
|
945
1020
|
return ''
|
|
946
1021
|
}
|
|
947
1022
|
}
|
|
948
1023
|
|
|
949
|
-
function runOnceQuiet(binary: string, args: string[]): Promise<void> {
|
|
1024
|
+
function runOnceQuiet(binary: string, args: string[], signal?: AbortSignal): Promise<void> {
|
|
950
1025
|
return new Promise((resolve) => {
|
|
951
1026
|
const child = spawn(binary, args, { stdio: 'ignore' })
|
|
952
|
-
|
|
953
|
-
|
|
1027
|
+
let settled = false
|
|
1028
|
+
const finish = () => {
|
|
1029
|
+
if (settled) return
|
|
1030
|
+
settled = true
|
|
1031
|
+
signal?.removeEventListener('abort', abort)
|
|
1032
|
+
child.removeListener('error', finish)
|
|
1033
|
+
child.removeListener('close', finish)
|
|
1034
|
+
resolve()
|
|
1035
|
+
}
|
|
1036
|
+
const abort = () => {
|
|
1037
|
+
child.kill('SIGKILL')
|
|
1038
|
+
child.unref()
|
|
1039
|
+
finish()
|
|
1040
|
+
}
|
|
1041
|
+
child.on('error', finish)
|
|
1042
|
+
child.on('close', finish)
|
|
1043
|
+
if (signal?.aborted) abort()
|
|
1044
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
954
1045
|
})
|
|
955
1046
|
}
|
|
956
1047
|
|
|
@@ -48,6 +48,7 @@ import https from 'node:https'
|
|
|
48
48
|
|
|
49
49
|
import type {
|
|
50
50
|
Sandbox,
|
|
51
|
+
SandboxDestroyOptions,
|
|
51
52
|
SandboxEnvironment,
|
|
52
53
|
SandboxExecOptions,
|
|
53
54
|
SandboxExecResult,
|
|
@@ -57,6 +58,7 @@ import type {
|
|
|
57
58
|
} from '@namzu/sdk'
|
|
58
59
|
|
|
59
60
|
import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
|
|
61
|
+
import { resolveReadinessOptions, runFailureCleanup } from '../readiness.js'
|
|
60
62
|
import type {
|
|
61
63
|
MtlsClientMaterial,
|
|
62
64
|
SandboxAgentHandle,
|
|
@@ -148,11 +150,20 @@ const DEFAULT_READY_POLL_MS = 250
|
|
|
148
150
|
* on the first `create()`.
|
|
149
151
|
*/
|
|
150
152
|
export function buildFirecrackerBackend(config: FirecrackerBackendInternalConfig): SandboxBackend {
|
|
153
|
+
const readiness = resolveReadinessOptions(
|
|
154
|
+
'firecracker',
|
|
155
|
+
config.readyTimeoutMs,
|
|
156
|
+
config.readyPollIntervalMs,
|
|
157
|
+
{
|
|
158
|
+
timeoutMs: DEFAULT_READY_TIMEOUT_MS,
|
|
159
|
+
pollIntervalMs: DEFAULT_READY_POLL_MS,
|
|
160
|
+
},
|
|
161
|
+
)
|
|
151
162
|
return {
|
|
152
163
|
tier: 'microvm',
|
|
153
164
|
name: 'firecracker',
|
|
154
165
|
async create(options: SandboxBackendOptions): Promise<Sandbox> {
|
|
155
|
-
return await spawnFirecrackerSandbox(config, options)
|
|
166
|
+
return await spawnFirecrackerSandbox(config, options, readiness)
|
|
156
167
|
},
|
|
157
168
|
}
|
|
158
169
|
}
|
|
@@ -208,8 +219,11 @@ async function orchestratorCall<T>(
|
|
|
208
219
|
getToken: OrchestratorTokenProvider,
|
|
209
220
|
body?: unknown,
|
|
210
221
|
mtls?: MtlsClientMaterial,
|
|
222
|
+
signal?: AbortSignal,
|
|
211
223
|
): Promise<T | undefined> {
|
|
224
|
+
signal?.throwIfAborted()
|
|
212
225
|
const token = await getToken()
|
|
226
|
+
signal?.throwIfAborted()
|
|
213
227
|
const url = `${stripTrailingSlashes(endpoint)}${pathSuffix}`
|
|
214
228
|
const payload = body !== undefined ? JSON.stringify(body) : undefined
|
|
215
229
|
const headers: Record<string, string> = {
|
|
@@ -226,8 +240,8 @@ async function orchestratorCall<T>(
|
|
|
226
240
|
// than fetch+undici-dispatcher because the package declares no undici
|
|
227
241
|
// dependency — node:https is always importable and needs nothing added.
|
|
228
242
|
res = mtls
|
|
229
|
-
? await httpsOrchestratorRequest(url, method, headers, payload, mtls)
|
|
230
|
-
: await fetchOrchestratorRequest(url, method, headers, payload)
|
|
243
|
+
? await httpsOrchestratorRequest(url, method, headers, payload, mtls, signal)
|
|
244
|
+
: await fetchOrchestratorRequest(url, method, headers, payload, signal)
|
|
231
245
|
} catch (err) {
|
|
232
246
|
const cause = err instanceof Error ? err.cause : undefined
|
|
233
247
|
throw new Error(
|
|
@@ -238,12 +252,16 @@ async function orchestratorCall<T>(
|
|
|
238
252
|
)
|
|
239
253
|
}
|
|
240
254
|
if (res.status < 200 || res.status >= 300) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
)
|
|
255
|
+
const text = await res.text()
|
|
256
|
+
signal?.throwIfAborted()
|
|
257
|
+
throw new Error(`firecracker orchestrator ${method} ${url} → ${res.status}: ${text}`)
|
|
244
258
|
}
|
|
245
259
|
if (res.status === 204) return undefined
|
|
246
|
-
if (res.contentType.includes('application/json'))
|
|
260
|
+
if (res.contentType.includes('application/json')) {
|
|
261
|
+
const text = await res.text()
|
|
262
|
+
signal?.throwIfAborted()
|
|
263
|
+
return JSON.parse(text) as T
|
|
264
|
+
}
|
|
247
265
|
return undefined
|
|
248
266
|
}
|
|
249
267
|
|
|
@@ -253,9 +271,11 @@ async function fetchOrchestratorRequest(
|
|
|
253
271
|
method: 'POST' | 'DELETE',
|
|
254
272
|
headers: Record<string, string>,
|
|
255
273
|
payload: string | undefined,
|
|
274
|
+
signal?: AbortSignal,
|
|
256
275
|
): Promise<OrchestratorRawResponse> {
|
|
257
276
|
const init: RequestInit = { method, headers }
|
|
258
277
|
if (payload !== undefined) init.body = payload
|
|
278
|
+
if (signal !== undefined) init.signal = signal
|
|
259
279
|
const res = await fetch(url, init)
|
|
260
280
|
return {
|
|
261
281
|
status: res.status,
|
|
@@ -276,6 +296,7 @@ function httpsOrchestratorRequest(
|
|
|
276
296
|
headers: Record<string, string>,
|
|
277
297
|
payload: string | undefined,
|
|
278
298
|
mtls: MtlsClientMaterial,
|
|
299
|
+
signal?: AbortSignal,
|
|
279
300
|
): Promise<OrchestratorRawResponse> {
|
|
280
301
|
const target = new URL(url)
|
|
281
302
|
return new Promise<OrchestratorRawResponse>((resolve, reject) => {
|
|
@@ -293,6 +314,7 @@ function httpsOrchestratorRequest(
|
|
|
293
314
|
rejectUnauthorized: true,
|
|
294
315
|
minVersion: 'TLSv1.3',
|
|
295
316
|
...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
|
|
317
|
+
...(signal !== undefined ? { signal } : {}),
|
|
296
318
|
},
|
|
297
319
|
(res) => {
|
|
298
320
|
const chunks: Buffer[] = []
|
|
@@ -321,9 +343,12 @@ function httpsOrchestratorRequest(
|
|
|
321
343
|
async function spawnFirecrackerSandbox(
|
|
322
344
|
config: FirecrackerBackendInternalConfig,
|
|
323
345
|
options: SandboxBackendOptions,
|
|
346
|
+
readiness: { readonly timeoutMs: number; readonly pollIntervalMs: number },
|
|
324
347
|
): Promise<Sandbox> {
|
|
348
|
+
options.signal?.throwIfAborted()
|
|
325
349
|
const endpoint = config.orchestratorEndpoint
|
|
326
350
|
const egressAllowlist = await resolveEgressAllowlist(options)
|
|
351
|
+
options.signal?.throwIfAborted()
|
|
327
352
|
const createBody: OrchestratorCreateRequest = {
|
|
328
353
|
...(config.template !== undefined ? { template: config.template } : {}),
|
|
329
354
|
...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
|
|
@@ -345,11 +370,17 @@ async function spawnFirecrackerSandbox(
|
|
|
345
370
|
config.getToken,
|
|
346
371
|
createBody,
|
|
347
372
|
config.controlPlaneMtls,
|
|
373
|
+
options.signal,
|
|
348
374
|
)
|
|
349
375
|
} catch (err) {
|
|
376
|
+
const interrupted = options.signal?.aborted
|
|
350
377
|
throw new Error(
|
|
351
378
|
`firecracker: failed to create microVM sandbox — ${
|
|
352
379
|
err instanceof Error ? err.message : String(err)
|
|
380
|
+
}${
|
|
381
|
+
interrupted
|
|
382
|
+
? '; allocation outcome is unresolved because the orchestrator did not return a sandboxId — the deployment fleet reaper must reconcile it'
|
|
383
|
+
: ''
|
|
353
384
|
}`,
|
|
354
385
|
{ cause: err },
|
|
355
386
|
)
|
|
@@ -380,7 +411,7 @@ async function spawnFirecrackerSandbox(
|
|
|
380
411
|
)
|
|
381
412
|
const transport = new VsockAgentTransport(handle, config.transport ?? {})
|
|
382
413
|
|
|
383
|
-
const destroy = async (): Promise<void> => {
|
|
414
|
+
const destroy = async (signal?: AbortSignal): Promise<void> => {
|
|
384
415
|
await orchestratorCall(
|
|
385
416
|
endpoint,
|
|
386
417
|
`/sandboxes/${encodeURIComponent(id)}:delete`,
|
|
@@ -388,28 +419,25 @@ async function spawnFirecrackerSandbox(
|
|
|
388
419
|
config.getToken,
|
|
389
420
|
undefined,
|
|
390
421
|
config.controlPlaneMtls,
|
|
422
|
+
signal,
|
|
391
423
|
)
|
|
392
424
|
}
|
|
393
425
|
|
|
394
426
|
try {
|
|
427
|
+
options.signal?.throwIfAborted()
|
|
395
428
|
// Readiness fence: the orchestrator's resume returns BEFORE the
|
|
396
429
|
// guest agent has reseeded entropy and re-listened on vsock. Stop
|
|
397
430
|
// the clock on the agent's healthz, exactly as the HTTP backends
|
|
398
431
|
// wait on `/healthz` — never on the orchestrator's 2xx, which
|
|
399
432
|
// fires before the guest runs (§5 clock semantics).
|
|
400
|
-
await transport.waitForReady(
|
|
401
|
-
config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
402
|
-
config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
|
|
403
|
-
)
|
|
433
|
+
await transport.waitForReady(readiness.timeoutMs, readiness.pollIntervalMs, options.signal)
|
|
404
434
|
} catch (err) {
|
|
405
435
|
// Best-effort orchestrator teardown so a readiness failure does not
|
|
406
|
-
// orphan a microVM/netns/UFFD handler
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
// Preserve the readiness error as primary.
|
|
412
|
-
}
|
|
436
|
+
// orphan a microVM/netns/UFFD handler. The fleet reaper backstops a
|
|
437
|
+
// failed delete; this call gets a separate short grace so cleanup
|
|
438
|
+
// cannot turn the captured readiness error back into an unbounded
|
|
439
|
+
// create operation.
|
|
440
|
+
await runFailureCleanup(async (signal) => destroy(signal))
|
|
413
441
|
throw err
|
|
414
442
|
}
|
|
415
443
|
|
|
@@ -482,13 +510,13 @@ async function spawnFirecrackerSandbox(
|
|
|
482
510
|
return entries
|
|
483
511
|
},
|
|
484
512
|
|
|
485
|
-
async destroy(): Promise<void> {
|
|
513
|
+
async destroy(options?: SandboxDestroyOptions): Promise<void> {
|
|
486
514
|
status = 'destroyed'
|
|
487
515
|
// Let the orchestrator DELETE failure propagate — the
|
|
488
516
|
// Vandal-side lifecycle wraps this with logging, and a
|
|
489
517
|
// swallowed error here means orphaned microVMs (and their
|
|
490
518
|
// netns / UFFD handlers) pile up with no observability handle.
|
|
491
|
-
await destroy()
|
|
519
|
+
await destroy(options?.signal)
|
|
492
520
|
},
|
|
493
521
|
}
|
|
494
522
|
}
|