@namzu/sandbox 1.1.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 +474 -0
- package/LICENSE.md +110 -0
- package/README.md +148 -0
- package/dist/backends/aci-standby-pool/index.d.ts +104 -0
- package/dist/backends/aci-standby-pool/index.d.ts.map +1 -0
- package/dist/backends/aci-standby-pool/index.js +425 -0
- package/dist/backends/aci-standby-pool/index.js.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts +40 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.d.ts.map +1 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js +157 -0
- package/dist/backends/docker/__tests__/leaf-permissions.smoke.test.js.map +1 -0
- package/dist/backends/docker/index.d.ts +118 -0
- package/dist/backends/docker/index.d.ts.map +1 -0
- package/dist/backends/docker/index.js +645 -0
- package/dist/backends/docker/index.js.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.d.ts +13 -0
- package/dist/backends/firecracker/__tests__/backend.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/backend.test.js +353 -0
- package/dist/backends/firecracker/__tests__/backend.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts +19 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js +201 -0
- package/dist/backends/firecracker/__tests__/control-plane-mtls.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts +39 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js +149 -0
- package/dist/backends/firecracker/__tests__/fixtures/mtls-pki.js.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts +6 -0
- package/dist/backends/firecracker/__tests__/protocol.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js +77 -0
- package/dist/backends/firecracker/__tests__/protocol.test.js.map +1 -0
- package/dist/backends/firecracker/__tests__/transport.test.d.ts +20 -0
- package/dist/backends/firecracker/__tests__/transport.test.d.ts.map +1 -0
- package/dist/backends/firecracker/__tests__/transport.test.js +449 -0
- package/dist/backends/firecracker/__tests__/transport.test.js.map +1 -0
- package/dist/backends/firecracker/index.d.ts +124 -0
- package/dist/backends/firecracker/index.d.ts.map +1 -0
- package/dist/backends/firecracker/index.js +334 -0
- package/dist/backends/firecracker/index.js.map +1 -0
- package/dist/backends/firecracker/protocol.d.ts +132 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -0
- package/dist/backends/firecracker/protocol.js +112 -0
- package/dist/backends/firecracker/protocol.js.map +1 -0
- package/dist/backends/firecracker/transport.d.ts +251 -0
- package/dist/backends/firecracker/transport.d.ts.map +1 -0
- package/dist/backends/firecracker/transport.js +524 -0
- package/dist/backends/firecracker/transport.js.map +1 -0
- package/dist/index.d.ts +611 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +376 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +28 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +670 -0
- package/dist/index.test.js.map +1 -0
- package/package.json +54 -0
- package/src/backends/aci-standby-pool/index.ts +602 -0
- package/src/backends/docker/__tests__/leaf-permissions.smoke.test.ts +169 -0
- package/src/backends/docker/index.ts +826 -0
- package/src/backends/firecracker/__tests__/backend.test.ts +418 -0
- package/src/backends/firecracker/__tests__/control-plane-mtls.test.ts +253 -0
- package/src/backends/firecracker/__tests__/fixtures/mtls-pki.ts +166 -0
- package/src/backends/firecracker/__tests__/protocol.test.ts +90 -0
- package/src/backends/firecracker/__tests__/transport.test.ts +526 -0
- package/src/backends/firecracker/index.ts +528 -0
- package/src/backends/firecracker/protocol.ts +191 -0
- package/src/backends/firecracker/transport.ts +667 -0
- package/src/index.test.ts +731 -0
- package/src/index.ts +930 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `microvm:self-hosted` (Firecracker) backend.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `docker/` and `aci-standby-pool/`: same
|
|
5
|
+
* {@link SandboxBackend} surface, same NDJSON exec-stream + base64
|
|
6
|
+
* file-IO **wire contract**, different transport and shipping
|
|
7
|
+
* mechanism.
|
|
8
|
+
*
|
|
9
|
+
* - docker `docker run`s a container and reaches an HTTP worker on a
|
|
10
|
+
* host loopback port.
|
|
11
|
+
* - aci PUTs an ACI container group and reaches the same HTTP worker
|
|
12
|
+
* over the group's IP.
|
|
13
|
+
* - firecracker POSTs the owned Azure **orchestrator** to CoW-resume a
|
|
14
|
+
* microVM off the golden snapshot, then reaches a **custom vsock
|
|
15
|
+
* agent** baked into the golden rootfs over the **vsock transport**
|
|
16
|
+
* (`transport.ts`). The wire FORMAT is identical (see `protocol.ts`);
|
|
17
|
+
* only the transport differs — HTTP for docker/aci, framed-over-vsock
|
|
18
|
+
* for FC, because across an FC snapshot resume a TCP control channel
|
|
19
|
+
* is dead-on-arrival while the vsock LISTEN socket survives
|
|
20
|
+
* (FC `snapshot-support.md`).
|
|
21
|
+
*
|
|
22
|
+
* ## Why this is a remote-copy backend (no host bind-mounts)
|
|
23
|
+
* A sibling process on the FC host cannot see the microVM's
|
|
24
|
+
* filesystem, so — exactly like ACI — the workspace is seeded by
|
|
25
|
+
* archive-sync over the control channel (tar.gz `writeFile` +
|
|
26
|
+
* in-sandbox `tar -xzf` via `exec`), driven by the Vandal-side
|
|
27
|
+
* lifecycle (`workspace-sync.ts`). This backend therefore exposes no
|
|
28
|
+
* `layout` mount rendering; it only needs the `outputs` container path
|
|
29
|
+
* as the workspace root, which the orchestrator returns as `rootDir`.
|
|
30
|
+
*
|
|
31
|
+
* ## Trust model
|
|
32
|
+
* The microVM is a hardware-virtualization (KVM) trust boundary —
|
|
33
|
+
* stronger than docker namespaces or ACI's managed isolation. The
|
|
34
|
+
* vsock control channel never traverses the guest's egress netns; the
|
|
35
|
+
* agent listens on a fixed AF_VSOCK port captured warm in the golden
|
|
36
|
+
* snapshot. Auth to the orchestrator rides a caller-supplied
|
|
37
|
+
* `getToken()` closure (the ACI `getArmToken` pattern) so
|
|
38
|
+
* `@namzu/sandbox` keeps zero Azure-SDK dependencies.
|
|
39
|
+
*
|
|
40
|
+
* ## Auth / SDK-dependency boundary
|
|
41
|
+
* `getToken()` mirrors ACI's `getArmToken`: the consumer's runtime
|
|
42
|
+
* owns Managed-Identity / federated-credential picking; this package
|
|
43
|
+
* only calls the closure on every orchestrator HTTP call so a
|
|
44
|
+
* long-running sandbox survives token rotation.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import https from 'node:https'
|
|
48
|
+
|
|
49
|
+
import type {
|
|
50
|
+
Sandbox,
|
|
51
|
+
SandboxEnvironment,
|
|
52
|
+
SandboxExecOptions,
|
|
53
|
+
SandboxExecResult,
|
|
54
|
+
SandboxFileEntry,
|
|
55
|
+
SandboxId,
|
|
56
|
+
SandboxStatus,
|
|
57
|
+
} from '@namzu/sdk'
|
|
58
|
+
|
|
59
|
+
import type { AgentSnapshotRef, SandboxBackend, SandboxBackendOptions } from '../../index.js'
|
|
60
|
+
import type {
|
|
61
|
+
MtlsClientMaterial,
|
|
62
|
+
SandboxAgentHandle,
|
|
63
|
+
VsockTransportOptions,
|
|
64
|
+
WireSandboxAgentHandle,
|
|
65
|
+
} from './transport.js'
|
|
66
|
+
import { VsockAgentTransport } from './transport.js'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Async callback returning a fresh bearer token for
|
|
70
|
+
* {@link FirecrackerBackendInternalConfig.orchestratorEndpoint}.
|
|
71
|
+
* Invoked on every orchestrator HTTP call (mirrors ACI's
|
|
72
|
+
* `ArmTokenProvider`).
|
|
73
|
+
*/
|
|
74
|
+
export type OrchestratorTokenProvider = () => Promise<string>
|
|
75
|
+
|
|
76
|
+
export interface FirecrackerBackendInternalConfig {
|
|
77
|
+
/** Base URL of the owned Azure control plane / orchestrator. */
|
|
78
|
+
readonly orchestratorEndpoint: string
|
|
79
|
+
/** Bearer token provider for `orchestratorEndpoint`. */
|
|
80
|
+
readonly getToken: OrchestratorTokenProvider
|
|
81
|
+
/** Golden template / snapshot revision id to resume from. */
|
|
82
|
+
readonly template?: string
|
|
83
|
+
/**
|
|
84
|
+
* Per-agent captured snapshot to resume INSTEAD of a fresh golden boot
|
|
85
|
+
* (layered on its base golden). Optional + additive: absent ⇒ the
|
|
86
|
+
* orchestrator create body is byte-identical and the generic golden-resume
|
|
87
|
+
* path is unchanged. The orchestrator honors it (routing the claim to a
|
|
88
|
+
* per-agent resume) only when its own per-agent flag is on; it is the WIRE
|
|
89
|
+
* carrier the host's per-agent trigger path sets. See {@link AgentSnapshotRef}.
|
|
90
|
+
*/
|
|
91
|
+
readonly agentSnapshot?: AgentSnapshotRef
|
|
92
|
+
/** Fixed guest AF_VSOCK port the agent listens on (contract port). */
|
|
93
|
+
readonly agentVsockPort?: number
|
|
94
|
+
readonly readyTimeoutMs?: number
|
|
95
|
+
readonly readyPollIntervalMs?: number
|
|
96
|
+
/** Transport tuning forwarded to {@link VsockAgentTransport}. */
|
|
97
|
+
readonly transport?: VsockTransportOptions
|
|
98
|
+
/**
|
|
99
|
+
* NETWORK-mode mTLS client material (ses_051 P4). When present AND the
|
|
100
|
+
* orchestrator returns an `mtls` agent handle, this CA/cert/key is MERGED
|
|
101
|
+
* onto the handle's `tls` block before the transport dials the per-host
|
|
102
|
+
* relay. The consumer's runtime injects it (mirrors `getToken`); this
|
|
103
|
+
* package never reads it from disk or fetches it, so it stays Azure-SDK
|
|
104
|
+
* free. Absent for the single-host VSOCK default (the live proofs).
|
|
105
|
+
*/
|
|
106
|
+
readonly mtls?: MtlsClientMaterial
|
|
107
|
+
/**
|
|
108
|
+
* CONTROL-plane mTLS client material. When present, the orchestrator
|
|
109
|
+
* control-plane calls (`POST /sandboxes`, `DELETE /sandboxes/{id}:delete`,
|
|
110
|
+
* `GET /capacity`) dial over mTLS — presenting this client cert and
|
|
111
|
+
* verifying the orchestrator's server cert against this CA — INSTEAD of the
|
|
112
|
+
* plain `fetch`. This secures the control plane when `orchestratorEndpoint`
|
|
113
|
+
* is reached over the PUBLIC internet (the `ca-vandal-app` → FC-host hop is
|
|
114
|
+
* not VNet-integrated), where the shared-secret bearer alone would be
|
|
115
|
+
* exposed. The bearer is STILL sent (defense in depth on top of mTLS).
|
|
116
|
+
*
|
|
117
|
+
* Reuses the SAME `{ca,cert,key,servername}` shape as the relay's `mtls`
|
|
118
|
+
* material — one fleet CA secures both planes. The consumer's runtime
|
|
119
|
+
* injects the bytes (mirrors `getToken` / `mtls`); the package never reads
|
|
120
|
+
* them from disk. Absent → the control plane stays plain `fetch` (the
|
|
121
|
+
* single-host DEFAULT, byte-for-byte unchanged).
|
|
122
|
+
*/
|
|
123
|
+
readonly controlPlaneMtls?: MtlsClientMaterial
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DEFAULT_AGENT_VSOCK_PORT = 1024
|
|
127
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000
|
|
128
|
+
const DEFAULT_READY_POLL_MS = 250
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a {@link SandboxBackend} backed by the owned Firecracker
|
|
132
|
+
* platform. Construction is synchronous; the orchestrator POST happens
|
|
133
|
+
* on the first `create()`.
|
|
134
|
+
*/
|
|
135
|
+
export function buildFirecrackerBackend(config: FirecrackerBackendInternalConfig): SandboxBackend {
|
|
136
|
+
return {
|
|
137
|
+
tier: 'microvm',
|
|
138
|
+
name: 'firecracker',
|
|
139
|
+
async create(options: SandboxBackendOptions): Promise<Sandbox> {
|
|
140
|
+
return await spawnFirecrackerSandbox(config, options)
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Orchestrator wire (the create/destroy control plane)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
interface OrchestratorCreateRequest {
|
|
150
|
+
readonly template?: string
|
|
151
|
+
readonly memoryLimitMb?: number
|
|
152
|
+
readonly maxProcesses?: number
|
|
153
|
+
readonly timeoutMs?: number
|
|
154
|
+
/**
|
|
155
|
+
* Resolved egress allowlist for the run, materialised by the host
|
|
156
|
+
* into per-VM nftables rules (deny-all default). The backend just
|
|
157
|
+
* forwards the resolved hostnames; it does not own the firewall.
|
|
158
|
+
*/
|
|
159
|
+
readonly egressAllowlist?: readonly string[]
|
|
160
|
+
/**
|
|
161
|
+
* Per-agent captured snapshot to resume (layered on the base golden).
|
|
162
|
+
* Optional + additive: omitted from the POST body when undefined (the
|
|
163
|
+
* spread below drops it), so a generic create is byte-identical to the
|
|
164
|
+
* pre-field wire shape. The orchestrator routes the claim to a per-agent
|
|
165
|
+
* resume only when present AND its own per-agent flag is on; otherwise it
|
|
166
|
+
* ignores it and runs the generic golden claim. See {@link AgentSnapshotRef}.
|
|
167
|
+
*/
|
|
168
|
+
readonly agentSnapshot?: AgentSnapshotRef
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Orchestrator `create` response. Carries the sandbox id, the
|
|
173
|
+
* addressable vsock endpoint (see {@link SandboxAgentHandle}), and the
|
|
174
|
+
* workspace root path the agent resolves against.
|
|
175
|
+
*/
|
|
176
|
+
interface OrchestratorCreateResponse {
|
|
177
|
+
readonly sandboxId: string
|
|
178
|
+
readonly agent: WireSandboxAgentHandle
|
|
179
|
+
readonly rootDir: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A transport-agnostic view of the orchestrator response the dispatch needs. */
|
|
183
|
+
interface OrchestratorRawResponse {
|
|
184
|
+
readonly status: number
|
|
185
|
+
readonly contentType: string
|
|
186
|
+
readonly text: () => Promise<string>
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function orchestratorCall<T>(
|
|
190
|
+
endpoint: string,
|
|
191
|
+
pathSuffix: string,
|
|
192
|
+
method: 'POST' | 'DELETE',
|
|
193
|
+
getToken: OrchestratorTokenProvider,
|
|
194
|
+
body?: unknown,
|
|
195
|
+
mtls?: MtlsClientMaterial,
|
|
196
|
+
): Promise<T | undefined> {
|
|
197
|
+
const token = await getToken()
|
|
198
|
+
const url = `${endpoint.replace(/\/+$/, '')}${pathSuffix}`
|
|
199
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined
|
|
200
|
+
const headers: Record<string, string> = {
|
|
201
|
+
Authorization: `Bearer ${token}`,
|
|
202
|
+
'content-type': 'application/json',
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let res: OrchestratorRawResponse
|
|
206
|
+
try {
|
|
207
|
+
// When CONTROL-plane mTLS material is injected the call dials over a
|
|
208
|
+
// node:https request presenting the client cert + pinning the server CA
|
|
209
|
+
// (the public-internet hop). Without it the EXISTING plain `fetch` path
|
|
210
|
+
// runs unchanged (the single-host default). node:https is used rather
|
|
211
|
+
// than fetch+undici-dispatcher because the package declares no undici
|
|
212
|
+
// dependency — node:https is always importable and needs nothing added.
|
|
213
|
+
res = mtls
|
|
214
|
+
? await httpsOrchestratorRequest(url, method, headers, payload, mtls)
|
|
215
|
+
: await fetchOrchestratorRequest(url, method, headers, payload)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const cause = err instanceof Error ? err.cause : undefined
|
|
218
|
+
throw new Error(
|
|
219
|
+
`firecracker orchestrator ${method} ${url} failed: ${
|
|
220
|
+
err instanceof Error ? err.message : String(err)
|
|
221
|
+
}${cause ? ` — cause: ${cause instanceof Error ? cause.message : String(cause)}` : ''}`,
|
|
222
|
+
{ cause: err },
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
if (res.status < 200 || res.status >= 300) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`firecracker orchestrator ${method} ${url} → ${res.status}: ${await res.text()}`,
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
if (res.status === 204) return undefined
|
|
231
|
+
if (res.contentType.includes('application/json')) return JSON.parse(await res.text()) as T
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The plain-`fetch` control-plane path — the single-host DEFAULT, unchanged. */
|
|
236
|
+
async function fetchOrchestratorRequest(
|
|
237
|
+
url: string,
|
|
238
|
+
method: 'POST' | 'DELETE',
|
|
239
|
+
headers: Record<string, string>,
|
|
240
|
+
payload: string | undefined,
|
|
241
|
+
): Promise<OrchestratorRawResponse> {
|
|
242
|
+
const init: RequestInit = { method, headers }
|
|
243
|
+
if (payload !== undefined) init.body = payload
|
|
244
|
+
const res = await fetch(url, init)
|
|
245
|
+
return {
|
|
246
|
+
status: res.status,
|
|
247
|
+
contentType: res.headers.get('content-type') ?? '',
|
|
248
|
+
text: () => res.text(),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The mTLS control-plane path: a node:https request presenting the injected
|
|
254
|
+
* client cert and verifying the orchestrator's server cert against the injected
|
|
255
|
+
* CA (`rejectUnauthorized: true`). Used only when `controlPlaneMtls` is present;
|
|
256
|
+
* the plain-`fetch` path above stays the default for single-host.
|
|
257
|
+
*/
|
|
258
|
+
function httpsOrchestratorRequest(
|
|
259
|
+
url: string,
|
|
260
|
+
method: 'POST' | 'DELETE',
|
|
261
|
+
headers: Record<string, string>,
|
|
262
|
+
payload: string | undefined,
|
|
263
|
+
mtls: MtlsClientMaterial,
|
|
264
|
+
): Promise<OrchestratorRawResponse> {
|
|
265
|
+
const target = new URL(url)
|
|
266
|
+
return new Promise<OrchestratorRawResponse>((resolve, reject) => {
|
|
267
|
+
const req = https.request(
|
|
268
|
+
{
|
|
269
|
+
protocol: target.protocol,
|
|
270
|
+
hostname: target.hostname,
|
|
271
|
+
port: target.port !== '' ? Number(target.port) : 443,
|
|
272
|
+
path: `${target.pathname}${target.search}`,
|
|
273
|
+
method,
|
|
274
|
+
headers,
|
|
275
|
+
ca: mtls.ca,
|
|
276
|
+
cert: mtls.cert,
|
|
277
|
+
key: mtls.key,
|
|
278
|
+
rejectUnauthorized: true,
|
|
279
|
+
minVersion: 'TLSv1.3',
|
|
280
|
+
...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
|
|
281
|
+
},
|
|
282
|
+
(res) => {
|
|
283
|
+
const chunks: Buffer[] = []
|
|
284
|
+
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
285
|
+
res.on('end', () => {
|
|
286
|
+
const ct = res.headers['content-type']
|
|
287
|
+
resolve({
|
|
288
|
+
status: res.statusCode ?? 0,
|
|
289
|
+
contentType: Array.isArray(ct) ? (ct[0] ?? '') : (ct ?? ''),
|
|
290
|
+
text: async () => Buffer.concat(chunks).toString('utf8'),
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
res.on('error', reject)
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
req.on('error', reject)
|
|
297
|
+
if (payload !== undefined) req.write(payload)
|
|
298
|
+
req.end()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// create()
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async function spawnFirecrackerSandbox(
|
|
307
|
+
config: FirecrackerBackendInternalConfig,
|
|
308
|
+
options: SandboxBackendOptions,
|
|
309
|
+
): Promise<Sandbox> {
|
|
310
|
+
const endpoint = config.orchestratorEndpoint
|
|
311
|
+
const createBody: OrchestratorCreateRequest = {
|
|
312
|
+
...(config.template !== undefined ? { template: config.template } : {}),
|
|
313
|
+
...(config.agentSnapshot !== undefined ? { agentSnapshot: config.agentSnapshot } : {}),
|
|
314
|
+
...(options.memoryLimitMb !== undefined ? { memoryLimitMb: options.memoryLimitMb } : {}),
|
|
315
|
+
...(options.maxProcesses !== undefined ? { maxProcesses: options.maxProcesses } : {}),
|
|
316
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
317
|
+
...(resolveEgressAllowlist(options) !== undefined
|
|
318
|
+
? { egressAllowlist: resolveEgressAllowlist(options) }
|
|
319
|
+
: {}),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let created: OrchestratorCreateResponse | undefined
|
|
323
|
+
try {
|
|
324
|
+
created = await orchestratorCall<OrchestratorCreateResponse>(
|
|
325
|
+
endpoint,
|
|
326
|
+
'/sandboxes',
|
|
327
|
+
'POST',
|
|
328
|
+
config.getToken,
|
|
329
|
+
createBody,
|
|
330
|
+
config.controlPlaneMtls,
|
|
331
|
+
)
|
|
332
|
+
} catch (err) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`firecracker: failed to create microVM sandbox — ${
|
|
335
|
+
err instanceof Error ? err.message : String(err)
|
|
336
|
+
}`,
|
|
337
|
+
{ cause: err },
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
if (!created || !created.sandboxId || !created.agent) {
|
|
341
|
+
throw new Error('firecracker: orchestrator create returned no sandboxId / agent handle')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const id = created.sandboxId as SandboxId
|
|
345
|
+
const rootDir = created.rootDir
|
|
346
|
+
// Normalise the orchestrator handle: fill the contract vsock port when a
|
|
347
|
+
// vsock handle omits it, and MERGE the consumer-injected mTLS material onto
|
|
348
|
+
// a network (`mtls`) handle so the transport can dial the relay. The
|
|
349
|
+
// orchestrator returns an `mtls` handle WITHOUT cert material (host/port/
|
|
350
|
+
// sandboxId only); the certs are injected here from `config.mtls`, never
|
|
351
|
+
// shipped by the control plane.
|
|
352
|
+
const handle = normalizeHandle(
|
|
353
|
+
created.agent,
|
|
354
|
+
config.agentVsockPort ?? DEFAULT_AGENT_VSOCK_PORT,
|
|
355
|
+
config.mtls,
|
|
356
|
+
)
|
|
357
|
+
const transport = new VsockAgentTransport(handle, config.transport ?? {})
|
|
358
|
+
|
|
359
|
+
const destroy = async (): Promise<void> => {
|
|
360
|
+
await orchestratorCall(
|
|
361
|
+
endpoint,
|
|
362
|
+
`/sandboxes/${encodeURIComponent(id)}:delete`,
|
|
363
|
+
'DELETE',
|
|
364
|
+
config.getToken,
|
|
365
|
+
undefined,
|
|
366
|
+
config.controlPlaneMtls,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
// Readiness fence: the orchestrator's resume returns BEFORE the
|
|
372
|
+
// guest agent has reseeded entropy and re-listened on vsock. Stop
|
|
373
|
+
// the clock on the agent's healthz, exactly as the HTTP backends
|
|
374
|
+
// wait on `/healthz` — never on the orchestrator's 2xx, which
|
|
375
|
+
// fires before the guest runs (§5 clock semantics).
|
|
376
|
+
await transport.waitForReady(
|
|
377
|
+
config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
378
|
+
config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
|
|
379
|
+
)
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// Best-effort orchestrator teardown so a readiness failure does not
|
|
382
|
+
// orphan a microVM/netns/UFFD handler (the reaper backstops, but
|
|
383
|
+
// surfacing the delete failure keeps the leak observable).
|
|
384
|
+
try {
|
|
385
|
+
await destroy()
|
|
386
|
+
} catch {
|
|
387
|
+
// Preserve the readiness error as primary.
|
|
388
|
+
}
|
|
389
|
+
throw err
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let status: SandboxStatus = 'ready'
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
id,
|
|
396
|
+
get status(): SandboxStatus {
|
|
397
|
+
return status
|
|
398
|
+
},
|
|
399
|
+
rootDir,
|
|
400
|
+
environment: detectEnvironment(),
|
|
401
|
+
|
|
402
|
+
async exec(
|
|
403
|
+
command: string,
|
|
404
|
+
argv?: string[],
|
|
405
|
+
opts?: SandboxExecOptions,
|
|
406
|
+
): Promise<SandboxExecResult> {
|
|
407
|
+
status = 'busy'
|
|
408
|
+
try {
|
|
409
|
+
return await transport.execute({
|
|
410
|
+
command,
|
|
411
|
+
args: argv ?? [],
|
|
412
|
+
...(opts?.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
413
|
+
...(opts?.env !== undefined ? { env: opts.env } : {}),
|
|
414
|
+
...(opts?.timeout !== undefined ? { timeoutMs: opts.timeout } : {}),
|
|
415
|
+
})
|
|
416
|
+
} finally {
|
|
417
|
+
status = 'ready'
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
async writeFile(path: string, content: string | Buffer): Promise<void> {
|
|
422
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
|
|
423
|
+
await transport.writeFile(path, buf)
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
async readFile(path: string): Promise<Buffer> {
|
|
427
|
+
return await transport.readFile(path)
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
|
|
431
|
+
// Same wire as docker/aci: `find -printf '%p\t%s\n'`, parse
|
|
432
|
+
// line-by-line, map a non-zero exit (missing root) to "empty".
|
|
433
|
+
const result = await transport.execute({
|
|
434
|
+
command: 'find',
|
|
435
|
+
args: [rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
|
|
436
|
+
})
|
|
437
|
+
if (result.exitCode !== 0) return []
|
|
438
|
+
const entries: SandboxFileEntry[] = []
|
|
439
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
440
|
+
if (!rawLine) continue
|
|
441
|
+
const tab = rawLine.indexOf('\t')
|
|
442
|
+
if (tab < 0) continue
|
|
443
|
+
const filePath = rawLine.slice(0, tab)
|
|
444
|
+
const size = Number.parseInt(rawLine.slice(tab + 1), 10)
|
|
445
|
+
if (!filePath || !Number.isFinite(size)) continue
|
|
446
|
+
entries.push({ path: filePath, size })
|
|
447
|
+
}
|
|
448
|
+
return entries
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
async destroy(): Promise<void> {
|
|
452
|
+
status = 'destroyed'
|
|
453
|
+
// Let the orchestrator DELETE failure propagate — the
|
|
454
|
+
// Vandal-side lifecycle wraps this with logging, and a
|
|
455
|
+
// swallowed error here means orphaned microVMs (and their
|
|
456
|
+
// netns / UFFD handlers) pile up with no observability handle.
|
|
457
|
+
await destroy()
|
|
458
|
+
},
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Helpers
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
function resolveEgressAllowlist(options: SandboxBackendOptions): readonly string[] | undefined {
|
|
467
|
+
const egress = options.egress
|
|
468
|
+
if (!egress) return undefined
|
|
469
|
+
if (egress.kind === 'static') return egress.allowedHosts
|
|
470
|
+
// `deny-all` → empty allowlist (explicit). `allow-all` / `resolver`
|
|
471
|
+
// are resolved by the host before create when they apply; the
|
|
472
|
+
// backend forwards only the static, already-resolved shape. A
|
|
473
|
+
// resolver-shaped policy is the Vandal lifecycle's job to resolve
|
|
474
|
+
// upstream and pass as `static`.
|
|
475
|
+
if (egress.kind === 'deny-all') return []
|
|
476
|
+
return undefined
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Turn the orchestrator's WIRE handle into the transport handle the dialer
|
|
481
|
+
* uses. Two transforms:
|
|
482
|
+
*
|
|
483
|
+
* - `vsock` with a missing/<=0 port → fill the contract port.
|
|
484
|
+
* - `mtls` → MERGE the consumer-injected `mtls` cert material onto the
|
|
485
|
+
* wire handle (which carries only host/port/sandboxId). The orchestrator
|
|
486
|
+
* never ships cert material; without an injected `mtls` block a network
|
|
487
|
+
* handle cannot dial, so this throws loud rather than constructing a
|
|
488
|
+
* transport that would fail at connect time.
|
|
489
|
+
*
|
|
490
|
+
* `unix` + already-correct `vsock` handles pass through untouched.
|
|
491
|
+
*/
|
|
492
|
+
export function normalizeHandle(
|
|
493
|
+
handle: WireSandboxAgentHandle,
|
|
494
|
+
contractPort: number,
|
|
495
|
+
mtls: MtlsClientMaterial | undefined,
|
|
496
|
+
): SandboxAgentHandle {
|
|
497
|
+
if (handle.kind === 'mtls') {
|
|
498
|
+
if (!mtls) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
'firecracker: orchestrator returned an mtls agent handle but no client cert material was injected ' +
|
|
501
|
+
'(the Vandal host layer must supply VANDAL_SANDBOX_FC_TLS_CA/_CERT/_KEY in network mode)',
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
kind: 'mtls',
|
|
506
|
+
host: handle.host,
|
|
507
|
+
port: handle.port,
|
|
508
|
+
sandboxId: handle.sandboxId,
|
|
509
|
+
tls: {
|
|
510
|
+
ca: mtls.ca,
|
|
511
|
+
cert: mtls.cert,
|
|
512
|
+
key: mtls.key,
|
|
513
|
+
...(mtls.servername !== undefined ? { servername: mtls.servername } : {}),
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (handle.kind === 'vsock' && (!handle.port || handle.port <= 0)) {
|
|
518
|
+
return { kind: 'vsock', udsPath: handle.udsPath, port: contractPort }
|
|
519
|
+
}
|
|
520
|
+
return handle
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function detectEnvironment(): SandboxEnvironment {
|
|
524
|
+
// Firecracker guests run Linux; the agent is a Linux-namespace-style
|
|
525
|
+
// worker from the SDK's perspective (the enum is host-shape, not
|
|
526
|
+
// guest internals).
|
|
527
|
+
return 'linux-namespace'
|
|
528
|
+
}
|