@claxedo/sandbox-manager 0.10.1 → 0.10.2
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 +36 -4
- package/dist/checkpoint-manager.mjs +8 -0
- package/dist/contract.d.ts +12 -1
- package/dist/driver-ensure-input.d.ts +21 -0
- package/dist/drivers/boat-machine.d.ts +90 -0
- package/dist/drivers/boat-preparation.test-support.d.ts +2 -0
- package/dist/drivers/boat-template.mjs +132 -81
- package/dist/drivers/boat.d.ts +0 -12
- package/dist/drivers/boat.mjs +136 -97
- package/dist/drivers/cloudflare.mjs +7 -3
- package/dist/drivers/docker.mjs +5 -0
- package/dist/drivers/local-brokering.d.ts +12 -0
- package/dist/drivers/local-brokering.mjs +11 -6
- package/dist/drivers/modal.mjs +5 -0
- package/dist/drivers/vercel.mjs +5 -0
- package/dist/image-name.mjs +5 -0
- package/dist/image.d.ts +1 -0
- package/dist/image.mjs +6 -0
- package/dist/index.mjs +82 -50
- package/dist/lease-failure.d.ts +17 -0
- package/dist/runtime-version.d.ts +21 -7
- package/dist/runtime-version.mjs +7 -2
- package/dist/start-telemetry.d.ts +5 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -108,7 +108,13 @@ nothing. Without a template, or when the organization's key cannot read it, or
|
|
|
108
108
|
for any other image, the first start seeds them. The seed runs once per image, at the start that first names it: it pulls the
|
|
109
109
|
image, streams the trees out of a created container of it with `docker cp` and
|
|
110
110
|
unpacks them as root, leaving out `~/.cache` (which Boat's own snapshots leave
|
|
111
|
-
out too) and npm's cache.
|
|
111
|
+
out too) and npm's cache. Boat's link to ghcr resets mid-pull (2 of 8 staging
|
|
112
|
+
updates on 2026-10-22, `failed to copy: … connection reset by peer`), and
|
|
113
|
+
Docker ends a pull at the first reset, keeping the layers it finished and none
|
|
114
|
+
of the ones in flight. So the pull runs again after a transport failure
|
|
115
|
+
(connection reset, unexpected EOF, a timeout) for as long as each run finishes
|
|
116
|
+
a layer no run before it did; any other failure, and a run that finished
|
|
117
|
+
nothing new, fails the boot with the pull's own output. The persistent root is created as root too: Docker
|
|
112
118
|
made it as root on every sandbox that booted before the trees existed, and the
|
|
113
119
|
VM user cannot create inside it. The seed then saves beside the trees what the
|
|
114
120
|
container needs besides them: the image's env (`image.env`, given to the
|
|
@@ -152,9 +158,35 @@ the container leaves `running`/`paused`. A sandbox still archiving from a stop,
|
|
|
152
158
|
which Boat offers no wait for, is read again after 250 ms, doubling to 2 s.
|
|
153
159
|
|
|
154
160
|
A wake that pulled (before the stub) took 57–77 s, and failed when ghcr reset
|
|
155
|
-
the connection.
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
the connection. A wake whose machine holds only an older image keeps serving
|
|
162
|
+
that image: its trees, stub and env are there, and the runtime in them is no
|
|
163
|
+
older than `MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION`. The wake answers
|
|
164
|
+
with that image's label, so the workspace reads as outdated, instead of waiting
|
|
165
|
+
60–100 s on the new image's pull and seed (staging, 2026-10-21). The update
|
|
166
|
+
runs only when a start asks for it with `updateImage` (a person's Restart to
|
|
167
|
+
update): the new image is pulled and seeded as above, its stub replaces the
|
|
168
|
+
old one, and the old image's trees are removed. That step is a start of its
|
|
169
|
+
own on the serving lease's epoch, whose phases are timed from the update. An
|
|
170
|
+
update whose step fails leaves the lease serving its image and answers the
|
|
171
|
+
start with the driver's reason, which the manager logs, and a start asking for
|
|
172
|
+
the update of a workspace already serving the deployment's image answers it as
|
|
173
|
+
it is. A cold start, and a wake whose older runtime is
|
|
174
|
+
below the floor, takes the new image at once. On staging (2026-10-22, 0.10.1
|
|
175
|
+
to 0.10.2) the wake that kept the image answered in 22 s and the update's step
|
|
176
|
+
ran 92.7 s.
|
|
177
|
+
|
|
178
|
+
`MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION` (`src/runtime-version.ts`) is the one
|
|
179
|
+
floor for every driver: the oldest runtime this control plane and relay serve,
|
|
180
|
+
whether a Cloudflare restore brings it back or a Boat wake keeps it. Below it a
|
|
181
|
+
workspace updates and keeps its files on every driver: a Boat wake takes the
|
|
182
|
+
new image over the same `/workspace` and homes, and a Cloudflare restore
|
|
183
|
+
carries the repository and HOME into the current image. A runtime
|
|
184
|
+
security fix, or a control-plane or relay contract an older runtime cannot
|
|
185
|
+
speak, raises the floor and the workspace-runtime version together, in the
|
|
186
|
+
commit that makes the change; a test keeps the floor at or below the runtime
|
|
187
|
+
version the tree builds. Each connection names the image it serves
|
|
188
|
+
(`runtimeImage`), and every mint of an outdated one is the
|
|
189
|
+
`sandbox.runtime_outdated_served` event.
|
|
158
190
|
|
|
159
191
|
A start runs as two commands: the preparation (daemon, login, seed, stub)
|
|
160
192
|
changes nothing a running container serves from, so its failure leaves a
|
|
@@ -53,11 +53,19 @@ var PUBLIC_API_ERRORS = {
|
|
|
53
53
|
enrollment_paused: { status: 403, message: "enrollment_paused" },
|
|
54
54
|
enrollment_owner_ineligible: { status: 403, message: "enrollment_owner_ineligible" },
|
|
55
55
|
enrollment_key_version_mismatch: { status: 403, message: "enrollment_key_version_mismatch" },
|
|
56
|
+
enrollment_denied: { status: 403, message: "enrollment_denied" },
|
|
57
|
+
enrollment_approval_expired: { status: 403, message: "enrollment_approval_expired" },
|
|
58
|
+
enrollment_awaiting_approval: { status: 403, message: "enrollment_awaiting_approval" },
|
|
56
59
|
invitation_invalid: { status: 403, message: "invitation_invalid" },
|
|
57
60
|
invitation_expired: { status: 410, message: "invitation_expired" },
|
|
58
61
|
invitation_revoked: { status: 410, message: "invitation_revoked" },
|
|
59
62
|
invitation_redeemed: { status: 409, message: "invitation_redeemed" },
|
|
60
63
|
invitation_host_conflict: { status: 409, message: "invitation_host_conflict" },
|
|
64
|
+
pairing_invalid: { status: 403, message: "pairing_invalid" },
|
|
65
|
+
pairing_expired: { status: 410, message: "pairing_expired" },
|
|
66
|
+
pairing_used: { status: 409, message: "pairing_used" },
|
|
67
|
+
pairing_owner_signed_out: { status: 410, message: "pairing_owner_signed_out" },
|
|
68
|
+
pairing_decided: { status: 409, message: "pairing_decided" },
|
|
61
69
|
enrollment_generation_superseded: { status: 409, message: "enrollment_generation_superseded" },
|
|
62
70
|
host_assignment_outside_scope: { status: 400, message: "host_assignment_outside_scope" },
|
|
63
71
|
host_sealing_key_undeclared: { status: 409, message: "host_sealing_key_undeclared" },
|
package/dist/contract.d.ts
CHANGED
|
@@ -337,6 +337,13 @@ export type SandboxDriverEnsureInput = {
|
|
|
337
337
|
net?: SandboxNetworkPolicy;
|
|
338
338
|
/** Optional snapshot/image to boot from (user-supplied or pre-built). */
|
|
339
339
|
snapshot?: string;
|
|
340
|
+
/**
|
|
341
|
+
* The person asked for the deployment's image. Without it, a resume of a
|
|
342
|
+
* machine that holds only an older image may keep serving that one and
|
|
343
|
+
* report it, so the workspace reads as outdated instead of waiting on the
|
|
344
|
+
* new image's preparation.
|
|
345
|
+
*/
|
|
346
|
+
updateImage?: true;
|
|
340
347
|
};
|
|
341
348
|
export type SandboxTarget = {
|
|
342
349
|
workspaceId?: string;
|
|
@@ -503,6 +510,8 @@ export type SandboxManagerInput = {
|
|
|
503
510
|
*/
|
|
504
511
|
net?: SandboxNetworkPolicy;
|
|
505
512
|
snapshot?: string;
|
|
513
|
+
/** @see SandboxDriverEnsureInput.updateImage */
|
|
514
|
+
updateImage?: true;
|
|
506
515
|
/**
|
|
507
516
|
* Called by the one ensure whose acquire created the workspace's first
|
|
508
517
|
* lease, before its driver is asked for anything; a concurrent start that
|
|
@@ -551,6 +560,8 @@ export type SandboxEnsureResult = ({
|
|
|
551
560
|
* delivered — the provider edge still holds the previous set.
|
|
552
561
|
*/
|
|
553
562
|
stale?: true;
|
|
563
|
+
/** @see SandboxTargetResult */
|
|
564
|
+
imageOutdated?: true;
|
|
554
565
|
}) | {
|
|
555
566
|
status: "provisioning";
|
|
556
567
|
retryAfterMs: number;
|
|
@@ -572,7 +583,7 @@ export type SandboxTargetResult = ({
|
|
|
572
583
|
epoch: number;
|
|
573
584
|
routingId?: string;
|
|
574
585
|
homeRegion: SandboxRegion;
|
|
575
|
-
/** The runtime booted from an image other than the driver's current one;
|
|
586
|
+
/** The runtime booted from an image other than the driver's current one; an ensure asked to `updateImage` boots the current one. */
|
|
576
587
|
imageOutdated?: true;
|
|
577
588
|
/** The start that brought this epoch up, as observed; the run still delivering its settings is timed from it. */
|
|
578
589
|
start?: SandboxEpochStart;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SandboxDriver, SandboxDriverEnsureInput, SandboxLease, SandboxManagerInput, SandboxRegion } from "./contract";
|
|
2
|
+
export declare function leaseHostId(driver: SandboxDriver, workspaceId: string, lease: SandboxLease, managerInput: SandboxManagerInput | undefined): string;
|
|
3
|
+
/**
|
|
4
|
+
* Build the driver-facing ensure input.
|
|
5
|
+
*
|
|
6
|
+
* This is the ONLY place a `SandboxDriverEnsureInput` is constructed, which is
|
|
7
|
+
* what makes the egress guarantee structural rather than a promise kept by each
|
|
8
|
+
* call site: `net` is copied across only when `sandboxEgressDisposition` says
|
|
9
|
+
* the driver can enforce it. A driver declaring `egressControl: "none"` — every
|
|
10
|
+
* driver that throws on a restricted policy, plus the two that silently drop it
|
|
11
|
+
* — cannot be reached by one through any path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureHostInput(input: {
|
|
14
|
+
driver: SandboxDriver;
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
lease: SandboxLease;
|
|
17
|
+
homeRegion: SandboxRegion;
|
|
18
|
+
managerInput?: SandboxManagerInput;
|
|
19
|
+
leaseEnv?: Record<string, string>;
|
|
20
|
+
appLabel: string;
|
|
21
|
+
}): SandboxDriverEnsureInput;
|
|
@@ -0,0 +1,90 @@
|
|
|
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 (Boat, 2026-10-16: the trees held only
|
|
13
|
+
* the image's own 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, 2026-10-21); 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
|
+
/**
|
|
52
|
+
* The command carries only paths: env arrives through the staged file
|
|
53
|
+
* (mounted read-only, sourced by the entrypoint at every container start so
|
|
54
|
+
* multi-line values like PEMs survive — `docker run --env-file` cannot
|
|
55
|
+
* express them), and the registry password is piped to `docker login` from a
|
|
56
|
+
* file that is removed whatever the outcome; a failed login aborts the chain.
|
|
57
|
+
*
|
|
58
|
+
* Two commands. `prepare` changes nothing a running container serves from:
|
|
59
|
+
* it waits for the daemon (a resume reboots the VM, and Docker comes up as a
|
|
60
|
+
* systemd service after Boat reports the sandbox ready), logs in, and settles
|
|
61
|
+
* which image boots: the one named when its trees, stub and env are there;
|
|
62
|
+
* the one the machine last booted when the boot may keep it and its trees
|
|
63
|
+
* hold a runtime still served; otherwise the one named, pulled and seeded
|
|
64
|
+
* where anything is missing. It loads that image's stub when Docker does not
|
|
65
|
+
* hold it, removes every other image's trees, writes the image for `start`
|
|
66
|
+
* and prints it. A failure there leaves a runtime of the previous image
|
|
67
|
+
* serving. `start` then replaces another image's container with one of the
|
|
68
|
+
* booted image, or starts that image's container that is already there: the
|
|
69
|
+
* one the stop kept, which Boat's restore creates again stopped, or one Boat
|
|
70
|
+
* recreated after a stop that was not this driver's; then it removes the
|
|
71
|
+
* image if a seed left it. A failure there leaves nothing serving.
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* `docker pull` that goes on after the connection under it breaks. Docker ends
|
|
75
|
+
* a pull at the first reset and keeps none of a layer that was still
|
|
76
|
+
* downloading, but keeps every layer it finished, so pulling again continues
|
|
77
|
+
* from those. Boat's link to ghcr resets mid-pull (2 of 8 staging updates on
|
|
78
|
+
* 22 October, `failed to copy: read tcp …: connection reset by peer`). A pull
|
|
79
|
+
* is run again only after such a transport failure and only while each run
|
|
80
|
+
* finishes a layer none before it did, so it ends; any other failure, and a
|
|
81
|
+
* run that finished nothing new, fails with the pull's own output.
|
|
82
|
+
*/
|
|
83
|
+
/** The last line a preparation prints when its pull gave up on a registry link that kept breaking. */
|
|
84
|
+
export declare const IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
|
|
85
|
+
/** 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. */
|
|
86
|
+
export declare const KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
|
|
87
|
+
export declare function machineBootScripts(boot: MachineBoot): {
|
|
88
|
+
prepare: string;
|
|
89
|
+
start: string;
|
|
90
|
+
};
|
|
@@ -81,6 +81,13 @@ var SandboxRuntimeBootError = class extends Error {
|
|
|
81
81
|
this.name = "SandboxRuntimeBootError";
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
|
+
var SandboxImageDownloadError = class extends Error {
|
|
85
|
+
imageDownloadFailed = true;
|
|
86
|
+
constructor(reason) {
|
|
87
|
+
super(reason);
|
|
88
|
+
this.name = "SandboxImageDownloadError";
|
|
89
|
+
}
|
|
90
|
+
};
|
|
84
91
|
var SandboxHostGoneError = class extends Error {
|
|
85
92
|
hostGone = true;
|
|
86
93
|
constructor(reason) {
|
|
@@ -181,6 +188,13 @@ function envFile(input) {
|
|
|
181
188
|
}).join("\n") + "\n";
|
|
182
189
|
}
|
|
183
190
|
|
|
191
|
+
// src/runtime-version.ts
|
|
192
|
+
var SERVED_WORKSPACE_RUNTIME_FLOOR = {
|
|
193
|
+
version: "0.10.1",
|
|
194
|
+
image: "ghcr.io/kyashrathore/claxedo-sandbox:workspace-runtime-0-10-1-40706b7356-agent-plugins-v8"
|
|
195
|
+
};
|
|
196
|
+
var MINIMUM_SERVED_WORKSPACE_RUNTIME_VERSION = SERVED_WORKSPACE_RUNTIME_FLOOR.version;
|
|
197
|
+
|
|
184
198
|
// src/image-name.ts
|
|
185
199
|
function assertSandboxImageReference(image) {
|
|
186
200
|
if (!image || image !== image.trim() || image.startsWith("-") || /\s/.test(image)) {
|
|
@@ -592,21 +606,10 @@ function isTransientDriverError(err, markers) {
|
|
|
592
606
|
}
|
|
593
607
|
var TRANSIENT_DRIVER_RETRY_MS = 2e3;
|
|
594
608
|
|
|
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"]);
|
|
609
|
+
// src/drivers/boat-machine.ts
|
|
606
610
|
var IMAGE_PREPARATION_TIMEOUT_SECONDS = 600;
|
|
607
611
|
var CONTAINER_START_TIMEOUT_SECONDS = 120;
|
|
608
|
-
var
|
|
609
|
-
var BOOT_TTL_SECONDS = 30 * 60;
|
|
612
|
+
var DEFAULT_CONTAINER_NAME = "claxedo-runtime";
|
|
610
613
|
var DOCKER_DAEMON_WAIT_SECONDS = 120;
|
|
611
614
|
var RUNTIME_ENV_PATH = ".claxedo-runtime-env";
|
|
612
615
|
var REGISTRY_PASSWORD_PATH = ".claxedo-registry-password";
|
|
@@ -632,6 +635,92 @@ var CONTAINER_CREATE_ATTEMPTS = 5;
|
|
|
632
635
|
var CONTAINER_SECURITY_OPTIONS = "--security-opt seccomp=unconfined --security-opt apparmor=unconfined";
|
|
633
636
|
var VM_DOCKER_INIT = "/usr/libexec/docker/docker-init";
|
|
634
637
|
var CONTAINER_INIT_PATH = "/claxedo-init";
|
|
638
|
+
function boatTemplateResetCommand() {
|
|
639
|
+
const bootFiles = [
|
|
640
|
+
`${PERSISTENT_ROOT}/workspace`,
|
|
641
|
+
...PERSISTENT_HOME_MOUNTS.map(([source]) => `${PERSISTENT_ROOT}/${source}`),
|
|
642
|
+
RUNTIME_ENV_PATH,
|
|
643
|
+
`${RUNTIME_ENV_PATH}.*`,
|
|
644
|
+
`${REGISTRY_PASSWORD_PATH}.*`
|
|
645
|
+
];
|
|
646
|
+
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}`;
|
|
647
|
+
}
|
|
648
|
+
function holdingBootLock(commandTimeoutSeconds, script) {
|
|
649
|
+
const wait = commandTimeoutSeconds - BOOT_LOCK_HEADROOM_SECONDS;
|
|
650
|
+
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}`;
|
|
651
|
+
}
|
|
652
|
+
function containerHostname(workspaceId) {
|
|
653
|
+
return workspaceId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "claxedo";
|
|
654
|
+
}
|
|
655
|
+
function bootedImageFile(staged) {
|
|
656
|
+
return `${staged.env}.image`;
|
|
657
|
+
}
|
|
658
|
+
function bootedImage(prepareStdout) {
|
|
659
|
+
return prepareStdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop();
|
|
660
|
+
}
|
|
661
|
+
var VERSION_FILE = "opt/workspace-runtime/workspace-runtime-version";
|
|
662
|
+
var stubLabel = `docker image inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${STUB_IMAGE} 2>/dev/null`;
|
|
663
|
+
var treesFunctions = [
|
|
664
|
+
`trees_of() { printf '%s/${PERSISTENT_ROOT}/image-%s' "$(pwd)" "$(printf %s "$1" | sed 's/[^A-Za-z0-9._-]/-/g')"; }`,
|
|
665
|
+
`seeded() { [ -f "$1/${IMAGE_TREES_COMPLETE_MARKER}" ] && [ -f "$1/${STUB_IMAGE_ARCHIVE}" ] && [ -f "$1/${IMAGE_ENV_FILE}" ]; }`,
|
|
666
|
+
`served() { version=$(cat "$1/${VERSION_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} ]; }`
|
|
667
|
+
].join(" && ");
|
|
668
|
+
var IMAGE_DOWNLOAD_INTERRUPTED = "image_download_interrupted";
|
|
669
|
+
var KEPT_IMAGE_SKIPPED = "kept_image_skipped:";
|
|
670
|
+
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; }`;
|
|
671
|
+
function machineBootScripts(boot) {
|
|
672
|
+
const named = shell(boot.image);
|
|
673
|
+
const trees = `"$(trees_of ${named})"`;
|
|
674
|
+
const bootedFile = bootedImageFile(boot.staged);
|
|
675
|
+
const containerOfBooted = `[ "$(docker container inspect --format '{{index .Config.Labels "${IMAGE_LABEL}"}}' ${boot.containerName} 2>/dev/null)" = "$booted" ]`;
|
|
676
|
+
const bootScript = [
|
|
677
|
+
`. ${CONTAINER_ENV_PATH}`,
|
|
678
|
+
`mkdir -p ${shell(boot.directory)}`,
|
|
679
|
+
`cd ${shell(boot.directory)}`,
|
|
680
|
+
`exec ${boot.runtimeCommand}`
|
|
681
|
+
].join(" && ");
|
|
682
|
+
const mounts = [["workspace", boot.directory], ...PERSISTENT_HOME_MOUNTS];
|
|
683
|
+
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)}`;
|
|
684
|
+
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}`;
|
|
685
|
+
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"`;
|
|
686
|
+
const seedNamed = `{ docker image inspect ${named} >/dev/null 2>&1 || pull_resuming ${named}; } && { [ -f ${trees}/${IMAGE_TREES_COMPLETE_MARKER} ] || { ${seedTrees}; }; } && ${seedStub}`;
|
|
687
|
+
const keepPrevious = `previous=$(${stubLabel}) && [ -n "$previous" ] && seeded "$(trees_of "$previous")" && served "$(trees_of "$previous")"`;
|
|
688
|
+
const keptImageSkipped = `echo "${KEPT_IMAGE_SKIPPED} stub=\${previous:-none} seeded=$(seeded "$(trees_of "$previous")" && echo yes || echo no) version=$(cat "$(trees_of "$previous")/${VERSION_FILE}" 2>/dev/null || echo none)" >&2`;
|
|
689
|
+
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`;
|
|
690
|
+
const prepare = [
|
|
691
|
+
`chmod 600 ${boot.staged.env}`,
|
|
692
|
+
`find . -maxdepth 1 \\( -name '${RUNTIME_ENV_PATH}.*' -o -name '${REGISTRY_PASSWORD_PATH}.*' \\) -mmin +${boot.staleStagedMinutes} -exec rm -f {} +`,
|
|
693
|
+
`timeout ${DOCKER_DAEMON_WAIT_SECONDS} sh -c 'until docker info >/dev/null 2>&1; do sleep 1; done'`,
|
|
694
|
+
`[ -x ${VM_DOCKER_INIT} ]`,
|
|
695
|
+
...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); }`] : [],
|
|
696
|
+
treesFunctions,
|
|
697
|
+
PULL_RESUMING,
|
|
698
|
+
`booted=${named}`,
|
|
699
|
+
`if ! seeded ${trees}; then ` + (boot.keepPreviousImage ? `if ${keepPrevious}; then booted=$previous; else ${keptImageSkipped}; ${seedNamed}; fi` : seedNamed) + `; fi`,
|
|
700
|
+
`{ [ "$(${stubLabel})" = "$booted" ] || docker load -i "$(trees_of "$booted")/${STUB_IMAGE_ARCHIVE}" >/dev/null; }`,
|
|
701
|
+
`sudo find "$(pwd)/${PERSISTENT_ROOT}" -mindepth 1 -maxdepth 1 -name 'image-*' ! -path "$(trees_of "$booted")" -exec rm -rf {} +`,
|
|
702
|
+
`printf '%s\\n' "$booted" > ${bootedFile}`,
|
|
703
|
+
`printf '%s\\n' "$booted"`
|
|
704
|
+
];
|
|
705
|
+
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}`;
|
|
706
|
+
return {
|
|
707
|
+
prepare: holdingBootLock(IMAGE_PREPARATION_TIMEOUT_SECONDS, prepare.join(" && ")),
|
|
708
|
+
start: holdingBootLock(CONTAINER_START_TIMEOUT_SECONDS, start)
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// src/drivers/boat.ts
|
|
713
|
+
var DEFAULT_RUNTIME_COMMAND = "/usr/local/bin/workspace-runtime";
|
|
714
|
+
var DEFAULT_WORKSPACE_DIR = "/workspace";
|
|
715
|
+
var DEFAULT_PROVISION_TIMEOUT_MS = 12e4;
|
|
716
|
+
var DEFAULT_STATE_POLL_MS = { firstMs: 250, maxMs: 2e3 };
|
|
717
|
+
var DEFAULT_HEALTH_TIMEOUT_MS = 10 * 6e4;
|
|
718
|
+
var DEFAULT_HEALTH_INTERVAL_MS = 100;
|
|
719
|
+
var HEALTH_PROBE_WINDOW_SECONDS = 50;
|
|
720
|
+
var HEALTH_PROBE_MARGIN_SECONDS = 10;
|
|
721
|
+
var BOOTING_CONTAINER_STATES = /* @__PURE__ */ new Set(["running", "paused"]);
|
|
722
|
+
var HOST_PUBLISH_TIMEOUT_SECONDS = 120;
|
|
723
|
+
var BOOT_TTL_SECONDS = 30 * 60;
|
|
635
724
|
var CONTAINER_STOP_GRACE_SECONDS = 30;
|
|
636
725
|
var HOST_RELAY_NOT_STOPPED = "The sandbox could not stop the localhost relay of this port. Try again.";
|
|
637
726
|
var STDERR_TAIL_CHARS = 600;
|
|
@@ -643,16 +732,6 @@ var GONE_CODES = /* @__PURE__ */ new Set(["not_found", "snapshot_data_expired"])
|
|
|
643
732
|
var RESUME_UNDER_WAY = "sandbox_resuming";
|
|
644
733
|
var NOT_RUNNING_CODES = /* @__PURE__ */ new Set(["machine_not_running", "sandbox_not_ready"]);
|
|
645
734
|
var TEMPLATE_UNREADABLE_CODES = /* @__PURE__ */ new Set(["snapshot_not_found", "api_key_action_forbidden", "api_key_sandbox_forbidden"]);
|
|
646
|
-
function boatTemplateResetCommand() {
|
|
647
|
-
const bootFiles = [
|
|
648
|
-
`${PERSISTENT_ROOT}/workspace`,
|
|
649
|
-
...PERSISTENT_HOME_MOUNTS.map(([source]) => `${PERSISTENT_ROOT}/${source}`),
|
|
650
|
-
RUNTIME_ENV_PATH,
|
|
651
|
-
`${RUNTIME_ENV_PATH}.*`,
|
|
652
|
-
`${REGISTRY_PASSWORD_PATH}.*`
|
|
653
|
-
];
|
|
654
|
-
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
|
-
}
|
|
656
735
|
function hostGoneOrThrow(err) {
|
|
657
736
|
if (err instanceof BoatApiError && GONE_CODES.has(err.code)) throw new SandboxHostGoneError(err.message);
|
|
658
737
|
throw err;
|
|
@@ -665,10 +744,6 @@ function archivedOrThrow(err) {
|
|
|
665
744
|
if (err instanceof BoatApiError && err.code === "stop_failed" && err.sandboxState === "archived") return;
|
|
666
745
|
hostGoneOrThrow(err);
|
|
667
746
|
}
|
|
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
747
|
function sleep(ms) {
|
|
673
748
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
674
749
|
}
|
|
@@ -678,12 +753,6 @@ function scrubbed(text, secrets) {
|
|
|
678
753
|
function scrubbedTail(text, secrets) {
|
|
679
754
|
return scrubbed(text, secrets).trim().slice(-STDERR_TAIL_CHARS);
|
|
680
755
|
}
|
|
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";
|
|
686
|
-
}
|
|
687
756
|
var BoatDriverError = class extends Error {
|
|
688
757
|
constructor(message) {
|
|
689
758
|
super(message);
|
|
@@ -764,43 +833,6 @@ function createBoatSandboxDriver(options) {
|
|
|
764
833
|
wait = Math.min(wait * 2, statePollMs.maxMs);
|
|
765
834
|
}
|
|
766
835
|
}
|
|
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
836
|
function healthProbe(port, windowSeconds) {
|
|
805
837
|
const booting = [...BOOTING_CONTAINER_STATES].join("|");
|
|
806
838
|
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"`;
|
|
@@ -836,22 +868,41 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
836
868
|
}
|
|
837
869
|
throw new SandboxRuntimeBootError(`Boat ${sandboxId} runtime did not become healthy: ${last}; ${diagnostic}`);
|
|
838
870
|
}
|
|
839
|
-
async function startContainer(sandboxId, input, hostId) {
|
|
871
|
+
async function startContainer(sandboxId, input, hostId, keepPreviousImage) {
|
|
840
872
|
const env = { ...bootEnv(input, hostId), ...await options.env?.(input, { id: hostId }) };
|
|
841
873
|
const boot2 = crypto.randomUUID();
|
|
842
874
|
const staged = {
|
|
843
875
|
env: `${RUNTIME_ENV_PATH}.${boot2}`,
|
|
844
876
|
...options.registryAuth ? { password: `${REGISTRY_PASSWORD_PATH}.${boot2}` } : {}
|
|
845
877
|
};
|
|
846
|
-
const
|
|
878
|
+
const image = resolveImage(input);
|
|
879
|
+
const scripts = machineBootScripts({
|
|
880
|
+
image,
|
|
881
|
+
keepPreviousImage: keepPreviousImage && image === options.image,
|
|
882
|
+
workspaceId: input.workspaceId,
|
|
883
|
+
directory: workspaceDirectory(input),
|
|
884
|
+
port: runtimePort(input),
|
|
885
|
+
runtimeCommand,
|
|
886
|
+
containerName,
|
|
887
|
+
...options.registryAuth ? { registry: { server: options.registryAuth.server, username: options.registryAuth.username } } : {},
|
|
888
|
+
staged,
|
|
889
|
+
staleStagedMinutes: BOOT_TTL_SECONDS / 60
|
|
890
|
+
});
|
|
847
891
|
await client.writeFile(sandboxId, { path: staged.env, content: envFile(env) });
|
|
848
892
|
if (options.registryAuth && staged.password) {
|
|
849
893
|
await client.writeFile(sandboxId, { path: staged.password, content: options.registryAuth.password });
|
|
850
894
|
}
|
|
851
895
|
const secrets = [...workspaceRuntimeBootSecrets(env), ...options.registryAuth ? [options.registryAuth.password] : []];
|
|
852
896
|
let failure;
|
|
897
|
+
let booted;
|
|
853
898
|
try {
|
|
854
|
-
await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets })
|
|
899
|
+
const prepared = await execOrThrow(sandboxId, scripts.prepare, "runtime image preparation", { timeoutSeconds: IMAGE_PREPARATION_TIMEOUT_SECONDS, secrets }).catch((error) => {
|
|
900
|
+
throw error instanceof BoatDriverError && error.message.includes(IMAGE_DOWNLOAD_INTERRUPTED) ? new SandboxImageDownloadError(error.message) : error;
|
|
901
|
+
});
|
|
902
|
+
booted = bootedImage(prepared.stdout);
|
|
903
|
+
if (!booted) throw new BoatDriverError(`Boat ${sandboxId} runtime image preparation named no image it booted`);
|
|
904
|
+
const skipped = keepPreviousImage ? prepared.stderr.split("\n").find((line) => line.startsWith(KEPT_IMAGE_SKIPPED)) : void 0;
|
|
905
|
+
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
906
|
try {
|
|
856
907
|
await execOrThrow(sandboxId, scripts.start, "container start", { timeoutSeconds: CONTAINER_START_TIMEOUT_SECONDS, secrets });
|
|
857
908
|
} catch (error) {
|
|
@@ -862,19 +913,19 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
862
913
|
}
|
|
863
914
|
try {
|
|
864
915
|
if (options.registryAuth || failure !== void 0) {
|
|
865
|
-
await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
|
|
916
|
+
await execOrThrow(sandboxId, `rm -f ${[staged.env, staged.password, bootedImageFile(staged)].filter(Boolean).join(" ")}`, "staged file cleanup", { secrets });
|
|
866
917
|
}
|
|
867
918
|
} catch (cleanupFailure) {
|
|
868
919
|
if (failure === void 0) throw cleanupFailure;
|
|
869
920
|
if (failure instanceof Error) failure.message = `${failure.message}; ${cleanupFailure instanceof Error ? cleanupFailure.message : String(cleanupFailure)}`;
|
|
870
921
|
}
|
|
871
|
-
if (failure !== void 0) throw failure;
|
|
872
|
-
return secrets;
|
|
922
|
+
if (failure !== void 0 || !booted) throw failure;
|
|
923
|
+
return { secrets, booted };
|
|
873
924
|
}
|
|
874
|
-
async function boot(sandboxId, input, hostId) {
|
|
925
|
+
async function boot(sandboxId, input, hostId, keepPreviousImage) {
|
|
875
926
|
await input.onMachineReady?.();
|
|
876
927
|
const port = runtimePort(input);
|
|
877
|
-
const secrets = await startContainer(sandboxId, input, hostId);
|
|
928
|
+
const { secrets, booted } = await startContainer(sandboxId, input, hostId, keepPreviousImage);
|
|
878
929
|
await input.onImageReady?.();
|
|
879
930
|
await execOrThrow(sandboxId, holdingBootLock(HOST_PUBLISH_TIMEOUT_SECONDS, `host ${port} --public`), "host publish", {
|
|
880
931
|
timeoutSeconds: HOST_PUBLISH_TIMEOUT_SECONDS,
|
|
@@ -894,7 +945,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
894
945
|
hostId,
|
|
895
946
|
driverResourceId: sandboxId,
|
|
896
947
|
driver: { id: "boat", resourceId: sandboxId },
|
|
897
|
-
labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]:
|
|
948
|
+
labels: { ...input.labels, ...resolveImage(input) === options.image ? { [SANDBOX_IMAGE_LABEL]: booted } : {} }
|
|
898
949
|
};
|
|
899
950
|
}
|
|
900
951
|
function assertNetwork(input) {
|
|
@@ -943,7 +994,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
943
994
|
if (failures.length > 1) throw new AggregateError(failures, `Boat ${created.id} resource handoff and deletion failed`);
|
|
944
995
|
const ready = await waitUntilReady(created.id);
|
|
945
996
|
if (!ready) return { provisioning: true, retryAfterMs: 0 };
|
|
946
|
-
return boot(created.id, input, hostId);
|
|
997
|
+
return boot(created.id, input, hostId, false);
|
|
947
998
|
}
|
|
948
999
|
async function resumeHost(input) {
|
|
949
1000
|
const sandboxId = input.lease.sandboxId;
|
|
@@ -963,7 +1014,7 @@ ${result.stderr}`, secrets), STDERR_TAIL_CHARS);
|
|
|
963
1014
|
}
|
|
964
1015
|
const ready = await waitUntilReady(sandboxId);
|
|
965
1016
|
if (!ready) return { provisioning: true, retryAfterMs: 0 };
|
|
966
|
-
return boot(sandboxId, input.ensure, hostId);
|
|
1017
|
+
return boot(sandboxId, input.ensure, hostId, !input.ensure.updateImage);
|
|
967
1018
|
}
|
|
968
1019
|
async function stop(target) {
|
|
969
1020
|
await client.command(target.sandboxId, {
|
package/dist/drivers/boat.d.ts
CHANGED
|
@@ -49,16 +49,4 @@ export type BoatSandboxDriverOptions = {
|
|
|
49
49
|
operationTimeoutMs?: number;
|
|
50
50
|
fetchImpl?: BoatFetch;
|
|
51
51
|
};
|
|
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
52
|
export declare function createBoatSandboxDriver(options: BoatSandboxDriverOptions): SandboxDriver;
|