@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,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Container Instances Standby Pool backend.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `docker/` — same {@link SandboxBackend} surface, same
|
|
5
|
+
* worker-HTTP contract, different shipping mechanism. Where docker
|
|
6
|
+
* `docker run`s a container on a local daemon, this backend PUTs an
|
|
7
|
+
* `Microsoft.ContainerInstance/containerGroups` resource that
|
|
8
|
+
* references a pre-warmed `Microsoft.StandbyPool/standbyContainerGroupPools`
|
|
9
|
+
* resource — Azure hands back a warm ACI in ~1.5 s instead of a
|
|
10
|
+
* cold 10-30 s spawn. Refill is automatic per the pool's
|
|
11
|
+
* `refillPolicy`.
|
|
12
|
+
*
|
|
13
|
+
* Workspace shipping:
|
|
14
|
+
* - Docker uses bind-mounts (`hostDir` source variant).
|
|
15
|
+
* - ACI has no host filesystem; this backend ONLY accepts
|
|
16
|
+
* `azureFileShare` source variants and translates them to ACI's
|
|
17
|
+
* `properties.volumes[] + container.properties.volumeMounts[]`
|
|
18
|
+
* shape. The Vandal-side (or any host) provisions per-task
|
|
19
|
+
* shares upstream and hands them in via the layout.
|
|
20
|
+
*
|
|
21
|
+
* Authentication:
|
|
22
|
+
* - Caller supplies a `getArmToken()` async function. Sandbox
|
|
23
|
+
* keeps zero auth dependencies (`@azure/identity` etc.) — the
|
|
24
|
+
* consumer's runtime owns Managed-Identity / AzureCLI / federated
|
|
25
|
+
* credential picking. Token is fetched on every ARM call so a
|
|
26
|
+
* short-lived token survives a long-running sandbox.
|
|
27
|
+
*
|
|
28
|
+
* Trust model:
|
|
29
|
+
* - ACI runs the container in a Microsoft-owned isolation host;
|
|
30
|
+
* inside, the worker is a non-root user (image's `USER namzu`).
|
|
31
|
+
* - The container group can be subnet-injected (no public IP) when
|
|
32
|
+
* `subnetId` is supplied. Without it the IP is public — fine for
|
|
33
|
+
* benchmarking, NOT acceptable for production. Caller decides.
|
|
34
|
+
* - The Confidential variant of Standby Pools (AMD SEV-SNP TEE) is
|
|
35
|
+
* a pool-side knob, not a backend knob — the backend never
|
|
36
|
+
* chooses; it just PUTs against whichever pool the caller named.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type {
|
|
40
|
+
ContainerSandboxMountSource,
|
|
41
|
+
ResolvedContainerSandboxLayout,
|
|
42
|
+
Sandbox,
|
|
43
|
+
SandboxEnvironment,
|
|
44
|
+
SandboxExecOptions,
|
|
45
|
+
SandboxExecResult,
|
|
46
|
+
SandboxFileEntry,
|
|
47
|
+
SandboxId,
|
|
48
|
+
SandboxStatus,
|
|
49
|
+
} from '@namzu/sdk'
|
|
50
|
+
|
|
51
|
+
import type { SandboxBackend, SandboxBackendOptions } from '../../index.js'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Authentication callback. Caller returns a fresh Azure Resource
|
|
55
|
+
* Manager bearer token (audience `https://management.azure.com/`).
|
|
56
|
+
* Backend invokes this on every ARM call so a long-running sandbox
|
|
57
|
+
* survives token rotation.
|
|
58
|
+
*/
|
|
59
|
+
export type ArmTokenProvider = () => Promise<string>
|
|
60
|
+
|
|
61
|
+
export interface ACIStandbyPoolBackendInternalConfig {
|
|
62
|
+
readonly subscriptionId: string
|
|
63
|
+
readonly resourceGroup: string
|
|
64
|
+
readonly location: string
|
|
65
|
+
/**
|
|
66
|
+
* Fully-qualified resource ID of the Standby Pool to claim from.
|
|
67
|
+
* Example:
|
|
68
|
+
* /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.StandbyPool/standbyContainerGroupPools/<pool>
|
|
69
|
+
*/
|
|
70
|
+
readonly standbyPoolResourceId: string
|
|
71
|
+
/**
|
|
72
|
+
* Fully-qualified resource ID of the Container Group Profile the
|
|
73
|
+
* pool was created against.
|
|
74
|
+
*/
|
|
75
|
+
readonly containerGroupProfileResourceId: string
|
|
76
|
+
/**
|
|
77
|
+
* Container Group Profile revision the pool's warm instances were
|
|
78
|
+
* built from. Defaults to 1.
|
|
79
|
+
*/
|
|
80
|
+
readonly containerGroupProfileRevision?: number
|
|
81
|
+
/**
|
|
82
|
+
* Pre-resolved layout. The backend requires every mount source to
|
|
83
|
+
* be `azureFileShare`; any other variant throws.
|
|
84
|
+
*/
|
|
85
|
+
readonly layout: ResolvedContainerSandboxLayout
|
|
86
|
+
/**
|
|
87
|
+
* Authentication callback (see {@link ArmTokenProvider}).
|
|
88
|
+
*/
|
|
89
|
+
readonly getArmToken: ArmTokenProvider
|
|
90
|
+
/**
|
|
91
|
+
* Optional subnet to inject the container group into (no public IP).
|
|
92
|
+
* Strongly recommended for production. When omitted, ACI assigns a
|
|
93
|
+
* public IP — fine for benchmarks, attack-surface for prod.
|
|
94
|
+
*/
|
|
95
|
+
readonly subnetId?: string
|
|
96
|
+
readonly readyPollIntervalMs?: number
|
|
97
|
+
readonly readyTimeoutMs?: number
|
|
98
|
+
/**
|
|
99
|
+
* Worker HTTP port (matches the image's listening port). Default 2024.
|
|
100
|
+
*/
|
|
101
|
+
readonly workerPort?: number
|
|
102
|
+
readonly armApiVersion?: string
|
|
103
|
+
/**
|
|
104
|
+
* Prefix for the ACI container group name and the inner worker
|
|
105
|
+
* container. Defaults to a Namzu-branded label; consumers (e.g.
|
|
106
|
+
* Vandal) override via env / config to brand their own
|
|
107
|
+
* deployments. The runtime appends a sandbox id suffix; the
|
|
108
|
+
* combined name is sanitised to ARM's allowed character set.
|
|
109
|
+
*/
|
|
110
|
+
readonly containerNamePrefix?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DEFAULT_READY_POLL_MS = 500
|
|
114
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000
|
|
115
|
+
const DEFAULT_WORKER_PORT = 2024
|
|
116
|
+
const DEFAULT_ARM_API_VERSION = '2024-05-01-preview'
|
|
117
|
+
const ARM_BASE = 'https://management.azure.com'
|
|
118
|
+
const DEFAULT_CONTAINER_NAME_PREFIX = 'namzu-task'
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a {@link SandboxBackend} backed by Azure Container Instances
|
|
122
|
+
* Standby Pool. Construction is synchronous; the ARM PUT happens on
|
|
123
|
+
* the first `create()`.
|
|
124
|
+
*/
|
|
125
|
+
export function buildAciStandbyPoolBackend(
|
|
126
|
+
config: ACIStandbyPoolBackendInternalConfig,
|
|
127
|
+
): SandboxBackend {
|
|
128
|
+
return {
|
|
129
|
+
tier: 'container',
|
|
130
|
+
name: 'aci-standby-pool',
|
|
131
|
+
async create(options: SandboxBackendOptions): Promise<Sandbox> {
|
|
132
|
+
return await spawnAciSandbox(config, options)
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface AzureFileShareSource {
|
|
138
|
+
readonly storageAccountName: string
|
|
139
|
+
readonly shareName: string
|
|
140
|
+
readonly storageAccountKey: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Interpret one mount source. ACI accepts two source variants:
|
|
145
|
+
* - `azureFileShare` → emit an ACI `volume.azureFile` + matching `volumeMount`.
|
|
146
|
+
* - `inImage` → emit NOTHING; the container's own filesystem carries the path.
|
|
147
|
+
*
|
|
148
|
+
* Standby-Pool-warm flows MUST use `inImage` because Standby Pool's
|
|
149
|
+
* claim-time API rejects every `volumes[]` override (the volume set
|
|
150
|
+
* is profile-baked across all warm instances). Cold-spawn ACI flows
|
|
151
|
+
* can use either.
|
|
152
|
+
*
|
|
153
|
+
* The `hostDir` variant is for docker backends and is rejected here.
|
|
154
|
+
*/
|
|
155
|
+
function interpretSource(
|
|
156
|
+
source: ContainerSandboxMountSource,
|
|
157
|
+
label: string,
|
|
158
|
+
): { kind: 'azureFile'; source: AzureFileShareSource } | { kind: 'inImage' } {
|
|
159
|
+
if (source.type === 'azureFileShare') {
|
|
160
|
+
return {
|
|
161
|
+
kind: 'azureFile',
|
|
162
|
+
source: {
|
|
163
|
+
storageAccountName: source.storageAccountName,
|
|
164
|
+
shareName: source.shareName,
|
|
165
|
+
storageAccountKey: source.storageAccountKey,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (source.type === 'inImage') {
|
|
170
|
+
return { kind: 'inImage' }
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`aci-standby-pool backend cannot consume mount source type ${JSON.stringify(source.type)} for ${label}; expected 'azureFileShare' or 'inImage'. The hostDir variant belongs to the docker backend.`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface BuiltVolumes {
|
|
178
|
+
readonly volumes: Array<{
|
|
179
|
+
name: string
|
|
180
|
+
azureFile: {
|
|
181
|
+
shareName: string
|
|
182
|
+
storageAccountName: string
|
|
183
|
+
storageAccountKey: string
|
|
184
|
+
readOnly: boolean
|
|
185
|
+
}
|
|
186
|
+
}>
|
|
187
|
+
readonly volumeMounts: Array<{
|
|
188
|
+
name: string
|
|
189
|
+
mountPath: string
|
|
190
|
+
readOnly: boolean
|
|
191
|
+
}>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function buildAzureFileVolumesFromLayout(layout: ResolvedContainerSandboxLayout): BuiltVolumes {
|
|
195
|
+
const volumes: BuiltVolumes['volumes'] = []
|
|
196
|
+
const volumeMounts: BuiltVolumes['volumeMounts'] = []
|
|
197
|
+
let counter = 0
|
|
198
|
+
|
|
199
|
+
function add(
|
|
200
|
+
mount: { readonly source: ContainerSandboxMountSource; readonly containerPath: string },
|
|
201
|
+
label: string,
|
|
202
|
+
readOnly: boolean,
|
|
203
|
+
): void {
|
|
204
|
+
const interpreted = interpretSource(mount.source, label)
|
|
205
|
+
// `inImage` is a no-op — the image's own filesystem provides
|
|
206
|
+
// the path. The Standby-Pool-warm flow lives on this branch.
|
|
207
|
+
if (interpreted.kind === 'inImage') return
|
|
208
|
+
const source = interpreted.source
|
|
209
|
+
const name = `vol-${label}-${counter++}`
|
|
210
|
+
volumes.push({
|
|
211
|
+
name,
|
|
212
|
+
azureFile: {
|
|
213
|
+
shareName: source.shareName,
|
|
214
|
+
storageAccountName: source.storageAccountName,
|
|
215
|
+
storageAccountKey: source.storageAccountKey,
|
|
216
|
+
readOnly,
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
volumeMounts.push({
|
|
220
|
+
name,
|
|
221
|
+
mountPath: mount.containerPath,
|
|
222
|
+
readOnly,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
add(layout.outputs, 'outputs', false)
|
|
227
|
+
if (layout.uploads) add(layout.uploads, 'uploads', true)
|
|
228
|
+
if (layout.scratch) add(layout.scratch, 'scratch', false)
|
|
229
|
+
if (layout.toolResults) add(layout.toolResults, 'toolResults', true)
|
|
230
|
+
if (layout.transcripts) add(layout.transcripts, 'transcripts', true)
|
|
231
|
+
if (layout.skills) {
|
|
232
|
+
for (const skill of layout.skills) {
|
|
233
|
+
add({ source: skill.source, containerPath: skill.containerPath }, `skill-${skill.id}`, true)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { volumes, volumeMounts }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function detectEnvironment(): SandboxEnvironment {
|
|
241
|
+
// ACI containers run Linux. The SandboxEnvironment enum is host-
|
|
242
|
+
// platform shape, not container internals — we pick the variant
|
|
243
|
+
// the consumer's code paths expect for a Linux namespace-isolated
|
|
244
|
+
// worker.
|
|
245
|
+
return 'linux-namespace'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let _sandboxIdCounter = 0
|
|
249
|
+
function generateSandboxId(): SandboxId {
|
|
250
|
+
const ts = Date.now().toString(36)
|
|
251
|
+
const rand = Math.random().toString(36).slice(2, 8)
|
|
252
|
+
_sandboxIdCounter += 1
|
|
253
|
+
return `sbx_${ts}_${rand}_${_sandboxIdCounter}` as SandboxId
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function armCall<T>(
|
|
257
|
+
url: string,
|
|
258
|
+
method: 'GET' | 'PUT' | 'DELETE',
|
|
259
|
+
getToken: ArmTokenProvider,
|
|
260
|
+
body?: unknown,
|
|
261
|
+
): Promise<T | undefined> {
|
|
262
|
+
const token = await getToken()
|
|
263
|
+
const init: RequestInit = {
|
|
264
|
+
method,
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${token}`,
|
|
267
|
+
'content-type': 'application/json',
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
if (body !== undefined) {
|
|
271
|
+
init.body = JSON.stringify(body)
|
|
272
|
+
}
|
|
273
|
+
const res = await fetch(url, init)
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
const text = await res.text()
|
|
276
|
+
throw new Error(`ARM ${method} ${url} → ${res.status}: ${text}`)
|
|
277
|
+
}
|
|
278
|
+
if (res.status === 204 || res.status === 202) return undefined
|
|
279
|
+
const ct = res.headers.get('content-type') ?? ''
|
|
280
|
+
if (ct.includes('application/json')) {
|
|
281
|
+
return (await res.json()) as T
|
|
282
|
+
}
|
|
283
|
+
return undefined
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface ArmContainerGroup {
|
|
287
|
+
id?: string
|
|
288
|
+
name?: string
|
|
289
|
+
properties?: {
|
|
290
|
+
provisioningState?: string
|
|
291
|
+
ipAddress?: {
|
|
292
|
+
ip?: string
|
|
293
|
+
fqdn?: string
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function spawnAciSandbox(
|
|
299
|
+
config: ACIStandbyPoolBackendInternalConfig,
|
|
300
|
+
_options: SandboxBackendOptions,
|
|
301
|
+
): Promise<Sandbox> {
|
|
302
|
+
const id = generateSandboxId()
|
|
303
|
+
const prefix = config.containerNamePrefix ?? DEFAULT_CONTAINER_NAME_PREFIX
|
|
304
|
+
const cgName = `${prefix}-${id
|
|
305
|
+
.replace(/[^a-z0-9-]/gi, '')
|
|
306
|
+
.toLowerCase()
|
|
307
|
+
.slice(0, 50)}`
|
|
308
|
+
const apiVersion = config.armApiVersion ?? DEFAULT_ARM_API_VERSION
|
|
309
|
+
const workerPort = config.workerPort ?? DEFAULT_WORKER_PORT
|
|
310
|
+
const armUrl = `${ARM_BASE}/subscriptions/${config.subscriptionId}/resourceGroups/${config.resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/${cgName}?api-version=${apiVersion}`
|
|
311
|
+
|
|
312
|
+
const { volumes, volumeMounts } = buildAzureFileVolumesFromLayout(config.layout)
|
|
313
|
+
|
|
314
|
+
// Standby Pool's claim API rejects every property override that
|
|
315
|
+
// is NOT a `configMap`. The empty / no-mount cases (every source
|
|
316
|
+
// is `inImage`) MUST therefore omit `containers`, `volumes`, and
|
|
317
|
+
// `volumeMounts` entirely from the PUT body — even an empty
|
|
318
|
+
// array trips the BadRequest "ContainerGroup properties other
|
|
319
|
+
// than config map are not allowed" check. The fields land only
|
|
320
|
+
// when something real needs to ride through (e.g. cold-spawn ACI
|
|
321
|
+
// with per-task azureFileShare mounts, future flow).
|
|
322
|
+
const properties: Record<string, unknown> = {
|
|
323
|
+
containerGroupProfile: {
|
|
324
|
+
id: config.containerGroupProfileResourceId,
|
|
325
|
+
revision: config.containerGroupProfileRevision ?? 1,
|
|
326
|
+
},
|
|
327
|
+
standbyPoolProfile: {
|
|
328
|
+
id: config.standbyPoolResourceId,
|
|
329
|
+
},
|
|
330
|
+
...(config.subnetId ? { subnetIds: [{ id: config.subnetId }] } : {}),
|
|
331
|
+
}
|
|
332
|
+
if (volumes.length > 0) {
|
|
333
|
+
properties.volumes = volumes
|
|
334
|
+
properties.containers = [
|
|
335
|
+
{
|
|
336
|
+
name: `${prefix}-worker`,
|
|
337
|
+
properties: { volumeMounts },
|
|
338
|
+
},
|
|
339
|
+
]
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const body: Record<string, unknown> = {
|
|
343
|
+
location: config.location,
|
|
344
|
+
properties,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
let claimed: ArmContainerGroup | undefined
|
|
348
|
+
try {
|
|
349
|
+
claimed = await armCall<ArmContainerGroup>(armUrl, 'PUT', config.getArmToken, body)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`aci-standby-pool: failed to claim from pool — ${err instanceof Error ? err.message : String(err)}`,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const initialIp = claimed?.properties?.ipAddress?.ip
|
|
357
|
+
let ip = initialIp
|
|
358
|
+
try {
|
|
359
|
+
if (!ip) {
|
|
360
|
+
ip = await pollForRunningIp(
|
|
361
|
+
armUrl,
|
|
362
|
+
config.getArmToken,
|
|
363
|
+
config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
|
|
364
|
+
config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const baseUrl = `http://${ip}:${workerPort}`
|
|
369
|
+
await waitForWorkerReady(
|
|
370
|
+
baseUrl,
|
|
371
|
+
config.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
372
|
+
config.readyPollIntervalMs ?? DEFAULT_READY_POLL_MS,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
let status: SandboxStatus = 'ready'
|
|
376
|
+
const rootDir = config.layout.outputs.containerPath
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
id,
|
|
380
|
+
get status(): SandboxStatus {
|
|
381
|
+
return status
|
|
382
|
+
},
|
|
383
|
+
rootDir,
|
|
384
|
+
environment: detectEnvironment(),
|
|
385
|
+
|
|
386
|
+
async exec(
|
|
387
|
+
command: string,
|
|
388
|
+
argv?: string[],
|
|
389
|
+
opts?: SandboxExecOptions,
|
|
390
|
+
): Promise<SandboxExecResult> {
|
|
391
|
+
status = 'busy'
|
|
392
|
+
try {
|
|
393
|
+
return await execViaWorker(baseUrl, command, argv, opts)
|
|
394
|
+
} finally {
|
|
395
|
+
status = 'ready'
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
async writeFile(path: string, content: string | Buffer): Promise<void> {
|
|
400
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8')
|
|
401
|
+
const res = await fetch(`${baseUrl}/write-file`, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: { 'content-type': 'application/json' },
|
|
404
|
+
body: JSON.stringify({
|
|
405
|
+
path,
|
|
406
|
+
content: buf.toString('base64'),
|
|
407
|
+
encoding: 'base64',
|
|
408
|
+
}),
|
|
409
|
+
})
|
|
410
|
+
if (!res.ok) {
|
|
411
|
+
throw new Error(`write-file failed: HTTP ${res.status} ${await res.text()}`)
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async readFile(path: string): Promise<Buffer> {
|
|
416
|
+
const res = await fetch(`${baseUrl}/read-file`, {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'content-type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({ path, encoding: 'base64' }),
|
|
420
|
+
})
|
|
421
|
+
if (!res.ok) {
|
|
422
|
+
throw new Error(`read-file failed: HTTP ${res.status} ${await res.text()}`)
|
|
423
|
+
}
|
|
424
|
+
const json = (await res.json()) as { ok: boolean; content?: string; error?: string }
|
|
425
|
+
if (!json.ok || typeof json.content !== 'string') {
|
|
426
|
+
throw new Error(json.error ?? 'read-file: no content')
|
|
427
|
+
}
|
|
428
|
+
return Buffer.from(json.content, 'base64')
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]> {
|
|
432
|
+
return await listFilesViaWorker(baseUrl, rootPath)
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
async destroy(): Promise<void> {
|
|
436
|
+
status = 'destroyed'
|
|
437
|
+
// ARM DELETE — let failures propagate. The Vandal-side
|
|
438
|
+
// lifecycle wraps this in its own try/catch with logging,
|
|
439
|
+
// so a silently swallowed error here means orphan ACI
|
|
440
|
+
// container groups pile up under the resource group with
|
|
441
|
+
// no observability handle. The Standby Pool's refill keeps
|
|
442
|
+
// the WARM side topped up; that has nothing to do with
|
|
443
|
+
// cleaning up a CLAIMED instance, which is exclusively the
|
|
444
|
+
// claimer's responsibility.
|
|
445
|
+
await armCall(armUrl, 'DELETE', config.getArmToken)
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
try {
|
|
450
|
+
await armCall(armUrl, 'DELETE', config.getArmToken)
|
|
451
|
+
} catch {
|
|
452
|
+
// Preserve the readiness failure as the primary error; the
|
|
453
|
+
// caller cannot use a Sandbox handle yet, so best-effort ARM
|
|
454
|
+
// cleanup is the only reliable orphan-prevention hook here.
|
|
455
|
+
}
|
|
456
|
+
throw err
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function pollForRunningIp(
|
|
461
|
+
armUrl: string,
|
|
462
|
+
getToken: ArmTokenProvider,
|
|
463
|
+
pollIntervalMs: number,
|
|
464
|
+
timeoutMs: number,
|
|
465
|
+
): Promise<string> {
|
|
466
|
+
const deadline = Date.now() + timeoutMs
|
|
467
|
+
while (Date.now() < deadline) {
|
|
468
|
+
const cg = await armCall<ArmContainerGroup>(armUrl, 'GET', getToken)
|
|
469
|
+
const state = cg?.properties?.provisioningState
|
|
470
|
+
const ip = cg?.properties?.ipAddress?.ip
|
|
471
|
+
if (state === 'Succeeded' && ip) return ip
|
|
472
|
+
if (state === 'Failed') {
|
|
473
|
+
throw new Error('aci-standby-pool: container group provisioning failed')
|
|
474
|
+
}
|
|
475
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs))
|
|
476
|
+
}
|
|
477
|
+
throw new Error(`aci-standby-pool: timed out waiting for container group IP (${timeoutMs}ms)`)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function waitForWorkerReady(
|
|
481
|
+
baseUrl: string,
|
|
482
|
+
timeoutMs: number,
|
|
483
|
+
pollIntervalMs: number,
|
|
484
|
+
): Promise<void> {
|
|
485
|
+
const deadline = Date.now() + timeoutMs
|
|
486
|
+
while (Date.now() < deadline) {
|
|
487
|
+
try {
|
|
488
|
+
const res = await fetch(`${baseUrl}/healthz`, {
|
|
489
|
+
signal: AbortSignal.timeout(2000),
|
|
490
|
+
})
|
|
491
|
+
if (res.ok) return
|
|
492
|
+
} catch {
|
|
493
|
+
// Network not ready yet, try again.
|
|
494
|
+
}
|
|
495
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs))
|
|
496
|
+
}
|
|
497
|
+
throw new Error(`aci-standby-pool: worker /healthz never responded (${timeoutMs}ms)`)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function execViaWorker(
|
|
501
|
+
baseUrl: string,
|
|
502
|
+
command: string,
|
|
503
|
+
argv: string[] | undefined,
|
|
504
|
+
opts: SandboxExecOptions | undefined,
|
|
505
|
+
): Promise<SandboxExecResult> {
|
|
506
|
+
const start = Date.now()
|
|
507
|
+
const res = await fetch(`${baseUrl}/execute`, {
|
|
508
|
+
method: 'POST',
|
|
509
|
+
headers: { 'content-type': 'application/json' },
|
|
510
|
+
body: JSON.stringify({
|
|
511
|
+
command,
|
|
512
|
+
args: argv ?? [],
|
|
513
|
+
cwd: opts?.cwd,
|
|
514
|
+
env: opts?.env,
|
|
515
|
+
timeoutMs: opts?.timeout,
|
|
516
|
+
}),
|
|
517
|
+
})
|
|
518
|
+
if (!res.ok || !res.body) {
|
|
519
|
+
throw new Error(`execute failed: HTTP ${res.status} ${await res.text()}`)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let stdout = ''
|
|
523
|
+
let stderr = ''
|
|
524
|
+
let exitCode = -1
|
|
525
|
+
let timedOut = false
|
|
526
|
+
|
|
527
|
+
const decoder = new TextDecoder()
|
|
528
|
+
const reader = res.body.getReader()
|
|
529
|
+
let buffered = ''
|
|
530
|
+
for (;;) {
|
|
531
|
+
const { value, done } = await reader.read()
|
|
532
|
+
if (done) break
|
|
533
|
+
buffered += decoder.decode(value, { stream: true })
|
|
534
|
+
let newlineIdx = buffered.indexOf('\n')
|
|
535
|
+
while (newlineIdx !== -1) {
|
|
536
|
+
const line = buffered.slice(0, newlineIdx).trim()
|
|
537
|
+
buffered = buffered.slice(newlineIdx + 1)
|
|
538
|
+
if (line) {
|
|
539
|
+
try {
|
|
540
|
+
const event = JSON.parse(line) as
|
|
541
|
+
| { type: 'stdout_delta'; data: string }
|
|
542
|
+
| { type: 'stderr_delta'; data: string }
|
|
543
|
+
| { type: 'result'; exitCode: number; timedOut: boolean }
|
|
544
|
+
| { type: 'error'; error: string }
|
|
545
|
+
if (event.type === 'stdout_delta') stdout += event.data
|
|
546
|
+
else if (event.type === 'stderr_delta') stderr += event.data
|
|
547
|
+
else if (event.type === 'result') {
|
|
548
|
+
exitCode = event.exitCode
|
|
549
|
+
timedOut = event.timedOut
|
|
550
|
+
} else if (event.type === 'error') {
|
|
551
|
+
throw new Error(event.error)
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (!(err instanceof SyntaxError)) throw err
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
newlineIdx = buffered.indexOf('\n')
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
stdout,
|
|
563
|
+
stderr,
|
|
564
|
+
exitCode,
|
|
565
|
+
timedOut,
|
|
566
|
+
durationMs: Date.now() - start,
|
|
567
|
+
} as SandboxExecResult
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Recursively list regular files under `rootPath` by shelling out to
|
|
572
|
+
* the worker's `find` (GNU find on the Debian-based reference image).
|
|
573
|
+
* `-printf` emits one `<path>\t<size>` line per file; any other
|
|
574
|
+
* non-zero exit (notably `find: '<root>': No such file or directory`)
|
|
575
|
+
* is mapped to "empty listing" because the agent legitimately may not
|
|
576
|
+
* have produced anything in `rootPath` yet.
|
|
577
|
+
*/
|
|
578
|
+
async function listFilesViaWorker(
|
|
579
|
+
baseUrl: string,
|
|
580
|
+
rootPath: string,
|
|
581
|
+
): Promise<readonly SandboxFileEntry[]> {
|
|
582
|
+
const result = await execViaWorker(
|
|
583
|
+
baseUrl,
|
|
584
|
+
'find',
|
|
585
|
+
[rootPath, '-type', 'f', '-printf', '%p\t%s\n'],
|
|
586
|
+
undefined,
|
|
587
|
+
)
|
|
588
|
+
if (result.exitCode !== 0) {
|
|
589
|
+
return []
|
|
590
|
+
}
|
|
591
|
+
const entries: SandboxFileEntry[] = []
|
|
592
|
+
for (const rawLine of result.stdout.split('\n')) {
|
|
593
|
+
if (!rawLine) continue
|
|
594
|
+
const tab = rawLine.indexOf('\t')
|
|
595
|
+
if (tab < 0) continue
|
|
596
|
+
const path = rawLine.slice(0, tab)
|
|
597
|
+
const size = Number.parseInt(rawLine.slice(tab + 1), 10)
|
|
598
|
+
if (!path || !Number.isFinite(size)) continue
|
|
599
|
+
entries.push({ path, size })
|
|
600
|
+
}
|
|
601
|
+
return entries
|
|
602
|
+
}
|