@claxedo/sandbox-manager 0.10.1 → 0.10.3
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/README.md +100 -30
- package/dist/checkpoint-manager.mjs +19 -0
- package/dist/contract.d.ts +40 -17
- package/dist/driver-billing.d.ts +23 -0
- package/dist/driver-catalog.mjs +57 -3
- package/dist/driver-ensure-input.d.ts +22 -0
- package/dist/drivers/agent37-client.d.ts +87 -0
- package/dist/drivers/agent37.d.ts +25 -0
- package/dist/drivers/agent37.mjs +888 -0
- package/dist/drivers/boat-client.d.ts +4 -4
- package/dist/drivers/boat-machine.d.ts +80 -0
- package/dist/drivers/boat-preparation.test-support.d.ts +2 -0
- package/dist/drivers/boat-template.mjs +196 -92
- package/dist/drivers/boat.d.ts +0 -17
- package/dist/drivers/boat.mjs +200 -108
- package/dist/drivers/cloudflare.d.ts +5 -9
- package/dist/drivers/cloudflare.mjs +71 -7
- package/dist/drivers/docker.mjs +57 -2
- package/dist/drivers/local-brokering.d.ts +12 -0
- package/dist/drivers/local-brokering.mjs +99 -24
- package/dist/drivers/modal.mjs +57 -2
- package/dist/drivers/transient-error.d.ts +0 -1
- package/dist/drivers/vercel.mjs +61 -3
- package/dist/egress-policy.d.ts +33 -32
- package/dist/egress-warnings.d.ts +4 -6
- package/dist/hosted-network-policy.d.ts +10 -64
- package/dist/hosted-network-policy.mjs +1 -1
- package/dist/image-name.d.ts +0 -1
- package/dist/image-name.mjs +5 -0
- package/dist/image.d.ts +1 -0
- package/dist/image.mjs +6 -0
- package/dist/index.mjs +180 -112
- package/dist/json.d.ts +1 -10
- package/dist/lease-failure.d.ts +20 -1
- package/dist/lease-types.d.ts +1 -10
- package/dist/runtime-env.d.ts +6 -0
- package/dist/runtime-env.mjs +5 -0
- package/dist/runtime-version.d.ts +25 -7
- package/dist/runtime-version.mjs +13 -2
- package/dist/start-admission.d.ts +32 -0
- package/dist/start-telemetry.d.ts +5 -3
- package/package.json +12 -4
|
@@ -18,7 +18,7 @@ export type BoatFetch = (input: string, init?: RequestInit) => Promise<Response>
|
|
|
18
18
|
/**
|
|
19
19
|
* What Boat's 429 `rate_limited` says about the account's start limit: the
|
|
20
20
|
* window that is used up, its limit, and the starts used of each window. It
|
|
21
|
-
* names no time the window reopens
|
|
21
|
+
* names no time the window reopens, so none is kept.
|
|
22
22
|
*/
|
|
23
23
|
export type BoatStartLimit = {
|
|
24
24
|
window: SandboxStartWindow;
|
|
@@ -33,10 +33,10 @@ export declare class BoatApiError extends Error {
|
|
|
33
33
|
readonly status?: number | undefined;
|
|
34
34
|
/** Boat answers 401 or 403 to the API key itself, never to a sandbox it was asked about. */
|
|
35
35
|
readonly providerKeyRefused: boolean;
|
|
36
|
-
/** Create, fork and resume each count as one start (Boat's own refusal
|
|
36
|
+
/** Create, fork and resume each count as one start (Boat's own refusal). */
|
|
37
37
|
readonly providerRateLimited?: SandboxStartLimit;
|
|
38
38
|
readonly startLimit?: BoatStartLimit;
|
|
39
|
-
/** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed
|
|
39
|
+
/** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed`. */
|
|
40
40
|
readonly sandboxState?: BoatSandboxState;
|
|
41
41
|
constructor(message: string, code: string, status?: number | undefined, refusal?: {
|
|
42
42
|
startLimit?: BoatStartLimit;
|
|
@@ -75,7 +75,7 @@ export declare function createBoatClient(options: BoatClientOptions): {
|
|
|
75
75
|
}): Promise<void>;
|
|
76
76
|
/**
|
|
77
77
|
* `ttlSeconds` sets the auto-stop deadline to now plus that many seconds; `null` removes it.
|
|
78
|
-
* Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated
|
|
78
|
+
* Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated`.
|
|
79
79
|
*/
|
|
80
80
|
update(id: string, input: {
|
|
81
81
|
ttlSeconds: number | null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export declare const IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
|
|
2
|
+
export declare const CONTAINER_START_TIMEOUT_SECONDS = 120;
|
|
3
|
+
export declare const DEFAULT_CONTAINER_NAME = "claxedo-runtime";
|
|
4
|
+
export declare const RUNTIME_ENV_PATH = ".claxedo-runtime-env";
|
|
5
|
+
export declare const REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
|
|
6
|
+
/**
|
|
7
|
+
* Turns a booted machine into one any workspace can start from: it keeps what
|
|
8
|
+
* every machine of the image shares (the image's trees, stub archive and env,
|
|
9
|
+
* and Boat's ~/.ascii/playbook.json) and removes what this machine's boot made
|
|
10
|
+
* (the runtime container and Boat's record and image archive of it, the
|
|
11
|
+
* workspace, the runtime's state, the boot's env). Nothing the runtime wrote
|
|
12
|
+
* into the image's trees was found there (the trees held only the image's own
|
|
13
|
+
* entries after two boots); what else differs on the machine
|
|
14
|
+
* is Boat's own, and a machine created from the template gets its own
|
|
15
|
+
* machine-id, SSH host keys, hostname and no hosted port.
|
|
16
|
+
*/
|
|
17
|
+
export declare function boatTemplateResetCommand(): string;
|
|
18
|
+
export type StagedFiles = {
|
|
19
|
+
env: string;
|
|
20
|
+
password?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function holdingBootLock(commandTimeoutSeconds: number, script: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* The image a boot names and the one it may keep serving instead. A resume of
|
|
25
|
+
* a machine whose trees hold only an older image keeps that image when its
|
|
26
|
+
* runtime is no older than the oldest the control plane serves, so a wake
|
|
27
|
+
* after a deploy does not wait 60–100 s on the new image's pull and seed
|
|
28
|
+
* (staging); the workspace then reads as outdated, and the
|
|
29
|
+
* person's Restart to update asks for the new image with `keepPreviousImage`
|
|
30
|
+
* off. A cold boot has no previous image to keep.
|
|
31
|
+
*/
|
|
32
|
+
export type MachineBoot = {
|
|
33
|
+
image: string;
|
|
34
|
+
keepPreviousImage: boolean;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
directory: string;
|
|
37
|
+
port: number;
|
|
38
|
+
runtimeCommand: string;
|
|
39
|
+
containerName: string;
|
|
40
|
+
registry?: {
|
|
41
|
+
server: string;
|
|
42
|
+
username: string;
|
|
43
|
+
};
|
|
44
|
+
staged: StagedFiles;
|
|
45
|
+
staleStagedMinutes: number;
|
|
46
|
+
};
|
|
47
|
+
/** Where a boot's preparation leaves the image it booted for its container start to read. */
|
|
48
|
+
export declare function bootedImageFile(staged: StagedFiles): string;
|
|
49
|
+
/** The image a preparation booted: the last line it prints, since `docker login` prints before it. */
|
|
50
|
+
export declare function bootedImage(prepareStdout: string): string | undefined;
|
|
51
|
+
/** The last line a preparation prints when its pull gave up on a registry link that kept breaking. */
|
|
52
|
+
export declare const IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
|
|
53
|
+
/** Leads the line a boot that could keep the machine's last image prints when it takes the named one instead, with what it found. */
|
|
54
|
+
export declare const KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
|
|
55
|
+
/**
|
|
56
|
+
* The command carries only paths: env arrives through the staged file
|
|
57
|
+
* (mounted read-only, sourced by the entrypoint at every container start so
|
|
58
|
+
* multi-line values like PEMs survive — `docker run --env-file` cannot
|
|
59
|
+
* express them), and the registry password is piped to `docker login` from a
|
|
60
|
+
* file that is removed whatever the outcome; a failed login aborts the chain.
|
|
61
|
+
*
|
|
62
|
+
* Two commands. `prepare` changes nothing a running container serves from:
|
|
63
|
+
* it waits for the daemon (a resume reboots the VM, and Docker comes up as a
|
|
64
|
+
* systemd service after Boat reports the sandbox ready), logs in, and settles
|
|
65
|
+
* which image boots: the one named when its trees, stub and env are there;
|
|
66
|
+
* the one the machine last booted when the boot may keep it and its trees
|
|
67
|
+
* hold a runtime still served; otherwise the one named, pulled and seeded
|
|
68
|
+
* where anything is missing. It loads that image's stub when Docker does not
|
|
69
|
+
* hold it, removes every other image's trees, writes the image for `start`
|
|
70
|
+
* and prints it. A failure there leaves a runtime of the previous image
|
|
71
|
+
* serving. `start` then replaces another image's container with one of the
|
|
72
|
+
* booted image, or starts that image's container that is already there: the
|
|
73
|
+
* one the stop kept, which Boat's restore creates again stopped, or one Boat
|
|
74
|
+
* recreated after a stop that was not this driver's; then it removes the
|
|
75
|
+
* image if a seed left it. A failure there leaves nothing serving.
|
|
76
|
+
*/
|
|
77
|
+
export declare function machineBootScripts(boot: MachineBoot): {
|
|
78
|
+
prepare: string;
|
|
79
|
+
start: string;
|
|
80
|
+
};
|
|
@@ -63,6 +63,7 @@ var sandboxDriverCredentialFields = {
|
|
|
63
63
|
{ key: "worker_url", label: "Worker URL" }
|
|
64
64
|
],
|
|
65
65
|
boat: [{ key: "api_key", label: "API Key", secret: true }],
|
|
66
|
+
agent37: [{ key: "api_key", label: "API Key", secret: true }],
|
|
66
67
|
docker: [{ key: "image", label: "Image" }]
|
|
67
68
|
};
|
|
68
69
|
var sandboxDriverLabels = {
|
|
@@ -70,6 +71,7 @@ var sandboxDriverLabels = {
|
|
|
70
71
|
vercel: "Vercel",
|
|
71
72
|
cloudflare: "Cloudflare",
|
|
72
73
|
boat: "Boat",
|
|
74
|
+
agent37: "Agent37",
|
|
73
75
|
docker: "Docker"
|
|
74
76
|
};
|
|
75
77
|
|
|
@@ -81,6 +83,13 @@ var SandboxRuntimeBootError = class extends Error {
|
|
|
81
83
|
this.name = "SandboxRuntimeBootError";
|
|
82
84
|
}
|
|
83
85
|
};
|
|
86
|
+
var SandboxImageDownloadError = class extends Error {
|
|
87
|
+
imageDownloadFailed = true;
|
|
88
|
+
constructor(reason) {
|
|
89
|
+
super(reason);
|
|
90
|
+
this.name = "SandboxImageDownloadError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
84
93
|
var SandboxHostGoneError = class extends Error {
|
|
85
94
|
hostGone = true;
|
|
86
95
|
constructor(reason) {
|
|
@@ -136,6 +145,10 @@ var WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS = /* @__PURE__ */ new Set([
|
|
|
136
145
|
function workspaceRuntimeBootSecrets(env) {
|
|
137
146
|
return Object.entries(env).flatMap(([key, value]) => WORKSPACE_RUNTIME_PUBLIC_ENV_KEYS.has(key) ? [] : [value]);
|
|
138
147
|
}
|
|
148
|
+
var REDACTED_VALUE_MIN_LENGTH = 8;
|
|
149
|
+
function redactWorkspaceRuntimeBootSecrets(text, secrets) {
|
|
150
|
+
return secrets.filter((secret) => secret.length >= REDACTED_VALUE_MIN_LENGTH).reduce((output, secret) => output.split(secret).join("[redacted]"), text);
|
|
151
|
+
}
|
|
139
152
|
function workspaceRuntimeBootEnv(input) {
|
|
140
153
|
assertWorkspaceRuntimeIdentityEnv(input.env);
|
|
141
154
|
const env = {
|
|
@@ -181,6 +194,17 @@ function envFile(input) {
|
|
|
181
194
|
}).join("\n") + "\n";
|
|
182
195
|
}
|
|
183
196
|
|
|
197
|
+
// src/runtime-version.ts
|
|
198
|
+
var SERVED_WORKSPACE_RUNTIME_FLOOR = {
|
|
199
|
+
version: "0.10.1",
|
|
200
|
+
image: "ghcr.io/kyashrathore/claxedo-sandbox:workspace-runtime-0-10-1-40706b7356-agent-plugins-v8"
|
|
201
|
+
};
|
|
202
|
+
var MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION = SERVED_WORKSPACE_RUNTIME_FLOOR.version;
|
|
203
|
+
var SANDBOX_IMAGE_RUNTIME_VERSION_FILE = "opt/workspace-runtime/workspace-runtime-version";
|
|
204
|
+
function servedRuntimeVersionTest(file) {
|
|
205
|
+
return `version=$(cat ${file} 2>/dev/null) && [ -n "$version" ] && [ "$(printf '%s\\n%s\\n' ${MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION} "$version" | sort -V | head -n 1)" = ${MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION} ]`;
|
|
206
|
+
}
|
|
207
|
+
|
|
184
208
|
// src/image-name.ts
|
|
185
209
|
function assertSandboxImageReference(image) {
|
|
186
210
|
if (!image || image !== image.trim() || image.startsWith("-") || /\s/.test(image)) {
|
|
@@ -200,6 +224,27 @@ var boatBilling = {
|
|
|
200
224
|
large: { size: "large", vcpu: 8, memoryGb: 16, usdPerHour: 0.072 }
|
|
201
225
|
}
|
|
202
226
|
};
|
|
227
|
+
var AGENT37_USD_PER_MONTH = { vcpu: 0.8, memoryGb: 0.7, diskGb: 0.09 };
|
|
228
|
+
var AGENT37_MONTH_HOURS = 730;
|
|
229
|
+
var agent37MachineShapes = {
|
|
230
|
+
small: { cpu: 2, memory: 4, disk: 10 },
|
|
231
|
+
default: { cpu: 4, memory: 8, disk: 20 },
|
|
232
|
+
large: { cpu: 4, memory: 16, disk: 40 }
|
|
233
|
+
};
|
|
234
|
+
function agent37Rate(shape) {
|
|
235
|
+
const usdPerMonth = shape.cpu * AGENT37_USD_PER_MONTH.vcpu + shape.memory * AGENT37_USD_PER_MONTH.memoryGb + shape.disk * AGENT37_USD_PER_MONTH.diskGb;
|
|
236
|
+
return { size: `${shape.cpu} vCPU / ${shape.memory} GB`, vcpu: shape.cpu, memoryGb: shape.memory, usdPerHour: usdPerMonth / AGENT37_MONTH_HOURS };
|
|
237
|
+
}
|
|
238
|
+
var agent37Billing = {
|
|
239
|
+
source: { url: "https://www.agent37.com/docs/agents-api/billing", readOn: "2026-11-11" },
|
|
240
|
+
meter: "Agent37 meters an instance per minute at a monthly rate over 730 hours: $0.80 per vCPU, $0.70 per GB of memory and $0.09 per GB of disk while it runs, and the disk alone while it is stopped.",
|
|
241
|
+
rates: {
|
|
242
|
+
small: agent37Rate(agent37MachineShapes.small),
|
|
243
|
+
default: agent37Rate(agent37MachineShapes.default),
|
|
244
|
+
large: agent37Rate(agent37MachineShapes.large)
|
|
245
|
+
},
|
|
246
|
+
excludes: `the disk of a stopped instance, billed at $${AGENT37_USD_PER_MONTH.diskGb} per GB a month until the instance is deleted`
|
|
247
|
+
};
|
|
203
248
|
var CLOUDFLARE_USD_PER_GIB_SECOND_MEMORY = 25e-7;
|
|
204
249
|
var CLOUDFLARE_USD_PER_GB_SECOND_DISK = 7e-8;
|
|
205
250
|
var CLOUDFLARE_USD_PER_VCPU_SECOND_ACTIVE = 2e-5;
|
|
@@ -285,8 +330,8 @@ var sandboxDriverCatalog = {
|
|
|
285
330
|
hostWatchesIdle: true,
|
|
286
331
|
targetAccess: "relay",
|
|
287
332
|
secretBrokering: "native",
|
|
288
|
-
//
|
|
289
|
-
egressControl: "
|
|
333
|
+
// The sandbox Worker starts the container without internet and routes each allowed host through itself; a Worker that cannot confirm that refuses the start.
|
|
334
|
+
egressControl: "hosts",
|
|
290
335
|
// A container snapshot holds the whole root filesystem; the platform keeps it 30 days past its last restore and offers no deletion.
|
|
291
336
|
persistence: {
|
|
292
337
|
resume: "replacement-restore",
|
|
@@ -324,6 +369,33 @@ var sandboxDriverCatalog = {
|
|
|
324
369
|
machineClasses: ["small", "default", "large"]
|
|
325
370
|
}
|
|
326
371
|
},
|
|
372
|
+
agent37: {
|
|
373
|
+
id: "agent37",
|
|
374
|
+
label: sandboxDriverLabels.agent37,
|
|
375
|
+
credentialFields: sandboxDriverCredentialFields.agent37,
|
|
376
|
+
runtimeNetwork: { controlPlane: "direct", relay: "configured" },
|
|
377
|
+
runtimeEnv: "callback",
|
|
378
|
+
preparedImage: true,
|
|
379
|
+
billing: agent37Billing,
|
|
380
|
+
metadata: {
|
|
381
|
+
driverRunsIn: ["worker", "node"],
|
|
382
|
+
hostStopBehavior: "suspends-host",
|
|
383
|
+
hostResumeBehavior: "same-host",
|
|
384
|
+
targetAccess: "relay",
|
|
385
|
+
secretBrokering: "none",
|
|
386
|
+
// An instance's network policy lists domains, reached over HTTPS on 443 alone, and IPv4 ranges; private ranges stay blocked whatever it lists.
|
|
387
|
+
egressControl: "hosts-and-cidrs",
|
|
388
|
+
persistence: {
|
|
389
|
+
resume: "same-sandbox",
|
|
390
|
+
capture: "none",
|
|
391
|
+
clone: false,
|
|
392
|
+
captureSource: "not-applicable",
|
|
393
|
+
retention: "provider-managed",
|
|
394
|
+
restoreMount: "same-resource"
|
|
395
|
+
},
|
|
396
|
+
machineClasses: ["small", "default", "large"]
|
|
397
|
+
}
|
|
398
|
+
},
|
|
327
399
|
docker: {
|
|
328
400
|
id: "docker",
|
|
329
401
|
label: sandboxDriverLabels.docker,
|
|
@@ -396,10 +468,10 @@ var BoatApiError = class extends Error {
|
|
|
396
468
|
}
|
|
397
469
|
/** Boat answers 401 or 403 to the API key itself, never to a sandbox it was asked about. */
|
|
398
470
|
providerKeyRefused;
|
|
399
|
-
/** Create, fork and resume each count as one start (Boat's own refusal
|
|
471
|
+
/** Create, fork and resume each count as one start (Boat's own refusal). */
|
|
400
472
|
providerRateLimited;
|
|
401
473
|
startLimit;
|
|
402
|
-
/** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed
|
|
474
|
+
/** The state Boat's refusal gives the sandbox it refused, e.g. `archived` on a 400 `stop_failed`. */
|
|
403
475
|
sandboxState;
|
|
404
476
|
};
|
|
405
477
|
function startCount(value) {
|
|
@@ -527,7 +599,7 @@ function createBoatClient(options) {
|
|
|
527
599
|
},
|
|
528
600
|
/**
|
|
529
601
|
* `ttlSeconds` sets the auto-stop deadline to now plus that many seconds; `null` removes it.
|
|
530
|
-
* Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated
|
|
602
|
+
* Boat's reference example answers `sandbox.info`; the API answers `sandbox.updated`.
|
|
531
603
|
*/
|
|
532
604
|
async update(id, input) {
|
|
533
605
|
const value = sandbox(await api(path(id), ["sandbox.updated", "sandbox.info"], { method: "PATCH", body: input }));
|
|
@@ -592,21 +664,10 @@ function isTransientDriverError(err, markers) {
|
|
|
592
664
|
}
|
|
593
665
|
var TRANSIENT_DRIVER_RETRY_MS = 2e3;
|
|
594
666
|
|
|
595
|
-
// src/drivers/boat.ts
|
|
596
|
-
var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
|
|
597
|
-
var DEFAULT_WORKSPACE_DIR = "/workspace";
|
|
598
|
-
var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
|
|
599
|
-
var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
|
|
600
|
-
var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
|
|
601
|
-
var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
|
|
602
|
-
var DEFAULT_HEALTH_INTERVAL_MS = 100;
|
|
603
|
-
var HEALTH_PROBE_WINDOW_SECONDS = 50;
|
|
604
|
-
var HEALTH_PROBE_MARGIN_SECONDS = 10;
|
|
605
|
-
var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
|
|
667
|
+
// src/drivers/boat-machine.ts
|
|
606
668
|
var IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
|
|
607
669
|
var CONTAINER_START_TIMEOUT_SECONDS = 120;
|
|
608
|
-
var
|
|
609
|
-
var BOOT_TTL_SECONDS = 30 * 60;
|
|
670
|
+
var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
|
|
610
671
|
var DOCKER_DAEMON_WAIT_SECONDS = 120;
|
|
611
672
|
var RUNTIME_ENV_PATH = ".claxedo-runtime-env";
|
|
612
673
|
var REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
|
|
@@ -632,17 +693,6 @@ var CONTAINER_CREATE_ATTEMPTS = 5;
|
|
|
632
693
|
var CONTAINER_SECURITY_OPTIONS = "--security-opt seccomp=unconfined --security-opt apparmor=unconfined";
|
|
633
694
|
var VM_DOCKER_INIT = "/usr/libexec/docker/docker-init";
|
|
634
695
|
var CONTAINER_INIT_PATH = "/claxedo-init";
|
|
635
|
-
var CONTAINER_STOP_GRACE_SECONDS = 30;
|
|
636
|
-
var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
|
|
637
|
-
var STDERR_TAIL_CHARS = 600;
|
|
638
|
-
var REDACTED_VALUE_MIN_LENGTH = 8;
|
|
639
|
-
var READY_STATES = /* @__PURE__ */ new Set(["ready", "idle", "running"]);
|
|
640
|
-
var PENDING_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning"]);
|
|
641
|
-
var TRANSIENT_MARKERS = ["transport_failed", "idempotency_in_progress"];
|
|
642
|
-
var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"]);
|
|
643
|
-
var RESUME_UNDER_WAY = "sandbox_resuming";
|
|
644
|
-
var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
|
|
645
|
-
var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
|
|
646
696
|
function boatTemplateResetCommand() {
|
|
647
697
|
const bootFiles = [
|
|
648
698
|
`${PERSISTENT_ROOT}/workspace`,
|
|
@@ -653,6 +703,91 @@ function boatTemplateResetCommand() {
|
|
|
653
703
|
];
|
|
654
704
|
return `{ docker rm -f ${DEFAULT_CONTAINER_NAME} >/dev/null 2>&1 || true; } && sudo rm -rf ${bootFiles.join(" ")} && [ -z "$(docker ps -aq)" ] && echo '[]' | sudo tee ${BOAT_CONTAINER_RECORD} >/dev/null && sudo find ${BOAT_SAVED_IMAGES} -mindepth 1 -delete && ls -d ${PERSISTENT_ROOT}/image-*/${IMAGE_TREES_COMPLETE_MARKER}`;
|
|
655
705
|
}
|
|
706
|
+
function holdingBootLock(commandTimeoutSeconds, script) {
|
|
707
|
+
const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
|
|
708
|
+
return `exec 9>>${BOOT_LOCK_PATH} && { flock -w ${wait} 9 || { echo "another boot of this machine held ${BOOT_LOCK_PATH} for ${wait} s" >&2; exit 1; }; } && ${script}`;
|
|
709
|
+
}
|
|
710
|
+
function containerHostname(workspaceId) {
|
|
711
|
+
return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
|
|
712
|
+
}
|
|
713
|
+
function bootedImageFile(staged) {
|
|
714
|
+
return `${staged.env}.image`;
|
|
715
|
+
}
|
|
716
|
+
function bootedImage(prepareStdout) {
|
|
717
|
+
return prepareStdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop();
|
|
718
|
+
}
|
|
719
|
+
var stubLabel = `docker image inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${STUB_IMAGE} 2>/dev/null`;
|
|
720
|
+
var treesFunctions = [
|
|
721
|
+
`trees_of() { printf '%s/${PERSISTENT_ROOT}/image-%s' "$(pwd)" "$(printf %s "$1" | sed 's/[^A-Za-z0-9._-]/-/g')"; }`,
|
|
722
|
+
`seeded() { [ -f "$1/${IMAGE_TREES_COMPLETE_MARKER}" ] && [ -f "$1/${STUB_IMAGE_ARCHIVE}" ] && [ -f "$1/${IMAGE_ENV_FILE}" ]; }`,
|
|
723
|
+
`served() { ${servedRuntimeVersionTest(`"$1/${SANDBOX_IMAGE_RUNTIME_VERSION_FILE}"`)}; }`
|
|
724
|
+
].join(" && ");
|
|
725
|
+
var IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
|
|
726
|
+
var KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
|
|
727
|
+
var PULL_RESUMING = `pull_resuming() { finished=$(mktemp) && while :; do if output=$(docker pull "$1" 2>&1); then rm -f "$finished"; return 0; fi; case "$output" in *"connection reset by peer"*|*"unexpected EOF"*|*"i/o timeout"*|*"TLS handshake timeout"*) ;; *) printf '%s\\n' "$output" >&2; rm -f "$finished"; return 1 ;; esac; before=$(wc -l < "$finished"); { cat "$finished"; printf '%s\\n' "$output" | sed -n -e 's/^\\([0-9a-f]\\{12\\}\\): Download complete$/\\1/p' -e 's/^\\([0-9a-f]\\{12\\}\\): Pull complete$/\\1/p'; } | sort -u > "$finished.next" && mv "$finished.next" "$finished"; [ "$(wc -l < "$finished")" -gt "$before" ] || { printf '%s\\n' "$output" ${IMAGE_DOWNLOAD_INTERRUPTED} >&2; rm -f "$finished"; return 1; }; done; }`;
|
|
728
|
+
function machineBootScripts(boot) {
|
|
729
|
+
const named = shell(boot.image);
|
|
730
|
+
const trees = `"$(trees_of ${named})"`;
|
|
731
|
+
const bootedFile = bootedImageFile(boot.staged);
|
|
732
|
+
const containerOfBooted = `[ "$(docker container inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${boot.containerName} 2>/dev/null)" = "$booted" ]`;
|
|
733
|
+
const bootScript = [
|
|
734
|
+
`. ${CONTAINER_ENV_PATH}`,
|
|
735
|
+
`mkdir -p ${shell(boot.directory)}`,
|
|
736
|
+
`cd ${shell(boot.directory)}`,
|
|
737
|
+
`exec ${boot.runtimeCommand}`
|
|
738
|
+
].join(" && ");
|
|
739
|
+
const mounts = [["workspace", boot.directory], ...PERSISTENT_HOME_MOUNTS];
|
|
740
|
+
const run = `docker run -d --name ${boot.containerName} --hostname ${shell(containerHostname(boot.workspaceId))} -p ${boot.port}:${boot.port} -v ${VM_DOCKER_INIT}:${CONTAINER_INIT_PATH}:ro -v "$(pwd)/${RUNTIME_ENV_PATH}:${CONTAINER_ENV_PATH}:ro" --env-file "$trees/${IMAGE_ENV_FILE}" ` + IMAGE_TREES.map((tree) => `-v "$trees/${tree}":/${tree} `).join("") + mounts.map(([source, target]) => `-v "$(pwd)/${PERSISTENT_ROOT}/${source}":${shell(target)} `).join("") + `${CONTAINER_SECURITY_OPTIONS} --entrypoint ${CONTAINER_INIT_PATH} ${STUB_IMAGE} -- sh -lc ${shell(bootScript)}`;
|
|
741
|
+
const seedTrees = `sudo rm -rf ${trees} && sudo mkdir -p ${trees} && { docker rm -f ${SEED_CONTAINER_NAME} >/dev/null 2>&1 || true; } && docker create --name ${SEED_CONTAINER_NAME} ${named} true >/dev/null && for tree in ${IMAGE_TREES.join(" ")}; do docker cp ${SEED_CONTAINER_NAME}:/$tree - | sudo tar -xf - -C ${trees} ${IMAGE_TREE_SEED_EXCLUDES.map((exclude) => `--exclude=${exclude}`).join(" ")} && [ -d ${trees}/$tree ] || exit 1; done && docker rm ${SEED_CONTAINER_NAME} >/dev/null && sudo touch ${trees}/${IMAGE_TREES_COMPLETE_MARKER}`;
|
|
742
|
+
const seedStub = `stub=$(mktemp -d) && docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${named} > "$stub/${IMAGE_ENV_FILE}" && workdir=$(docker image inspect --format '{{or .Config.WorkingDir "/"}}' ${named}) && mkdir "$stub/root" ${ROOT_DIRECTORIES.map((directory) => `"$stub/root/${directory}"`).join(" ")} && ${ROOT_LINKS.map((link) => `ln -s usr/${link} "$stub/root/${link}"`).join(" && ")} && chmod 1777 "$stub/root/tmp" && tar --owner=0 --group=0 --numeric-owner -cf "$stub/skeleton.tar" -C "$stub/root" . && docker import --change "WORKDIR $workdir" --change ${shell(`LABEL ${IMAGE_LABEL}=${boot.image}`)} "$stub/skeleton.tar" ${STUB_IMAGE} >/dev/null && docker save -o "$stub/${STUB_IMAGE_ARCHIVE}" ${STUB_IMAGE} && sudo mv "$stub/${IMAGE_ENV_FILE}" "$stub/${STUB_IMAGE_ARCHIVE}" ${trees}/ && rm -rf "$stub"`;
|
|
743
|
+
const seedNamed = `{ docker image inspect ${named} >/dev/null 2>&1 || pull_resuming ${named}; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}`;
|
|
744
|
+
const keepPrevious = `previous=$(${stubLabel}) && [ -n "$previous" ] && seeded "$(trees_of "$previous")" && served "$(trees_of "$previous")"`;
|
|
745
|
+
const keptImageSkipped = `echo "${KEPT_IMAGE_SKIPPED} stub=\${previous:-none} seeded=$(seeded "$(trees_of "$previous")" && echo yes || echo no) version=$(cat "$(trees_of "$previous")/${SANDBOX_IMAGE_RUNTIME_VERSION_FILE}" 2>/dev/null || echo none)" >&2`;
|
|
746
|
+
const removeImage = `if docker image inspect "$booted" >/dev/null 2>&1; then leftovers=$(docker ps -aq --filter ancestor="$booted") && { [ -z "$leftovers" ] || docker rm -f $leftovers >/dev/null; } && id=$(docker image inspect --format '{{.Id}}' "$booted") && docker rmi "$booted" >/dev/null && sudo rm -f ${BOAT_SAVED_IMAGES}/"\${id#sha256:}".tar; fi`;
|
|
747
|
+
const prepare = [
|
|
748
|
+
`chmod 600 ${boot.staged.env}`,
|
|
749
|
+
`find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${boot.staleStagedMinutes} -exec rm -f {} +`,
|
|
750
|
+
`timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
|
|
751
|
+
`[ -x ${VM_DOCKER_INIT} ]`,
|
|
752
|
+
...boot.registry && boot.staged.password ? [`{ chmod 600 ${boot.staged.password}; docker login ${shell(boot.registry.server)} --username ${shell(boot.registry.username)} --password-stdin < ${boot.staged.password}; rc=$?; rm -f ${boot.staged.password}; (exit $rc); }`] : [],
|
|
753
|
+
treesFunctions,
|
|
754
|
+
PULL_RESUMING,
|
|
755
|
+
`booted=${named}`,
|
|
756
|
+
`if ! seeded ${trees}; then ` + (boot.keepPreviousImage ? `if ${keepPrevious}; then booted=$previous; else ${keptImageSkipped}; ${seedNamed}; fi` : seedNamed) + `; fi`,
|
|
757
|
+
`{ [ "$(${stubLabel})" = "$booted" ] || docker load -i "$(trees_of "$booted")/${STUB_IMAGE_ARCHIVE}" >/dev/null; }`,
|
|
758
|
+
`sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path "$(trees_of "$booted")" -exec rm -rf {} +`,
|
|
759
|
+
`printf '%s\\n' "$booted" > ${bootedFile}`,
|
|
760
|
+
`printf '%s\\n' "$booted"`
|
|
761
|
+
];
|
|
762
|
+
const start = `${treesFunctions} && booted=$(cat ${bootedFile}) && trees=$(trees_of "$booted") && rm -f ${bootedFile} && mv -f ${boot.staged.env} ${RUNTIME_ENV_PATH} && up=; for attempt in $(seq ${CONTAINER_CREATE_ATTEMPTS}); do [ "$attempt" = 1 ] || sleep 1; if ${containerOfBooted}; then failure=$(docker start ${boot.containerName} 2>&1 >/dev/null) && { up=1; break; }; else docker rm -f ${boot.containerName} >/dev/null 2>&1; failure=$(${run} 2>&1 >/dev/null) && { up=1; break; }; fi; case "$failure" in *${shell(DOCKER_NAME_IN_USE)}*) ;; *) break ;; esac; done; [ -n "$up" ] || { echo "$failure" >&2; false; } && ${removeImage}`;
|
|
763
|
+
return {
|
|
764
|
+
prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
|
|
765
|
+
start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/drivers/boat.ts
|
|
770
|
+
var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
|
|
771
|
+
var DEFAULT_WORKSPACE_DIR = "/workspace";
|
|
772
|
+
var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
|
|
773
|
+
var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
|
|
774
|
+
var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
|
|
775
|
+
var DEFAULT_HEALTH_INTERVAL_MS = 100;
|
|
776
|
+
var HEALTH_PROBE_WINDOW_SECONDS = 50;
|
|
777
|
+
var HEALTH_PROBE_MARGIN_SECONDS = 10;
|
|
778
|
+
var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
|
|
779
|
+
var HOST_PUBLISH_TIMEOUT_SECONDS = 120;
|
|
780
|
+
var BOOT_TTL_SECONDS = 30 * 60;
|
|
781
|
+
var CONTAINER_STOP_GRACE_SECONDS = 30;
|
|
782
|
+
var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
|
|
783
|
+
var STDERR_TAIL_CHARS = 600;
|
|
784
|
+
var READY_STATES = /* @__PURE__ */ new Set(["ready", "idle", "running"]);
|
|
785
|
+
var PENDING_STATES = /* @__PURE__ */ new Set(["init", "provisioning", "provisioned", "cloning"]);
|
|
786
|
+
var TRANSIENT_MARKERS = ["transport_failed", "idempotency_in_progress"];
|
|
787
|
+
var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"]);
|
|
788
|
+
var RESUME_UNDER_WAY = "sandbox_resuming";
|
|
789
|
+
var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
|
|
790
|
+
var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
|
|
656
791
|
function hostGoneOrThrow(err) {
|
|
657
792
|
if (err instanceof BoatApiError && GONE_CODES.has(err.code)) throw new SandboxHostGoneError(err.message);
|
|
658
793
|
throw err;
|
|
@@ -665,24 +800,11 @@ function archivedOrThrow(err) {
|
|
|
665
800
|
if (err instanceof BoatApiError && err.code === "stop_failed" && err.sandboxState === "archived") return;
|
|
666
801
|
hostGoneOrThrow(err);
|
|
667
802
|
}
|
|
668
|
-
function holdingBootLock(commandTimeoutSeconds, script) {
|
|
669
|
-
const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
|
|
670
|
-
return `exec 9>>${BOOT_LOCK_PATH} && { flock -w ${wait} 9 || { echo "another boot of this machine held ${BOOT_LOCK_PATH} for ${wait} s" >&2; exit 1; }; } && ${script}`;
|
|
671
|
-
}
|
|
672
803
|
function sleep(ms) {
|
|
673
804
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
674
805
|
}
|
|
675
|
-
function scrubbed(text, secrets) {
|
|
676
|
-
return secrets.filter((secret) => secret.length >= REDACTED_VALUE_MIN_LENGTH).reduce((output, secret) => output.split(secret).join("[redacted]"), text);
|
|
677
|
-
}
|
|
678
806
|
function scrubbedTail(text, secrets) {
|
|
679
|
-
return
|
|
680
|
-
}
|
|
681
|
-
function imageTreesDirectory(image) {
|
|
682
|
-
return `image-${image.replace(/[^A-Za-z0-9._-]/g, "-")}`;
|
|
683
|
-
}
|
|
684
|
-
function containerHostname(workspaceId) {
|
|
685
|
-
return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
|
|
807
|
+
return redactWorkspaceRuntimeBootSecrets(text, secrets).trim().slice(-STDERR_TAIL_CHARS);
|
|
686
808
|
}
|
|
687
809
|
var BoatDriverError = class extends Error {
|
|
688
810
|
constructor(message) {
|
|
@@ -764,43 +886,6 @@ function createBoatSandboxDriver(options) {
|
|
|
764
886
|
wait = Math.min(wait * 2, statePollMs.maxMs);
|
|
765
887
|
}
|
|
766
888
|
}
|
|
767
|
-
function containerScripts(input, staged) {
|
|
768
|
-
const port = runtimePort(input);
|
|
769
|
-
const directory = workspaceDirectory(input);
|
|
770
|
-
const reference = resolveImage(input);
|
|
771
|
-
const image = shell(reference);
|
|
772
|
-
const trees = `"$(pwd)/${PERSISTENT_ROOT}/${imageTreesDirectory(reference)}"`;
|
|
773
|
-
const labelledWithImage = (object, name) => `[ "$(docker ${object} inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${name} 2>/dev/null)" = ${image} ]`;
|
|
774
|
-
const bootScript = [
|
|
775
|
-
`. ${CONTAINER_ENV_PATH}`,
|
|
776
|
-
`mkdir -p ${shell(directory)}`,
|
|
777
|
-
`cd ${shell(directory)}`,
|
|
778
|
-
`exec ${runtimeCommand}`
|
|
779
|
-
].join(" && ");
|
|
780
|
-
const mounts = [["workspace", directory], ...PERSISTENT_HOME_MOUNTS];
|
|
781
|
-
const run = `docker run -d --name ${containerName} --hostname ${shell(containerHostname(input.workspaceId))} -p ${port}:${port} -v ${VM_DOCKER_INIT}:${CONTAINER_INIT_PATH}:ro -v "$(pwd)/${RUNTIME_ENV_PATH}:${CONTAINER_ENV_PATH}:ro" --env-file ${trees}/${IMAGE_ENV_FILE} ` + IMAGE_TREES.map((tree) => `-v ${trees}/${tree}:/${tree} `).join("") + mounts.map(([source, target]) => `-v "$(pwd)/${PERSISTENT_ROOT}/${source}":${shell(target)} `).join("") + `${CONTAINER_SECURITY_OPTIONS} --entrypoint ${CONTAINER_INIT_PATH} ${STUB_IMAGE} -- sh -lc ${shell(bootScript)}`;
|
|
782
|
-
const seedTrees = `sudo rm -rf ${trees} && sudo mkdir -p ${trees} && { docker rm -f ${SEED_CONTAINER_NAME} >/dev/null 2>&1 || true; } && docker create --name ${SEED_CONTAINER_NAME} ${image} true >/dev/null && for tree in ${IMAGE_TREES.join(" ")}; do docker cp ${SEED_CONTAINER_NAME}:/$tree - | sudo tar -xf - -C ${trees} ${IMAGE_TREE_SEED_EXCLUDES.map((exclude) => `--exclude=${exclude}`).join(" ")} && [ -d ${trees}/$tree ] || exit 1; done && docker rm ${SEED_CONTAINER_NAME} >/dev/null && sudo touch ${trees}/${IMAGE_TREES_COMPLETE_MARKER}`;
|
|
783
|
-
const seedStub = `stub=$(mktemp -d) && docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' ${image} > "$stub/${IMAGE_ENV_FILE}" && workdir=$(docker image inspect --format '{{or .Config.WorkingDir "/"}}' ${image}) && mkdir "$stub/root" ${ROOT_DIRECTORIES.map((directory2) => `"$stub/root/${directory2}"`).join(" ")} && ${ROOT_LINKS.map((link) => `ln -s usr/${link} "$stub/root/${link}"`).join(" && ")} && chmod 1777 "$stub/root/tmp" && tar --owner=0 --group=0 --numeric-owner -cf "$stub/skeleton.tar" -C "$stub/root" . && docker import --change "WORKDIR $workdir" --change ${shell(`LABEL ${IMAGE_LABEL}=${reference}`)} "$stub/skeleton.tar" ${STUB_IMAGE} >/dev/null && docker save -o "$stub/${STUB_IMAGE_ARCHIVE}" ${STUB_IMAGE} && sudo mv "$stub/${IMAGE_ENV_FILE}" "$stub/${STUB_IMAGE_ARCHIVE}" ${trees}/ && rm -rf "$stub"`;
|
|
784
|
-
const removeImage = `if docker image inspect ${image} >/dev/null 2>&1; then leftovers=$(docker ps -aq --filter ancestor=${image}) && { [ -z "$leftovers" ] || docker rm -f $leftovers >/dev/null; } && id=$(docker image inspect --format '{{.Id}}' ${image}) && docker rmi ${image} >/dev/null && sudo rm -f ${BOAT_SAVED_IMAGES}/"\${id#sha256:}".tar; fi`;
|
|
785
|
-
const seeded = [IMAGE_TREES_COMPLETE_MARKER, STUB_IMAGE_ARCHIVE, IMAGE_ENV_FILE].map((file) => `[ -f ${trees}/${file} ]`).join(" && ");
|
|
786
|
-
const prepare = [
|
|
787
|
-
`chmod 600 ${staged.env}`,
|
|
788
|
-
`find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${BOOT_TTL_SECONDS / 60} -exec rm -f {} +`,
|
|
789
|
-
`timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
|
|
790
|
-
`[ -x ${VM_DOCKER_INIT} ]`,
|
|
791
|
-
...options.registryAuth && staged.password ? [`{ chmod 600 ${staged.password}; docker login ${shell(options.registryAuth.server)} --username ${shell(options.registryAuth.username)} --password-stdin < ${staged.password}; rc=$?; rm -f ${staged.password}; (exit $rc); }`] : [],
|
|
792
|
-
// Trees an earlier start marked complete hold what a person or a harness
|
|
793
|
-
// changed since, so a seed missing only its stub or env builds those alone.
|
|
794
|
-
`if ! { ${seeded}; }; then { docker image inspect ${image} >/dev/null 2>&1 || docker pull ${image} >/dev/null; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}; fi`,
|
|
795
|
-
`{ ${labelledWithImage("image", STUB_IMAGE)} || docker load -i ${trees}/${STUB_IMAGE_ARCHIVE} >/dev/null; }`,
|
|
796
|
-
`sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path ${trees} -exec rm -rf {} +`
|
|
797
|
-
];
|
|
798
|
-
const start = `mv -f ${staged.env} ${RUNTIME_ENV_PATH} && up=; for attempt in $(seq ${CONTAINER_CREATE_ATTEMPTS}); do [ "$attempt" = 1 ] || sleep 1; if ${labelledWithImage("container", containerName)}; then failure=$(docker start ${containerName} 2>&1 >/dev/null) && { up=1; break; }; else docker rm -f ${containerName} >/dev/null 2>&1; failure=$(${run} 2>&1 >/dev/null) && { up=1; break; }; fi; case "$failure" in *${shell(DOCKER_NAME_IN_USE)}*) ;; *) break ;; esac; done; [ -n "$up" ] || { echo "$failure" >&2; false; } && ${removeImage}`;
|
|
799
|
-
return {
|
|
800
|
-
prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
|
|
801
|
-
start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
889
|
function healthProbe(port, windowSeconds) {
|
|
805
890
|
const booting = [...BOOTING_CONTAINER_STATES].join("|");
|
|
806
891
|
return `end=$(($(date +%s) + ${windowSeconds})); while :; do code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/global/health); [ "$code" = 200 ] && break; state=$(docker inspect --format '{{.State.Status}}' ${shell(containerName)} 2>/dev/null || echo missing); case "$state" in ${booting}) ;; *) break ;; esac; [ "$(date +%s)" -lt "$end" ] || break; sleep ${healthIntervalMs / 1e3}; done; echo "$code"; echo "$state"`;
|
|
@@ -829,29 +914,48 @@ function createBoatSandboxDriver(options) {
|
|
|
829
914
|
command: `docker inspect --format 'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' ${shell(containerName)}; docker logs --tail 20 ${shell(containerName)} 2>&1`,
|
|
830
915
|
timeoutSeconds: 10
|
|
831
916
|
});
|
|
832
|
-
diagnostic = runtimeBootDiagnostic(
|
|
917
|
+
diagnostic = runtimeBootDiagnostic(redactWorkspaceRuntimeBootSecrets(`${result.stdout}
|
|
833
918
|
${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
834
919
|
} catch {
|
|
835
920
|
diagnostic = "container diagnostics unavailable";
|
|
836
921
|
}
|
|
837
922
|
throw new SandboxRuntimeBootError(`Boat ${sandboxId} runtime did not become healthy: ${last}; ${diagnostic}`);
|
|
838
923
|
}
|
|
839
|
-
async function startContainer(sandboxId, input, hostId) {
|
|
924
|
+
async function startContainer(sandboxId, input, hostId, keepPreviousImage) {
|
|
840
925
|
const env = { ...bootEnv(input, hostId), ...await options.env?.(input, { id: hostId }) };
|
|
841
926
|
const boot2 = crypto.randomUUID();
|
|
842
927
|
const staged = {
|
|
843
928
|
env: `${RUNTIME_ENV_PATH}.${boot2}`,
|
|
844
929
|
...options.registryAuth ? { password: `${REGISTRY_PASSWORD_PATH}.${boot2}` } : {}
|
|
845
930
|
};
|
|
846
|
-
const
|
|
931
|
+
const image = resolveImage(input);
|
|
932
|
+
const scripts = machineBootScripts({
|
|
933
|
+
image,
|
|
934
|
+
keepPreviousImage: keepPreviousImage && image === options.image,
|
|
935
|
+
workspaceId: input.workspaceId,
|
|
936
|
+
directory: workspaceDirectory(input),
|
|
937
|
+
port: runtimePort(input),
|
|
938
|
+
runtimeCommand,
|
|
939
|
+
containerName,
|
|
940
|
+
...options.registryAuth ? { registry: { server: options.registryAuth.server, username: options.registryAuth.username } } : {},
|
|
941
|
+
staged,
|
|
942
|
+
staleStagedMinutes: BOOT_TTL_SECONDS / 60
|
|
943
|
+
});
|
|
847
944
|
await client.writeFile(sandboxId, { path: staged.env, content: envFile(env) });
|
|
848
945
|
if (options.registryAuth && staged.password) {
|
|
849
946
|
await client.writeFile(sandboxId, { path: staged.password, content: options.registryAuth.password });
|
|
850
947
|
}
|
|
851
948
|
const secrets = [...workspaceRuntimeBootSecrets(env), ...options.registryAuth ? [options.registryAuth.password] : []];
|
|
852
949
|
let failure;
|
|
950
|
+
let booted;
|
|
853
951
|
try {
|
|
854
|
-
await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets })
|
|
952
|
+
const prepared = await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets }).catch((error) => {
|
|
953
|
+
throw error instanceof BoatDriverError && error.message.includes(IMAGE_DOWNLOAD_INTERRUPTED) ? new SandboxImageDownloadError(error.message) : error;
|
|
954
|
+
});
|
|
955
|
+
booted = bootedImage(prepared.stdout);
|
|
956
|
+
if (!booted) throw new BoatDriverError(`Boat ${sandboxId} runtime image preparation named no image it booted`);
|
|
957
|
+
const skipped = keepPreviousImage ? prepared.stderr.split("\n").find((line) => line.startsWith(KEPT_IMAGE_SKIPPED)) : void 0;
|
|
958
|
+
if (skipped) console.warn("A Boat wake took the deployment's image instead of the one the machine last booted", { sandboxId, workspaceId: input.workspaceId, found: skipped.slice(KEPT_IMAGE_SKIPPED.length).trim() });
|
|
855
959
|
try {
|
|
856
960
|
await execOrThrow(sandboxId, scripts.start, "container start", { timeoutSeconds: CONTAINER_START_TIMEOUT_SECONDS, secrets });
|
|
857
961
|
} catch (error) {
|
|
@@ -862,19 +966,19 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
862
966
|
}
|
|
863
967
|
try {
|
|
864
968
|
if (options.registryAuth || failure !== void 0) {
|
|
865
|
-
await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
|
|
969
|
+
await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password, bootedImageFile(staged)].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
|
|
866
970
|
}
|
|
867
971
|
} catch (cleanupFailure) {
|
|
868
972
|
if (failure === void 0) throw cleanupFailure;
|
|
869
973
|
if (failure instanceof Error) failure.message = `${failure.message}; ${cleanupFailure instanceof Error ? cleanupFailure.message : String(cleanupFailure)}`;
|
|
870
974
|
}
|
|
871
|
-
if (failure !== void 0) throw failure;
|
|
872
|
-
return secrets;
|
|
975
|
+
if (failure !== void 0 || !booted) throw failure;
|
|
976
|
+
return { secrets, booted };
|
|
873
977
|
}
|
|
874
|
-
async function boot(sandboxId, input, hostId) {
|
|
978
|
+
async function boot(sandboxId, input, hostId, keepPreviousImage) {
|
|
875
979
|
await input.onMachineReady?.();
|
|
876
980
|
const port = runtimePort(input);
|
|
877
|
-
const secrets = await startContainer(sandboxId, input, hostId);
|
|
981
|
+
const { secrets, booted } = await startContainer(sandboxId, input, hostId, keepPreviousImage);
|
|
878
982
|
await input.onImageReady?.();
|
|
879
983
|
await execOrThrow(sandboxId, holdingBootLock(HOST_PUBLISH_TIMEOUT_SECONDS, `host ${port} --public`), "host publish", {
|
|
880
984
|
timeoutSeconds: HOST_PUBLISH_TIMEOUT_SECONDS,
|
|
@@ -894,7 +998,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
894
998
|
hostId,
|
|
895
999
|
driverResourceId: sandboxId,
|
|
896
1000
|
driver: { id: "boat", resourceId: sandboxId },
|
|
897
|
-
labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]:
|
|
1001
|
+
labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]: booted } : {} }
|
|
898
1002
|
};
|
|
899
1003
|
}
|
|
900
1004
|
function assertNetwork(input) {
|
|
@@ -943,7 +1047,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
943
1047
|
if (failures.length > 1) throw new AggregateError(failures, `Boat ${created.id} resource handoff and deletion failed`);
|
|
944
1048
|
const ready = await waitUntilReady(created.id);
|
|
945
1049
|
if (!ready) return { provisioning: true, retryAfterMs: 0 };
|
|
946
|
-
return boot(created.id, input, hostId);
|
|
1050
|
+
return boot(created.id, input, hostId, false);
|
|
947
1051
|
}
|
|
948
1052
|
async function resumeHost(input) {
|
|
949
1053
|
const sandboxId = input.lease.sandboxId;
|
|
@@ -963,7 +1067,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
963
1067
|
}
|
|
964
1068
|
const ready = await waitUntilReady(sandboxId);
|
|
965
1069
|
if (!ready) return { provisioning: true, retryAfterMs: 0 };
|
|
966
|
-
return boot(sandboxId, input.ensure, hostId);
|
|
1070
|
+
return boot(sandboxId, input.ensure, hostId, !input.ensure.updateImage);
|
|
967
1071
|
}
|
|
968
1072
|
async function stop(target) {
|
|
969
1073
|
await client.command(target.sandboxId, {
|
package/dist/drivers/boat.d.ts
CHANGED
|
@@ -22,12 +22,7 @@ export type BoatSandboxDriverOptions = {
|
|
|
22
22
|
workspaceDir?: string;
|
|
23
23
|
nativeHarness?: string;
|
|
24
24
|
controlEnv?: WorkspaceRuntimeControlEnv;
|
|
25
|
-
/** Container name used for the runtime container inside the sandbox. */
|
|
26
25
|
containerName?: string;
|
|
27
|
-
/**
|
|
28
|
-
* Optional `docker login` before the pull, for a private runtime registry.
|
|
29
|
-
* Omit for public images.
|
|
30
|
-
*/
|
|
31
26
|
registryAuth?: {
|
|
32
27
|
server: string;
|
|
33
28
|
username: string;
|
|
@@ -49,16 +44,4 @@ export type BoatSandboxDriverOptions = {
|
|
|
49
44
|
operationTimeoutMs?: number;
|
|
50
45
|
fetchImpl?: BoatFetch;
|
|
51
46
|
};
|
|
52
|
-
/**
|
|
53
|
-
* Turns a booted machine into one any workspace can start from: it keeps what
|
|
54
|
-
* every machine of the image shares (the image's trees, stub archive and env,
|
|
55
|
-
* and Boat's ~/.ascii/playbook.json) and removes what this machine's boot made
|
|
56
|
-
* (the runtime container and Boat's record and image archive of it, the
|
|
57
|
-
* workspace, the runtime's state, the boot's env). Nothing the runtime wrote
|
|
58
|
-
* into the image's trees was found there (Boat, 2026-10-16: the trees held only
|
|
59
|
-
* the image's own entries after two boots); what else differs on the machine
|
|
60
|
-
* is Boat's own, and a machine created from the template gets its own
|
|
61
|
-
* machine-id, SSH host keys, hostname and no hosted port.
|
|
62
|
-
*/
|
|
63
|
-
export declare function boatTemplateResetCommand(): string;
|
|
64
47
|
export declare function createBoatSandboxDriver(options: BoatSandboxDriverOptions): SandboxDriver;
|