rightsize 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +255 -0
- package/dist/backend-docker/backend.d.ts +53 -0
- package/dist/backend-docker/backend.d.ts.map +1 -0
- package/dist/backend-docker/backend.js +336 -0
- package/dist/backend-docker/backend.js.map +1 -0
- package/dist/backend-docker/client.d.ts +82 -0
- package/dist/backend-docker/client.d.ts.map +1 -0
- package/dist/backend-docker/client.js +190 -0
- package/dist/backend-docker/client.js.map +1 -0
- package/dist/backend-docker/frames.d.ts +44 -0
- package/dist/backend-docker/frames.d.ts.map +1 -0
- package/dist/backend-docker/frames.js +105 -0
- package/dist/backend-docker/frames.js.map +1 -0
- package/dist/backend-docker/index.d.ts +5 -0
- package/dist/backend-docker/index.d.ts.map +1 -0
- package/dist/backend-docker/index.js +20 -0
- package/dist/backend-docker/index.js.map +1 -0
- package/dist/backend-docker/json.d.ts +13 -0
- package/dist/backend-docker/json.d.ts.map +1 -0
- package/dist/backend-docker/json.js +47 -0
- package/dist/backend-docker/json.js.map +1 -0
- package/dist/backend-docker/labels.d.ts +11 -0
- package/dist/backend-docker/labels.d.ts.map +1 -0
- package/dist/backend-docker/labels.js +13 -0
- package/dist/backend-docker/labels.js.map +1 -0
- package/dist/backend-docker/port-conflict.d.ts +9 -0
- package/dist/backend-docker/port-conflict.d.ts.map +1 -0
- package/dist/backend-docker/port-conflict.js +12 -0
- package/dist/backend-docker/port-conflict.js.map +1 -0
- package/dist/backend-docker/provider.d.ts +22 -0
- package/dist/backend-docker/provider.d.ts.map +1 -0
- package/dist/backend-docker/provider.js +36 -0
- package/dist/backend-docker/provider.js.map +1 -0
- package/dist/backend-msb/backend.d.ts +169 -0
- package/dist/backend-msb/backend.d.ts.map +1 -0
- package/dist/backend-msb/backend.js +664 -0
- package/dist/backend-msb/backend.js.map +1 -0
- package/dist/backend-msb/commands.d.ts +28 -0
- package/dist/backend-msb/commands.d.ts.map +1 -0
- package/dist/backend-msb/commands.js +67 -0
- package/dist/backend-msb/commands.js.map +1 -0
- package/dist/backend-msb/exec-tunnel.d.ts +33 -0
- package/dist/backend-msb/exec-tunnel.d.ts.map +1 -0
- package/dist/backend-msb/exec-tunnel.js +163 -0
- package/dist/backend-msb/exec-tunnel.js.map +1 -0
- package/dist/backend-msb/follow-replay.d.ts +14 -0
- package/dist/backend-msb/follow-replay.d.ts.map +1 -0
- package/dist/backend-msb/follow-replay.js +20 -0
- package/dist/backend-msb/follow-replay.js.map +1 -0
- package/dist/backend-msb/image-cache.d.ts +25 -0
- package/dist/backend-msb/image-cache.d.ts.map +1 -0
- package/dist/backend-msb/image-cache.js +27 -0
- package/dist/backend-msb/image-cache.js.map +1 -0
- package/dist/backend-msb/index.d.ts +6 -0
- package/dist/backend-msb/index.d.ts.map +1 -0
- package/dist/backend-msb/index.js +21 -0
- package/dist/backend-msb/index.js.map +1 -0
- package/dist/backend-msb/invoke.d.ts +24 -0
- package/dist/backend-msb/invoke.d.ts.map +1 -0
- package/dist/backend-msb/invoke.js +73 -0
- package/dist/backend-msb/invoke.js.map +1 -0
- package/dist/backend-msb/ls-json.d.ts +23 -0
- package/dist/backend-msb/ls-json.d.ts.map +1 -0
- package/dist/backend-msb/ls-json.js +113 -0
- package/dist/backend-msb/ls-json.js.map +1 -0
- package/dist/backend-msb/network-links.d.ts +7 -0
- package/dist/backend-msb/network-links.d.ts.map +1 -0
- package/dist/backend-msb/network-links.js +31 -0
- package/dist/backend-msb/network-links.js.map +1 -0
- package/dist/backend-msb/platform.d.ts +29 -0
- package/dist/backend-msb/platform.d.ts.map +1 -0
- package/dist/backend-msb/platform.js +128 -0
- package/dist/backend-msb/platform.js.map +1 -0
- package/dist/backend-msb/port-conflict.d.ts +10 -0
- package/dist/backend-msb/port-conflict.d.ts.map +1 -0
- package/dist/backend-msb/port-conflict.js +16 -0
- package/dist/backend-msb/port-conflict.js.map +1 -0
- package/dist/backend-msb/provider.d.ts +10 -0
- package/dist/backend-msb/provider.d.ts.map +1 -0
- package/dist/backend-msb/provider.js +40 -0
- package/dist/backend-msb/provider.js.map +1 -0
- package/dist/backend-msb/provisioner.d.ts +19 -0
- package/dist/backend-msb/provisioner.d.ts.map +1 -0
- package/dist/backend-msb/provisioner.js +332 -0
- package/dist/backend-msb/provisioner.js.map +1 -0
- package/dist/backend-msb/reaper.d.ts +10 -0
- package/dist/backend-msb/reaper.d.ts.map +1 -0
- package/dist/backend-msb/reaper.js +73 -0
- package/dist/backend-msb/reaper.js.map +1 -0
- package/dist/core/backend.d.ts +98 -0
- package/dist/core/backend.d.ts.map +1 -0
- package/dist/core/backend.js +2 -0
- package/dist/core/backend.js.map +1 -0
- package/dist/core/backends.d.ts +37 -0
- package/dist/core/backends.d.ts.map +1 -0
- package/dist/core/backends.js +127 -0
- package/dist/core/backends.js.map +1 -0
- package/dist/core/cleanup.d.ts +37 -0
- package/dist/core/cleanup.d.ts.map +1 -0
- package/dist/core/cleanup.js +86 -0
- package/dist/core/cleanup.js.map +1 -0
- package/dist/core/errors.d.ts +51 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +66 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/free-ports.d.ts +23 -0
- package/dist/core/free-ports.d.ts.map +1 -0
- package/dist/core/free-ports.js +63 -0
- package/dist/core/free-ports.js.map +1 -0
- package/dist/core/generic-container.d.ts +145 -0
- package/dist/core/generic-container.d.ts.map +1 -0
- package/dist/core/generic-container.js +385 -0
- package/dist/core/generic-container.js.map +1 -0
- package/dist/core/model.d.ts +60 -0
- package/dist/core/model.d.ts.map +1 -0
- package/dist/core/model.js +2 -0
- package/dist/core/model.js.map +1 -0
- package/dist/core/mountable-file.d.ts +25 -0
- package/dist/core/mountable-file.d.ts.map +1 -0
- package/dist/core/mountable-file.js +64 -0
- package/dist/core/mountable-file.js.map +1 -0
- package/dist/core/network.d.ts +58 -0
- package/dist/core/network.d.ts.map +1 -0
- package/dist/core/network.js +84 -0
- package/dist/core/network.js.map +1 -0
- package/dist/core/run-id.d.ts +18 -0
- package/dist/core/run-id.d.ts.map +1 -0
- package/dist/core/run-id.js +18 -0
- package/dist/core/run-id.js.map +1 -0
- package/dist/core/wait.d.ts +59 -0
- package/dist/core/wait.d.ts.map +1 -0
- package/dist/core/wait.js +239 -0
- package/dist/core/wait.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/arango.d.ts +14 -0
- package/dist/modules/arango.d.ts.map +1 -0
- package/dist/modules/arango.js +37 -0
- package/dist/modules/arango.js.map +1 -0
- package/dist/modules/clickhouse.d.ts +37 -0
- package/dist/modules/clickhouse.d.ts.map +1 -0
- package/dist/modules/clickhouse.js +69 -0
- package/dist/modules/clickhouse.js.map +1 -0
- package/dist/modules/flink.d.ts +65 -0
- package/dist/modules/flink.d.ts.map +1 -0
- package/dist/modules/flink.js +125 -0
- package/dist/modules/flink.js.map +1 -0
- package/dist/modules/floci.d.ts +46 -0
- package/dist/modules/floci.d.ts.map +1 -0
- package/dist/modules/floci.js +62 -0
- package/dist/modules/floci.js.map +1 -0
- package/dist/modules/index.d.ts +31 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +31 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/modules/kafka.d.ts +11 -0
- package/dist/modules/kafka.d.ts.map +1 -0
- package/dist/modules/kafka.js +41 -0
- package/dist/modules/kafka.js.map +1 -0
- package/dist/modules/keycloak.d.ts +40 -0
- package/dist/modules/keycloak.d.ts.map +1 -0
- package/dist/modules/keycloak.js +67 -0
- package/dist/modules/keycloak.js.map +1 -0
- package/dist/modules/mariadb.d.ts +56 -0
- package/dist/modules/mariadb.d.ts.map +1 -0
- package/dist/modules/mariadb.js +89 -0
- package/dist/modules/mariadb.js.map +1 -0
- package/dist/modules/memcached.d.ts +24 -0
- package/dist/modules/memcached.d.ts.map +1 -0
- package/dist/modules/memcached.js +105 -0
- package/dist/modules/memcached.js.map +1 -0
- package/dist/modules/mongodb.d.ts +19 -0
- package/dist/modules/mongodb.d.ts.map +1 -0
- package/dist/modules/mongodb.js +69 -0
- package/dist/modules/mongodb.js.map +1 -0
- package/dist/modules/mysql.d.ts +61 -0
- package/dist/modules/mysql.d.ts.map +1 -0
- package/dist/modules/mysql.js +101 -0
- package/dist/modules/mysql.js.map +1 -0
- package/dist/modules/neo4j.d.ts +60 -0
- package/dist/modules/neo4j.d.ts.map +1 -0
- package/dist/modules/neo4j.js +83 -0
- package/dist/modules/neo4j.js.map +1 -0
- package/dist/modules/pinot.d.ts +30 -0
- package/dist/modules/pinot.d.ts.map +1 -0
- package/dist/modules/pinot.js +49 -0
- package/dist/modules/pinot.js.map +1 -0
- package/dist/modules/postgres.d.ts +29 -0
- package/dist/modules/postgres.d.ts.map +1 -0
- package/dist/modules/postgres.js +73 -0
- package/dist/modules/postgres.js.map +1 -0
- package/dist/modules/rabbitmq.d.ts +46 -0
- package/dist/modules/rabbitmq.d.ts.map +1 -0
- package/dist/modules/rabbitmq.js +68 -0
- package/dist/modules/rabbitmq.js.map +1 -0
- package/dist/modules/redis.d.ts +15 -0
- package/dist/modules/redis.d.ts.map +1 -0
- package/dist/modules/redis.js +24 -0
- package/dist/modules/redis.js.map +1 -0
- package/dist/modules/redpanda.d.ts +27 -0
- package/dist/modules/redpanda.d.ts.map +1 -0
- package/dist/modules/redpanda.js +58 -0
- package/dist/modules/redpanda.js.map +1 -0
- package/dist/modules/spring-cloud-config.d.ts +18 -0
- package/dist/modules/spring-cloud-config.d.ts.map +1 -0
- package/dist/modules/spring-cloud-config.js +28 -0
- package/dist/modules/spring-cloud-config.js.map +1 -0
- package/dist/modules/wiremock.d.ts +21 -0
- package/dist/modules/wiremock.d.ts.map +1 -0
- package/dist/modules/wiremock.js +32 -0
- package/dist/modules/wiremock.js.map +1 -0
- package/package.json +104 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import * as readline from "node:readline";
|
|
3
|
+
import { RunId } from "../core/run-id.js";
|
|
4
|
+
import { BackendError, PortBindConflictError, UnsupportedByBackendError } from "../core/errors.js";
|
|
5
|
+
import { MsbCommands } from "./commands.js";
|
|
6
|
+
import { runningNames } from "./ls-json.js";
|
|
7
|
+
import { invoke, CLOSED_STDIN } from "./invoke.js";
|
|
8
|
+
import { isPortBindConflictOutput } from "./port-conflict.js";
|
|
9
|
+
import { isImageCacheCorruption } from "./image-cache.js";
|
|
10
|
+
import { orphanNames } from "./reaper.js";
|
|
11
|
+
import { undeliveredLines } from "./follow-replay.js";
|
|
12
|
+
import { requireNoDuplicateGuestPorts, requireAliasesAreValid, hostsAliasScript } from "./network-links.js";
|
|
13
|
+
import { ExecTunnel } from "./exec-tunnel.js";
|
|
14
|
+
const FIRST_RUN_PULL_TIMEOUT_MS = 600_000; // a cold pull can be slow
|
|
15
|
+
const READINESS_POLL_MS = 300;
|
|
16
|
+
const STOP_TIMEOUT_MS = 60_000;
|
|
17
|
+
const EXEC_TIMEOUT_MS = 120_000;
|
|
18
|
+
const LOGS_TIMEOUT_MS = 30_000;
|
|
19
|
+
const ATTACHED_PROC_STOP_TIMEOUT_MS = 10_000;
|
|
20
|
+
const TAIL_LINES = 50;
|
|
21
|
+
// How long the Windows polling follower's terminal fetch keeps retrying an
|
|
22
|
+
// `msb logs` invocation that itself keeps failing, once the sandbox is
|
|
23
|
+
// already confirmed no longer Running. Never a wait-for-content budget: a
|
|
24
|
+
// stopped sandbox's log cannot grow, so the first successful fetch is final.
|
|
25
|
+
const TERMINAL_FETCH_FAILURE_BUDGET_MS = 10_000;
|
|
26
|
+
/**
|
|
27
|
+
* The one boot failure `start()` heals and retries — carries the `msb run`
|
|
28
|
+
* child's combined output for the second-failure diagnostic. Internal to the
|
|
29
|
+
* boot path: never escapes `start()`, which converts a repeat failure into a
|
|
30
|
+
* `BackendError` naming the heal.
|
|
31
|
+
*/
|
|
32
|
+
class ImageCacheCorruptionError extends Error {
|
|
33
|
+
output;
|
|
34
|
+
constructor(output) {
|
|
35
|
+
super(`msb image cache corruption:\n${output}`);
|
|
36
|
+
this.output = output;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetches one msb invocation's stdout byte-exact (CRLF normalized to LF, but
|
|
41
|
+
* the presence or absence of a trailing newline preserved), unlike `invoke`,
|
|
42
|
+
* whose line-based reconstruction appends a trailing newline to any non-empty
|
|
43
|
+
* output — erasing exactly the signal the Windows polling follower's
|
|
44
|
+
* mid-write holdback keys on (an unterminated tail may have been read
|
|
45
|
+
* mid-write; a newline-terminated one is complete). Rejects on spawn failure,
|
|
46
|
+
* timeout, or a non-zero exit: msb's own internal errors print to stderr and
|
|
47
|
+
* exit non-zero with EMPTY stdout, indistinguishable from a genuinely-empty
|
|
48
|
+
* response unless the exit code is checked.
|
|
49
|
+
*/
|
|
50
|
+
function fetchStdoutExact(msbPath, args, timeoutMs) {
|
|
51
|
+
return new Promise((resolveFetch, rejectFetch) => {
|
|
52
|
+
const child = spawn(msbPath, args, { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
|
|
53
|
+
let stdout = "";
|
|
54
|
+
let stderr = "";
|
|
55
|
+
child.stdout.setEncoding("utf8");
|
|
56
|
+
child.stderr.setEncoding("utf8");
|
|
57
|
+
child.stdout.on("data", (chunk) => {
|
|
58
|
+
stdout += chunk;
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on("data", (chunk) => {
|
|
61
|
+
stderr += chunk;
|
|
62
|
+
});
|
|
63
|
+
let settled = false;
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
if (settled) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
settled = true;
|
|
69
|
+
child.kill("SIGKILL");
|
|
70
|
+
rejectFetch(new BackendError(`msb ${args.join(" ")} timed out after ${timeoutMs}ms and was force-killed`));
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
child.once("error", (err) => {
|
|
73
|
+
if (settled) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
rejectFetch(new BackendError(`failed to spawn 'msb ${args.join(" ")}': ${err.message}`));
|
|
79
|
+
});
|
|
80
|
+
// "close" (not "exit") so both stdio streams have fully flushed before
|
|
81
|
+
// the accumulated text is read.
|
|
82
|
+
child.once("close", (code) => {
|
|
83
|
+
if (settled) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
settled = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
if (code !== 0) {
|
|
89
|
+
rejectFetch(new BackendError(`msb ${args.join(" ")} exited ${code ?? "unknown"}: ${stderr.trim()}`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
resolveFetch(stdout.replace(/\r\n/g, "\n"));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Renders a heal attempt's outcome for the second-failure message — the
|
|
97
|
+
* heal's own failure (e.g. "image not found") is itself informative to
|
|
98
|
+
* whoever reads the error. */
|
|
99
|
+
function describeHeal(heal) {
|
|
100
|
+
if (heal !== null && typeof heal === "object" && "exitCode" in heal) {
|
|
101
|
+
const result = heal;
|
|
102
|
+
return result.exitCode === 0
|
|
103
|
+
? "removed"
|
|
104
|
+
: `'msb image remove' exited ${result.exitCode}: ${result.stderr.trim()}`;
|
|
105
|
+
}
|
|
106
|
+
return `'msb image remove' itself failed to run: ${heal instanceof Error ? heal.message : String(heal)}`;
|
|
107
|
+
}
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Drains a child's merged stdout+stderr line-by-line into `onLine`, keeping
|
|
113
|
+
* no more than `TAIL_LINES` for diagnostics. Returns a promise that settles
|
|
114
|
+
* once the stream ends, so callers can await "everything this child will
|
|
115
|
+
* ever print has been seen" without polling.
|
|
116
|
+
*/
|
|
117
|
+
function drainTail(stream, tail) {
|
|
118
|
+
return new Promise((resolveDrain) => {
|
|
119
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
120
|
+
rl.on("line", (line) => {
|
|
121
|
+
tail.push(line);
|
|
122
|
+
if (tail.length > TAIL_LINES) {
|
|
123
|
+
tail.shift();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
rl.on("close", () => resolveDrain());
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The attached-mode CLI driver: every sandbox this backend starts runs as a
|
|
131
|
+
* held child process (`msb run`, no `-d`) because detached mode never
|
|
132
|
+
* executes the image's own ENTRYPOINT/CMD — only attached mode does.
|
|
133
|
+
* Readiness is never inferred from that child's own output; it comes from
|
|
134
|
+
* polling `msb ls --format json` until the name shows `"Running"`. The
|
|
135
|
+
* attached child's stdout/stderr carries msb's own boot diagnostics and is
|
|
136
|
+
* kept only for pre-Running failure messages — it is not a dependable
|
|
137
|
+
* workload-log source (on Windows it does not relay guest stdout at all);
|
|
138
|
+
* workload logs are always fetched through the `msb logs` channel.
|
|
139
|
+
*
|
|
140
|
+
* `create()` on the `BackendProvider` interface is synchronous, but locating
|
|
141
|
+
* (and possibly downloading) the pinned `msb` binary is inherently async.
|
|
142
|
+
* The seam: this backend holds a memoized `ensureInstalled()` promise and
|
|
143
|
+
* every method awaits it once before doing anything else, so construction
|
|
144
|
+
* stays cheap and the actual provisioning happens lazily on first use.
|
|
145
|
+
*/
|
|
146
|
+
export class MsbCliBackend {
|
|
147
|
+
msbPathPromise;
|
|
148
|
+
/** `"microsandbox"` — matched against `RIGHTSIZE_BACKEND` and used in `dev.rightsize.runId`-style diagnostics. */
|
|
149
|
+
name = "microsandbox";
|
|
150
|
+
supportsNativeNetworks = false;
|
|
151
|
+
handles = new Map();
|
|
152
|
+
startedNames = new Set();
|
|
153
|
+
// Mirrors msbPathPromise's eventual value as soon as it settles, purely so
|
|
154
|
+
// cleanupSync (the synchronous process-exit path, which cannot await
|
|
155
|
+
// anything) has a best-effort synchronous read of it. Never written to
|
|
156
|
+
// except by this one .then() below; never awaited anywhere else.
|
|
157
|
+
resolvedMsbPath;
|
|
158
|
+
constructor(msbPathPromise) {
|
|
159
|
+
this.msbPathPromise = msbPathPromise;
|
|
160
|
+
this.msbPathPromise.then((p) => {
|
|
161
|
+
this.resolvedMsbPath = p;
|
|
162
|
+
}, () => {
|
|
163
|
+
// Provisioning failed: cleanupSync has nothing to clean up with
|
|
164
|
+
// either, and every other method will surface the same rejection
|
|
165
|
+
// when it awaits msbPathPromise itself.
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
async msbPath() {
|
|
169
|
+
return this.msbPathPromise;
|
|
170
|
+
}
|
|
171
|
+
async create(spec) {
|
|
172
|
+
this.handles.set(spec.name, { attached: undefined, attachedExited: false, resources: [], logTail: [] });
|
|
173
|
+
return { id: spec.name, spec };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Boots via `bootOnce`; on a first failure carrying msb's image-cache-
|
|
177
|
+
* corruption signature (see `isImageCacheCorruption`), heals by removing
|
|
178
|
+
* just the affected image's cache entry (`msb image remove <image>`, result
|
|
179
|
+
* ignored — including "image not found", since the real signal is whether
|
|
180
|
+
* the retried boot succeeds, not whether removal reported success) and
|
|
181
|
+
* retries the boot exactly once. A second identical failure surfaces an
|
|
182
|
+
* error naming the image and the attempted heal instead of retrying
|
|
183
|
+
* further. The heal is scoped to the one image reference — never the whole
|
|
184
|
+
* cache directory, and never any sandbox state.
|
|
185
|
+
*
|
|
186
|
+
* The failed first attempt never reached Running, so `state.attached` and
|
|
187
|
+
* `startedNames` (both populated only on success, inside `bootOnce`) carry
|
|
188
|
+
* no state from it to double-register, and its child has already been
|
|
189
|
+
* reaped there.
|
|
190
|
+
*
|
|
191
|
+
* Two corruption shapes were found empirically and the same one command
|
|
192
|
+
* heals both: the failing image's manifest was never committed to msb's
|
|
193
|
+
* cache database (a concurrent pull lost the race for a shared base layer
|
|
194
|
+
* before its own manifest write landed) — `image remove` reports "image not
|
|
195
|
+
* found" and the retry succeeds anyway, because by then the concurrent
|
|
196
|
+
* winner has finished materializing the shared layer — or the manifest IS
|
|
197
|
+
* committed but the cache file backing one of its layers is gone, where
|
|
198
|
+
* `image remove` clears the stale entry and the retry re-pulls from
|
|
199
|
+
* scratch.
|
|
200
|
+
*/
|
|
201
|
+
async start(handle) {
|
|
202
|
+
const msbPath = await this.msbPath();
|
|
203
|
+
const state = this.handles.get(handle.id);
|
|
204
|
+
if (state === undefined) {
|
|
205
|
+
throw new BackendError(`no handle state for sandbox '${handle.id}' — create() was never called for it`);
|
|
206
|
+
}
|
|
207
|
+
let firstOutput;
|
|
208
|
+
try {
|
|
209
|
+
await this.bootOnce(msbPath, handle, state);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
catch (first) {
|
|
213
|
+
if (!(first instanceof ImageCacheCorruptionError)) {
|
|
214
|
+
throw first;
|
|
215
|
+
}
|
|
216
|
+
firstOutput = first.output;
|
|
217
|
+
}
|
|
218
|
+
const heal = await invoke(msbPath, MsbCommands.imageRemove(handle.spec.image), STOP_TIMEOUT_MS).catch((e) => e);
|
|
219
|
+
try {
|
|
220
|
+
await this.bootOnce(msbPath, handle, state);
|
|
221
|
+
}
|
|
222
|
+
catch (second) {
|
|
223
|
+
if (!(second instanceof ImageCacheCorruptionError)) {
|
|
224
|
+
throw second;
|
|
225
|
+
}
|
|
226
|
+
throw new BackendError(`msb run for sandbox ${handle.id} hit its image cache error twice in a row for image ` +
|
|
227
|
+
`'${handle.spec.image}', even after removing that image's cache entry (${describeHeal(heal)}) ` +
|
|
228
|
+
`and retrying — this is likely a deeper cache corruption than this backend's one-shot heal ` +
|
|
229
|
+
`covers; try clearing the msb image cache by hand ('msb image prune' or removing the cache ` +
|
|
230
|
+
`directory under MSB_HOME).\nfirst attempt:\n${firstOutput}\nafter heal + retry:\n${second.output}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One boot attempt: spawns the attached `msb run` child and polls until the
|
|
235
|
+
* sandbox reaches Running. `state.attached` and `startedNames` are
|
|
236
|
+
* populated only on success; on any failure the child is reaped here (for
|
|
237
|
+
* the classified early-exit failures it has already exited; a readiness
|
|
238
|
+
* timeout leaves it alive and it is hard-killed) so a failed attempt leaves
|
|
239
|
+
* no live process or registered cleanup state behind — the caller owns
|
|
240
|
+
* retry policy, never cleanup. An early exit is classified from the child's
|
|
241
|
+
* combined output: the image-cache-corruption signature throws
|
|
242
|
+
* `ImageCacheCorruptionError` (the one failure `start()` heals and
|
|
243
|
+
* retries), a host-port bind conflict throws `PortBindConflictError`, and
|
|
244
|
+
* anything else surfaces the raw output.
|
|
245
|
+
*/
|
|
246
|
+
async bootOnce(msbPath, handle, state) {
|
|
247
|
+
// Fresh per-attempt diagnostics: a retried boot must not blend its tail
|
|
248
|
+
// with the failed attempt's.
|
|
249
|
+
state.logTail = [];
|
|
250
|
+
state.attachedExited = false;
|
|
251
|
+
const child = spawn(msbPath, MsbCommands.run(handle.spec), { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
|
|
252
|
+
// Merge stdout+stderr into one tail, kept only for the boot diagnostics
|
|
253
|
+
// below: this pipe is the sole carrier of msb's own output (registry/pull
|
|
254
|
+
// errors, crash output printed before the sandbox exists). It is not a
|
|
255
|
+
// dependable workload-log source — on Windows the attached process does
|
|
256
|
+
// not relay guest stdout at all — so logs() never reads it; workload
|
|
257
|
+
// output always comes from a `msb logs` invocation.
|
|
258
|
+
const stdoutDone = drainTail(child.stdout, state.logTail);
|
|
259
|
+
const stderrDone = drainTail(child.stderr, state.logTail);
|
|
260
|
+
let exited;
|
|
261
|
+
child.once("exit", (code) => {
|
|
262
|
+
exited = { code };
|
|
263
|
+
state.attachedExited = true;
|
|
264
|
+
});
|
|
265
|
+
const deadline = Date.now() + FIRST_RUN_PULL_TIMEOUT_MS;
|
|
266
|
+
for (;;) {
|
|
267
|
+
if (exited !== undefined) {
|
|
268
|
+
await Promise.all([stdoutDone, stderrDone]);
|
|
269
|
+
const output = state.logTail.join("\n");
|
|
270
|
+
if (isImageCacheCorruption(output)) {
|
|
271
|
+
throw new ImageCacheCorruptionError(output);
|
|
272
|
+
}
|
|
273
|
+
if (isPortBindConflictOutput(output)) {
|
|
274
|
+
throw new PortBindConflictError(`msb run for sandbox ${handle.id} could not bind a host port: ${output}`);
|
|
275
|
+
}
|
|
276
|
+
throw new BackendError(`msb run for sandbox ${handle.id} exited (code ${exited.code ?? "unknown"}) before reaching ` +
|
|
277
|
+
`Running — check the image entrypoint and 'msb run' output below:\n${output}`);
|
|
278
|
+
}
|
|
279
|
+
if ((await this.runningSandboxNames(msbPath)).has(handle.id)) {
|
|
280
|
+
state.attached = child;
|
|
281
|
+
this.startedNames.add(handle.id);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (Date.now() >= deadline) {
|
|
285
|
+
child.kill("SIGKILL");
|
|
286
|
+
throw new BackendError(`Sandbox ${handle.id} did not reach Running within ${FIRST_RUN_PULL_TIMEOUT_MS / 1000}s — this ` +
|
|
287
|
+
`can mean a slow image pull, a crash-looping entrypoint, or msb itself being unresponsive; ` +
|
|
288
|
+
`last output:\n${state.logTail.join("\n")}`);
|
|
289
|
+
}
|
|
290
|
+
await sleep(READINESS_POLL_MS);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async stop(handle) {
|
|
294
|
+
const msbPath = await this.msbPath();
|
|
295
|
+
const state = this.handles.get(handle.id);
|
|
296
|
+
if (state !== undefined) {
|
|
297
|
+
for (const tunnel of state.resources) {
|
|
298
|
+
await tunnel.close().catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
state.resources = [];
|
|
301
|
+
}
|
|
302
|
+
await invoke(msbPath, MsbCommands.stop(handle.id), STOP_TIMEOUT_MS).catch(() => { });
|
|
303
|
+
const attached = state?.attached;
|
|
304
|
+
// The attached `msb run` child is msb's own supervisor for this sandbox:
|
|
305
|
+
// it stays alive for the sandbox's entire lifetime and only exits once
|
|
306
|
+
// the `msb stop` call just above lands, so the common path here is
|
|
307
|
+
// "attach a listener, then observe the exit that our own stop just
|
|
308
|
+
// caused." state.attachedExited exists for the other case: if the child
|
|
309
|
+
// had already died before this method ever ran (crashed, or killed by
|
|
310
|
+
// something external), start()'s own listener already flipped it, and
|
|
311
|
+
// Node never replays a past "exit" event to a listener attached after
|
|
312
|
+
// the fact — without this check, stop() would attach a listener for an
|
|
313
|
+
// event that will never come and wait out the full timeout before
|
|
314
|
+
// falling back to SIGKILL.
|
|
315
|
+
//
|
|
316
|
+
// `attached.kill("SIGKILL")` below is the escalation path, not the
|
|
317
|
+
// graceful one — the graceful stop is the `msb stop` invocation above,
|
|
318
|
+
// which already ran and quiesced the sandbox before this ever fires. On
|
|
319
|
+
// Windows, `ChildProcess.kill()` ignores the signal name entirely and
|
|
320
|
+
// always calls `TerminateProcess` (Windows has no real signal delivery
|
|
321
|
+
// at the Node level), so this escalation is already a hard terminate
|
|
322
|
+
// there regardless of which signal string is passed; nothing about the
|
|
323
|
+
// ordering above needs to change because the graceful step was already
|
|
324
|
+
// the `msb stop` call, not a signal to this child.
|
|
325
|
+
if (attached !== undefined && state !== undefined && !state.attachedExited) {
|
|
326
|
+
const exitedInTime = await new Promise((resolveWait) => {
|
|
327
|
+
const timer = setTimeout(() => resolveWait(false), ATTACHED_PROC_STOP_TIMEOUT_MS);
|
|
328
|
+
attached.once("exit", () => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
resolveWait(true);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
if (!exitedInTime) {
|
|
334
|
+
attached.kill("SIGKILL");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (state !== undefined) {
|
|
338
|
+
state.attached = undefined;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async remove(handle) {
|
|
342
|
+
const msbPath = await this.msbPath();
|
|
343
|
+
await invoke(msbPath, MsbCommands.rm(handle.id), STOP_TIMEOUT_MS).catch(() => { });
|
|
344
|
+
this.startedNames.delete(handle.id);
|
|
345
|
+
this.handles.delete(handle.id);
|
|
346
|
+
}
|
|
347
|
+
async runningSandboxNames(msbPath) {
|
|
348
|
+
const result = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
|
|
349
|
+
return runningNames(result.stdout);
|
|
350
|
+
}
|
|
351
|
+
async exec(handle, cmd) {
|
|
352
|
+
const msbPath = await this.msbPath();
|
|
353
|
+
return invoke(msbPath, MsbCommands.exec(handle.id, cmd), EXEC_TIMEOUT_MS);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A fresh `msb logs <name> --tail 1000` invocation, same on every platform.
|
|
357
|
+
* This is the workload's own output, as distinct from the attached
|
|
358
|
+
* `msb run` child's pipe (drained in start() into a tail kept only for
|
|
359
|
+
* pre-Running crash diagnostics): on Windows the attached process does not
|
|
360
|
+
* relay guest stdout at all, while `msb logs` does everywhere, so this is
|
|
361
|
+
* the only channel this method can source from. Never rejects on a
|
|
362
|
+
* missing/removed sandbox — invoke() only rejects on spawn failure or
|
|
363
|
+
* timeout, never on exit code, so a failing `msb logs` call resolves with
|
|
364
|
+
* whatever (possibly empty) stdout it produced.
|
|
365
|
+
*/
|
|
366
|
+
async logs(handle) {
|
|
367
|
+
const msbPath = await this.msbPath();
|
|
368
|
+
return (await invoke(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS)).stdout;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* `msb logs -f` never exits once the sandbox stops (confirmed against the
|
|
372
|
+
* real msb binary — it blocks on read forever instead of the documented
|
|
373
|
+
* clean exit). A watchdog polls `msb ls` in the background; the instant
|
|
374
|
+
* the sandbox leaves Running it quiesces the stuck follow child FIRST
|
|
375
|
+
* (kill it, wait for the reader to finish draining whatever was already
|
|
376
|
+
* buffered) so `delivered` reflects everything the live stream will ever
|
|
377
|
+
* produce, THEN does one authoritative non-follow `msb logs` fetch and
|
|
378
|
+
* replays only the lines after `delivered` — guarded so that replay can
|
|
379
|
+
* only ever happen once. An explicit `close()` never triggers a replay:
|
|
380
|
+
* closing means the caller asked delivery to stop, not "catch me up."
|
|
381
|
+
*
|
|
382
|
+
* On Windows hosts this routes to `followLogsByPolling` instead: there,
|
|
383
|
+
* `msb logs -f` stays alive for the sandbox's whole run but never relays a
|
|
384
|
+
* single line to its stdout pipe while the sandbox is Running (confirmed
|
|
385
|
+
* against the real binary on a hosted windows-2025 runner — the same lines
|
|
386
|
+
* are retrievable through non-follow `msb logs` the whole time), so a
|
|
387
|
+
* pipe-reading follow child can never deliver a live line on Windows.
|
|
388
|
+
*/
|
|
389
|
+
async followLogs(handle, consumer) {
|
|
390
|
+
const msbPath = await this.msbPath();
|
|
391
|
+
if (process.platform === "win32") {
|
|
392
|
+
return this.followLogsByPolling(msbPath, handle, consumer);
|
|
393
|
+
}
|
|
394
|
+
const child = spawn(msbPath, MsbCommands.followLogs(handle.id), { stdio: [CLOSED_STDIN, "pipe", "pipe"] });
|
|
395
|
+
let delivered = 0;
|
|
396
|
+
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
397
|
+
const readerDone = new Promise((resolveReader) => {
|
|
398
|
+
rl.on("line", (line) => {
|
|
399
|
+
delivered++;
|
|
400
|
+
consumer(line);
|
|
401
|
+
});
|
|
402
|
+
rl.on("close", () => resolveReader());
|
|
403
|
+
});
|
|
404
|
+
// stderr of `logs -f` carries no separate signal this backend acts on;
|
|
405
|
+
// drain it so the pipe never backs up and stalls the child.
|
|
406
|
+
child.stderr.resume();
|
|
407
|
+
let flushed = false;
|
|
408
|
+
let closeRequested = false;
|
|
409
|
+
let watchdogDone;
|
|
410
|
+
const flushTailOnce = async () => {
|
|
411
|
+
child.kill();
|
|
412
|
+
await readerDone;
|
|
413
|
+
if (flushed) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
flushed = true;
|
|
417
|
+
const full = (await invoke(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS)).stdout;
|
|
418
|
+
for (const line of undeliveredLines(full, delivered)) {
|
|
419
|
+
consumer(line);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const runWatchdog = async () => {
|
|
423
|
+
while (!child.killed && !closeRequested) {
|
|
424
|
+
const names = await this.runningSandboxNames(msbPath).catch(() => new Set());
|
|
425
|
+
if (!names.has(handle.id)) {
|
|
426
|
+
await flushTailOnce();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await sleep(READINESS_POLL_MS);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
watchdogDone = runWatchdog();
|
|
433
|
+
return {
|
|
434
|
+
close: async () => {
|
|
435
|
+
closeRequested = true;
|
|
436
|
+
child.kill();
|
|
437
|
+
await readerDone;
|
|
438
|
+
await watchdogDone;
|
|
439
|
+
// Deliberately no flushTailOnce() call here: an explicit close means
|
|
440
|
+
// "stop delivery," never "catch me up." If the sandbox had already
|
|
441
|
+
// left Running before close() was called, the watchdog's own flush
|
|
442
|
+
// already ran and this is a no-op by way of the `flushed` guard.
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Windows follow-logs path: no follow child at all. One async loop polls
|
|
448
|
+
* the non-follow `msb logs` fetch and delivers each fetch's not-yet-
|
|
449
|
+
* delivered lines, tracked by a monotonic `delivered` index — the same
|
|
450
|
+
* index-based diffing the POSIX watchdog's one-shot replay uses, made
|
|
451
|
+
* continuous. Delivery contract is identical to the POSIX path: in order,
|
|
452
|
+
* each line at most once, nothing after `close()`.
|
|
453
|
+
*
|
|
454
|
+
* A fetch's last line is held back only while the sandbox is Running AND
|
|
455
|
+
* the fetched text does not end with a newline: an unterminated tail may
|
|
456
|
+
* have been read mid-write, and delivering it early would split one
|
|
457
|
+
* workload line into two deliveries (the next fetch's index-diff would then
|
|
458
|
+
* skip its completed form). A newline-terminated tail is complete and
|
|
459
|
+
* delivers immediately — a workload that trickles complete lines while
|
|
460
|
+
* staying alive must see each line arrive live, not stall behind a
|
|
461
|
+
* holdback. Once the sandbox leaves Running, a final fetch (see
|
|
462
|
+
* `deliverTerminalTail`) delivers everything outstanding, including a
|
|
463
|
+
* trailing unterminated line.
|
|
464
|
+
*
|
|
465
|
+
* Every msb invocation this poller makes runs to completion strictly before
|
|
466
|
+
* the next one starts — exactly one in-flight child at a time from this
|
|
467
|
+
* code path. A failed `msb ls`/`msb logs` invocation — spawn error,
|
|
468
|
+
* timeout, or msb exiting non-zero (msb's own internal errors print to
|
|
469
|
+
* stderr and exit non-zero with EMPTY stdout, indistinguishable from a
|
|
470
|
+
* genuinely-empty response unless the exit code is checked) — is never read
|
|
471
|
+
* as "the sandbox stopped" or "the log is empty"; it retries.
|
|
472
|
+
*/
|
|
473
|
+
followLogsByPolling(msbPath, handle, consumer) {
|
|
474
|
+
let closeRequested = false;
|
|
475
|
+
const pollerDone = (async () => {
|
|
476
|
+
let delivered = 0;
|
|
477
|
+
for (;;) {
|
|
478
|
+
if (closeRequested) {
|
|
479
|
+
return; // An explicit close never triggers delivery of anything new.
|
|
480
|
+
}
|
|
481
|
+
let running;
|
|
482
|
+
try {
|
|
483
|
+
const ls = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
|
|
484
|
+
if (ls.exitCode !== 0) {
|
|
485
|
+
throw new BackendError(`msb ls exited ${ls.exitCode}: ${ls.stderr.trim()}`);
|
|
486
|
+
}
|
|
487
|
+
running = runningNames(ls.stdout).has(handle.id);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
await sleep(READINESS_POLL_MS);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (!running) {
|
|
494
|
+
await this.deliverTerminalTail(msbPath, handle.id, delivered, consumer, () => closeRequested);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
let full;
|
|
498
|
+
try {
|
|
499
|
+
full = await fetchStdoutExact(msbPath, MsbCommands.logs(handle.id), LOGS_TIMEOUT_MS);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
await sleep(READINESS_POLL_MS);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const lines = undeliveredLines(full, 0);
|
|
506
|
+
// msb's Windows log store can briefly show a single empty line before
|
|
507
|
+
// the workload's first real output lands, and that phantom line is
|
|
508
|
+
// REPLACED by the real content on a later read rather than prepended
|
|
509
|
+
// to it (observed against the real binary on a hosted windows-2025
|
|
510
|
+
// runner: delivering it permanently shifted the index-diff by one and
|
|
511
|
+
// swallowed the first real line in every follow). Until something
|
|
512
|
+
// non-empty has been delivered, an all-empty snapshot is "no content
|
|
513
|
+
// yet" — a workload's genuine interior blank lines are unaffected,
|
|
514
|
+
// since they arrive inside content-bearing snapshots.
|
|
515
|
+
if (delivered === 0 && lines.every((l) => l === "")) {
|
|
516
|
+
await sleep(READINESS_POLL_MS);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const lastLineMayBeMidWrite = full.length > 0 && !full.endsWith("\n");
|
|
520
|
+
const deliverable = lastLineMayBeMidWrite ? Math.max(delivered, lines.length - 1) : lines.length;
|
|
521
|
+
for (let i = delivered; i < deliverable; i++) {
|
|
522
|
+
if (closeRequested) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
consumer(lines[i]);
|
|
526
|
+
}
|
|
527
|
+
delivered = Math.max(delivered, deliverable);
|
|
528
|
+
await sleep(READINESS_POLL_MS);
|
|
529
|
+
}
|
|
530
|
+
})();
|
|
531
|
+
return {
|
|
532
|
+
close: async () => {
|
|
533
|
+
closeRequested = true;
|
|
534
|
+
await pollerDone;
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Delivers everything outstanding once the sandbox is confirmed no longer
|
|
540
|
+
* Running: retries the `msb logs` fetch only while it keeps failing to
|
|
541
|
+
* invoke at all (bounded by `TERMINAL_FETCH_FAILURE_BUDGET_MS`), and
|
|
542
|
+
* delivers from the very first successful fetch — withholding nothing,
|
|
543
|
+
* since a stopped sandbox's log cannot grow, so there is no more mid-write
|
|
544
|
+
* risk. This is the one place a trailing unterminated line reaches the
|
|
545
|
+
* consumer on Windows.
|
|
546
|
+
*/
|
|
547
|
+
async deliverTerminalTail(msbPath, id, delivered, consumer, isCloseRequested) {
|
|
548
|
+
const deadline = Date.now() + TERMINAL_FETCH_FAILURE_BUDGET_MS;
|
|
549
|
+
let full = "";
|
|
550
|
+
for (;;) {
|
|
551
|
+
try {
|
|
552
|
+
full = await fetchStdoutExact(msbPath, MsbCommands.logs(id), LOGS_TIMEOUT_MS);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
if (isCloseRequested() || Date.now() >= deadline) {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
await sleep(READINESS_POLL_MS);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// The same phantom-empty-line guard the live poll loop applies (see
|
|
563
|
+
// `followLogsByPolling`): a store still showing only empty lines when
|
|
564
|
+
// nothing was ever delivered means the workload produced no output, not
|
|
565
|
+
// that it printed blanks.
|
|
566
|
+
if (delivered === 0 && undeliveredLines(full, 0).every((l) => l === "")) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
for (const line of undeliveredLines(full, delivered)) {
|
|
570
|
+
if (isCloseRequested()) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
consumer(line);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async ensureNetwork(_networkId) {
|
|
577
|
+
// Emulated via the host gateway; there is no native network object to create.
|
|
578
|
+
}
|
|
579
|
+
async removeNetwork(_networkId) {
|
|
580
|
+
// Nothing was created for ensureNetwork(); nothing to remove.
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Networks are emulated because there is no bridge/subnet the current
|
|
584
|
+
* msb exposes on macOS — the only data path into a running sandbox is the
|
|
585
|
+
* exec channel. Four concerns, each its own guard: reject duplicate guest
|
|
586
|
+
* ports, validate every alias (they get shell-interpolated), probe for
|
|
587
|
+
* `nc`, then install `/etc/hosts` aliases and spawn one tunnel per link.
|
|
588
|
+
*/
|
|
589
|
+
async installNetworkLinks(handle, links) {
|
|
590
|
+
if (links.length === 0) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const msbPath = await this.msbPath();
|
|
594
|
+
requireNoDuplicateGuestPorts(links);
|
|
595
|
+
requireAliasesAreValid(links);
|
|
596
|
+
const ncProbe = await this.exec(handle, ["sh", "-c", "command -v nc"]);
|
|
597
|
+
if (ncProbe.exitCode !== 0) {
|
|
598
|
+
throw new UnsupportedByBackendError(`network links (no nc/busybox in consumer image '${handle.spec.image}')`, this.name, "run this test with RIGHTSIZE_BACKEND=docker instead");
|
|
599
|
+
}
|
|
600
|
+
const hostsResult = await this.exec(handle, ["sh", "-c", hostsAliasScript(links)]);
|
|
601
|
+
if (hostsResult.exitCode !== 0) {
|
|
602
|
+
throw new BackendError(`failed to install /etc/hosts aliases in ${handle.id}: ${hostsResult.stderr}`);
|
|
603
|
+
}
|
|
604
|
+
const state = this.handles.get(handle.id);
|
|
605
|
+
for (const link of links) {
|
|
606
|
+
const tunnel = new ExecTunnel(msbPath, handle.id, link);
|
|
607
|
+
if (state !== undefined) {
|
|
608
|
+
state.resources.push(tunnel);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async close() {
|
|
613
|
+
const msbPath = await this.msbPath().catch(() => undefined);
|
|
614
|
+
if (msbPath === undefined) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
for (const name of [...this.startedNames]) {
|
|
618
|
+
await this.silently(msbPath, name);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Synchronous, blocking teardown for the process-exit path, where the
|
|
623
|
+
* event loop is not available to await this backend's normal async
|
|
624
|
+
* stop/remove. `spawnSync` is the one primitive Node offers that actually
|
|
625
|
+
* blocks the process here; failures are swallowed because the process is
|
|
626
|
+
* exiting regardless and there is no caller left to report them to.
|
|
627
|
+
*/
|
|
628
|
+
cleanupSync(id) {
|
|
629
|
+
// A container only ever reaches start() (and therefore only ever gets
|
|
630
|
+
// registered for sync cleanup) after msbPathPromise has already
|
|
631
|
+
// resolved, so resolvedMsbPath is populated by the time this can matter
|
|
632
|
+
// in practice; the undefined case below covers only the very first
|
|
633
|
+
// provisioning attempt racing its own constructor's .then().
|
|
634
|
+
const msbPath = this.resolvedMsbPath;
|
|
635
|
+
if (msbPath === undefined) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
spawnSync(msbPath, MsbCommands.stop(id));
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Best-effort.
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
spawnSync(msbPath, MsbCommands.rm(id));
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// Best-effort.
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Remove leftover `rz-<other-runid>-*` sandboxes from a crashed prior run — never this run's own. */
|
|
652
|
+
async sweepOrphans() {
|
|
653
|
+
const msbPath = await this.msbPath();
|
|
654
|
+
const result = await invoke(msbPath, MsbCommands.ls(), LOGS_TIMEOUT_MS);
|
|
655
|
+
for (const name of orphanNames(result.stdout, RunId.value)) {
|
|
656
|
+
await this.silently(msbPath, name);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async silently(msbPath, name) {
|
|
660
|
+
await invoke(msbPath, MsbCommands.stop(name), STOP_TIMEOUT_MS).catch(() => { });
|
|
661
|
+
await invoke(msbPath, MsbCommands.rm(name), STOP_TIMEOUT_MS).catch(() => { });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
//# sourceMappingURL=backend.js.map
|