@parcha/pi-durable-disk 0.0.0-stage → 0.1.0-beta.13

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
@@ -0,0 +1,362 @@
1
+ // dockerHost: one container per instance of a run, from an image that carries Node, the archil client, FUSE and
2
+ // this package (docker/Dockerfile). The container is the instance's machine and its cage: the instance runs as root inside
3
+ // (the archil verbs need root, and no sudo is involved), mounts the run's directory through /dev/fuse, and runs the
4
+ // agent's commands as the image's unprivileged user under no_new_privs (`run --run-as`). Nothing on the host needs root.
5
+ //
6
+ // One container is one incarnation: no restart policy; the supervisor decides. A container that dies takes its FUSE
7
+ // daemon and its mount with it (the mount lives in the container's mount namespace, never on the host), so the claim
8
+ // is orphaned and the next supervisor tick revokes it and starts a new container. The name
9
+ // `<prefix><run>-<disk key>-g<attempt>` (the disk key a short hash of the disk and its region, so runs of the same id on
10
+ // two disks never share a container) is the idempotency key of a start: a retry of the same attempt adopts a container
11
+ // that is running, which the handle reports (`adopted`, so the supervisor removes the token it minted for this start), and
12
+ // replaces one that never started or already exited. As with localHost, an instance parks a run that only waits (it exits
13
+ // 0 and its container stays exited until the supervisor starts the next one at the wake) and drains on `docker stop`.
14
+ //
15
+ // The mount token never appears in the container's configuration (`docker inspect` shows its environment and argv): the
16
+ // driver copies it into the created container as a root-only file (`docker cp -`, a tar stream built in memory, so it
17
+ // never touches the host's file system), the image's entrypoint makes that file the instance's stdin and removes it
18
+ // before the instance starts, and the instance closes its stdin once read. The API key stays with the supervisor.
19
+ import { spawn } from "node:child_process";
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync } from "node:fs";
22
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
23
+ import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
24
+ import { PdaError } from "../errors.js";
25
+ import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
26
+ export class DockerHostError extends PdaError {
27
+ constructor(code, message, options = {}) {
28
+ super(code, message, options);
29
+ }
30
+ }
31
+ /** Inside the container: where the app root is mounted, the root-owned archil wrapper, and where the token lands. */
32
+ const APP_DIR = "/opt/pda/app";
33
+ const CONTAINER_ARCHIL = "/usr/local/sbin/archil-scoped";
34
+ const TOKEN_DIR = "/run/pda";
35
+ const TOKEN_FILE = "token";
36
+ const NAME_SAFE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/;
37
+ /** dockerHost's park threshold: localHost's, far above twice a container's start. */
38
+ export const DOCKER_PARK_THRESHOLD_MS = LOCAL_PARK_THRESHOLD_MS;
39
+ /** The value of the last `--restart` among docker flags (`--restart X` or `--restart=X`), or null when none is set. */
40
+ function restartPolicyIn(args) {
41
+ let found = null;
42
+ args.forEach((a, i) => {
43
+ if (a === "--restart")
44
+ found = args[i + 1] ?? "";
45
+ else if (a.startsWith("--restart="))
46
+ found = a.slice("--restart=".length);
47
+ });
48
+ return found;
49
+ }
50
+ const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
51
+ const NO_SUCH = /No such (container|object)/i;
52
+ const IN_USE = /Conflict|already in use/i;
53
+ /** Eight hex digits of the run's disk and region: part of each container's name, so equal run ids on two disks differ. */
54
+ export function diskKey(ref) {
55
+ return createHash("sha256").update(`${ref.disk}\0${ref.region}`).digest("hex").slice(0, 8);
56
+ }
57
+ /** A run's labels, all of which a container must carry to count as that run's (adoption, cleanup). */
58
+ const runLabels = (fleet, ref) => ({ "pda.fleet": fleet, "pda.run": ref.id, "pda.disk": ref.disk, "pda.region": ref.region });
59
+ /** The docker CLI with the caller's PATH (Docker Desktop installs outside the system paths) and HOME (its contexts). */
60
+ export const runDocker = (argv, opts = {}) => new Promise((resolvePromise, reject) => {
61
+ let stdout = "", stderr = "", settled = false;
62
+ const env = { PATH: process.env.PATH || SAFE_PATH, HOME: process.env.HOME ?? "/", LANG: "C.UTF-8" };
63
+ for (const k of ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY", "XDG_RUNTIME_DIR"]) {
64
+ if (process.env[k])
65
+ env[k] = process.env[k];
66
+ }
67
+ const child = spawn(argv[0], argv.slice(1), { env, stdio: ["pipe", "pipe", "pipe"] });
68
+ child.stdout.setEncoding("utf8").on("data", (c) => (stdout += c));
69
+ child.stderr.setEncoding("utf8").on("data", (c) => (stderr += c));
70
+ const done = (r) => void (settled || ((settled = true), clearTimeout(timer), resolvePromise(r)));
71
+ const timer = setTimeout(() => {
72
+ child.kill("SIGKILL");
73
+ done({ code: null, timedOut: true, stdout, stderr });
74
+ }, opts.timeoutMs ?? 60_000);
75
+ child.on("error", (err) => void (settled || ((settled = true), clearTimeout(timer), reject(err))));
76
+ child.on("close", (code) => done({ code, timedOut: false, stdout, stderr }));
77
+ child.stdin.on("error", () => { });
78
+ child.stdin.end(opts.input ?? "");
79
+ });
80
+ /** One regular file in a POSIX tar stream, owned by root: what `docker cp -` extracts into the container. */
81
+ export function tarOneFile(name, data, mode = 0o600) {
82
+ if (!/^[A-Za-z0-9._-]{1,99}$/.test(name))
83
+ throw new DockerHostError("INVALID_ARGUMENT", `tar entry ${name} is not a plain file name`);
84
+ const header = Buffer.alloc(512);
85
+ const put = (text, offset, length) => header.write(text, offset, length, "ascii");
86
+ const octal = (n, width) => `${n.toString(8).padStart(width - 1, "0")}\0`;
87
+ put(name, 0, 100);
88
+ put(octal(mode, 8), 100, 8);
89
+ put(octal(0, 8), 108, 8);
90
+ put(octal(0, 8), 116, 8);
91
+ put(octal(data.length, 12), 124, 12);
92
+ put(octal(Math.floor(Date.now() / 1000), 12), 136, 12);
93
+ header.fill(" ", 148, 156);
94
+ put("0", 156, 1);
95
+ put("ustar\0", 257, 6);
96
+ put("00", 263, 2);
97
+ put("root", 265, 32);
98
+ put("root", 297, 32);
99
+ let sum = 0;
100
+ for (const byte of header)
101
+ sum += byte;
102
+ put(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8);
103
+ const pad = Buffer.alloc((512 - (data.length % 512)) % 512);
104
+ return Buffer.concat([header, Buffer.from(data), pad, Buffer.alloc(1024)]);
105
+ }
106
+ /**
107
+ * A container's state as a host status. Paused is a frozen host that still holds its claim: running, so the lease decides,
108
+ * and STONITH stops it (`docker stop` works on a paused container). Exit 0 (a drain) is stopped; any other exit, the
109
+ * terminal 65, 70, 75 and 76 included, is failed. Created (never started) is stopped.
110
+ */
111
+ export function containerStatus(state) {
112
+ switch (state.Status) {
113
+ case "running":
114
+ case "paused":
115
+ case "restarting":
116
+ return "running";
117
+ case "created":
118
+ case "removing":
119
+ return "stopped";
120
+ case "exited":
121
+ return state.ExitCode === 0 ? "stopped" : "failed";
122
+ case "dead":
123
+ return "failed";
124
+ default:
125
+ return "unknown";
126
+ }
127
+ }
128
+ export function dockerHost(opts = {}) {
129
+ const docker = opts.docker ?? "docker";
130
+ const exec = opts.exec ?? runDocker;
131
+ const fleet = opts.fleet ?? "default";
132
+ const prefix = opts.namePrefix ?? "pda-";
133
+ const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
134
+ const runAs = opts.runAs ?? "pda";
135
+ const stopTimeoutMs = opts.stopTimeoutMs ?? 30_000;
136
+ const parkMs = opts.parkThresholdMs === undefined ? DOCKER_PARK_THRESHOLD_MS : opts.parkThresholdMs;
137
+ const drainMs = Math.max(0, stopTimeoutMs - Math.min(5_000, stopTimeoutMs / 2));
138
+ const apparmorOption = opts.apparmor ?? "auto";
139
+ if (!/^[A-Za-z0-9_.-]{1,63}$/.test(fleet))
140
+ throw new DockerHostError("INVALID_ARGUMENT", `fleet ${fleet} is not a plain label value`);
141
+ if (!isAbsolute(mountRoot))
142
+ throw new DockerHostError("INVALID_ARGUMENT", `mountRoot ${mountRoot} is not absolute`);
143
+ // The supervisor decides every start: a parked instance exits 0, and a policy that restarted it would race that start.
144
+ const restart = restartPolicyIn(opts.dockerArgs ?? []);
145
+ if (restart !== null && restart !== "no")
146
+ throw new DockerHostError("INVALID_ARGUMENT", `dockerArgs set --restart ${restart}; instances run with no restart policy, the supervisor starts them`);
147
+ const app = appMount(opts.app, opts.appRoot);
148
+ const run = (args, o) => exec([docker, ...args], o);
149
+ const firstLine = (r) => (r.timedOut ? "timed out" : (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300));
150
+ const ok = (r) => r.code === 0 && !r.timedOut;
151
+ // The daemon's identity: a handle from another daemon is not this driver's to judge.
152
+ let daemonId;
153
+ const daemon = () => (daemonId ??= run(["info", "--format", "{{.ID}}"], { timeoutMs: 30_000 }).then((r) => {
154
+ if (!ok(r) || !r.stdout.trim()) {
155
+ daemonId = undefined;
156
+ throw new DockerHostError("DOCKER_FAILED", `docker info: ${firstLine(r)}`);
157
+ }
158
+ return r.stdout.trim();
159
+ }));
160
+ // The AppArmor option, decided once: given, or from what the daemon reports it applies.
161
+ let apparmorProfile;
162
+ const apparmor = () => (apparmorProfile ??= (async () => {
163
+ if (apparmorOption !== "auto")
164
+ return apparmorOption === false ? null : apparmorOption;
165
+ const r = await run(["info", "--format", "{{json .SecurityOptions}}"], { timeoutMs: 30_000 });
166
+ if (!ok(r)) {
167
+ apparmorProfile = undefined;
168
+ throw new DockerHostError("DOCKER_FAILED", `docker info: ${firstLine(r)}`);
169
+ }
170
+ const options = JSON.parse(r.stdout.trim() || "[]") ?? [];
171
+ const applies = options.some((o) => o.split(",").includes("name=apparmor"));
172
+ opts.note?.(applies
173
+ ? "the docker daemon applies AppArmor, whose default profile denies the mount archil needs: containers run with --security-opt apparmor=unconfined"
174
+ : "the docker daemon applies no AppArmor profile: containers get no AppArmor option");
175
+ return applies ? "unconfined" : null;
176
+ })());
177
+ const mine = async (h) => h.driver === "docker" && h.fleet === fleet && typeof h.name === "string" && NAME_SAFE.test(h.name) && h.daemon === (await daemon());
178
+ const target = (h) => (typeof h.id === "string" && /^[0-9a-f]{12,64}$/.test(h.id) ? h.id : String(h.name));
179
+ async function inspect(ref) {
180
+ const r = await run(["inspect", "--type", "container", "--format", "{{json .State}}\t{{json .Config.Labels}}", ref], { timeoutMs: 30_000 });
181
+ if (!ok(r)) {
182
+ if (NO_SUCH.test(r.stderr))
183
+ return null;
184
+ throw new DockerHostError("DOCKER_FAILED", `docker inspect ${ref}: ${firstLine(r)}`);
185
+ }
186
+ const [state, labels] = r.stdout.trim().split("\t");
187
+ return { state: JSON.parse(state), labels: JSON.parse(labels || "null") ?? {} };
188
+ }
189
+ async function remove(ref) {
190
+ const r = await run(["rm", "-f", ref], { timeoutMs: 60_000 });
191
+ if (!ok(r) && !NO_SUCH.test(r.stderr))
192
+ throw new DockerHostError("DOCKER_FAILED", `docker rm -f ${ref}: ${firstLine(r)}`);
193
+ }
194
+ function createArgs(ref, name, attempt, holder, profile) {
195
+ const env = { ...opts.env, PDA_HOLDER: JSON.stringify(holder) };
196
+ const runFlags = [
197
+ "run",
198
+ "--disk", ref.disk,
199
+ "--region", ref.region,
200
+ "--id", ref.id,
201
+ "--mount-root", mountRoot,
202
+ "--archil", CONTAINER_ARCHIL,
203
+ "--run-as", runAs,
204
+ ...(app ? ["--app", app.inside] : []),
205
+ ...(parkMs ? ["--park-threshold", `${parkMs}ms`] : []),
206
+ "--drain-timeout", `${Math.floor(drainMs)}ms`,
207
+ ...(opts.runArgs ?? []),
208
+ "--token-stdin",
209
+ ];
210
+ return [
211
+ "create",
212
+ "--name", name,
213
+ "--hostname", name.slice(0, 63).replace(/[^A-Za-z0-9-]/g, "-").replace(/-+$/, ""),
214
+ ...Object.entries(runLabels(fleet, ref)).flatMap(([k, v]) => ["--label", `${k}=${v}`]),
215
+ ...(attempt === null ? [] : ["--label", `pda.attempt=${attempt}`]),
216
+ // FUSE: the device and the capability mount(2) needs; the container's root holds them, the commands never do.
217
+ "--device", "/dev/fuse",
218
+ "--cap-add", "SYS_ADMIN",
219
+ ...(profile === null ? [] : ["--security-opt", `apparmor=${profile}`]),
220
+ // Nothing in the container gains privilege through exec (setuid binaries, file capabilities).
221
+ "--security-opt", "no-new-privileges",
222
+ "--restart", "no",
223
+ "--stop-timeout", String(Math.max(1, Math.ceil(stopTimeoutMs / 1000))),
224
+ ...Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
225
+ ...(app?.mounts ?? []),
226
+ ...(opts.dockerArgs ?? []),
227
+ opts.image,
228
+ ...runFlags,
229
+ ];
230
+ }
231
+ async function create(ref, name, attempt, holder) {
232
+ const profile = await apparmor();
233
+ for (let tries = 0;; tries++) {
234
+ const r = await run(createArgs(ref, name, attempt, holder, profile), { timeoutMs: 120_000 });
235
+ if (ok(r))
236
+ return { id: r.stdout.trim().split("\n").at(-1).trim() };
237
+ if (!IN_USE.test(r.stderr) || tries > 0)
238
+ throw new DockerHostError("START_FAILED", `docker create ${name}: ${firstLine(r)}`);
239
+ // The same attempt again: a running container is a start that already happened (a racing supervisor, or a retry
240
+ // after a timeout), so it is adopted; one that never started or already exited is replaced.
241
+ const found = await inspect(name);
242
+ if (found && Object.entries(runLabels(fleet, ref)).some(([k, v]) => found.labels[k] !== v)) {
243
+ throw new DockerHostError("START_FAILED", `container ${name} exists and is not this run's (fleet ${fleet}, ${ref.disk} in ${ref.region})`);
244
+ }
245
+ if (found && containerStatus(found.state) === "running")
246
+ return { adopted: true };
247
+ await remove(name);
248
+ }
249
+ }
250
+ /**
251
+ * Remove this run's other containers of this fleet that have ended (a killed or fenced instance stays as an exited
252
+ * container until here, so its `docker logs` can be read until the next start), and those created for an earlier
253
+ * attempt that never started. Best effort; never a running or paused one, never the one just started.
254
+ */
255
+ async function collect(ref, attempt, keep) {
256
+ const filters = Object.entries(runLabels(fleet, ref)).flatMap(([k, v]) => ["--filter", `label=${k}=${v}`]);
257
+ const r = await run(["ps", "-a", ...filters, "--format", "{{.ID}}\t{{.State}}\t{{.Label \"pda.attempt\"}}"], { timeoutMs: 30_000 }).catch(() => null);
258
+ if (!r || !ok(r))
259
+ return;
260
+ for (const line of r.stdout.split("\n").filter(Boolean)) {
261
+ const [id, state, label] = line.split("\t");
262
+ if (keep.startsWith(id) || id.startsWith(keep))
263
+ continue;
264
+ const ended = state === "exited" || state === "dead";
265
+ const abandoned = state === "created" && attempt !== null && /^\d+$/.test(label ?? "") && Number(label) < attempt;
266
+ if (ended || abandoned)
267
+ await remove(id).catch(() => { });
268
+ }
269
+ }
270
+ return {
271
+ fleet,
272
+ mountRoot,
273
+ async start(ref, token, attemptInfo) {
274
+ runPath(ref.id);
275
+ if (!opts.image)
276
+ throw new DockerHostError("INVALID_ARGUMENT", "dockerHost needs an image to start an instance");
277
+ if (!token || /[\r\n]/.test(token))
278
+ throw new DockerHostError("INVALID_ARGUMENT", "no mount token, or one with a line break");
279
+ const attempt = attemptInfo?.attempt ?? null;
280
+ const name = `${prefix}${ref.id}-${diskKey(ref)}-${attempt === null ? `t${Date.now().toString(36)}` : `g${attempt}`}`;
281
+ if (!NAME_SAFE.test(name))
282
+ throw new DockerHostError("INVALID_ARGUMENT", `container name ${name} is not safe`);
283
+ const handle = { driver: "docker", fleet, daemon: await daemon(), name, mountpoint: posix.join(mountRoot, runPath(ref.id)), image: opts.image };
284
+ const made = await create(ref, name, attempt, handle);
285
+ // An earlier start of this attempt made the container: the token given to this start was not used.
286
+ if ("adopted" in made)
287
+ return { ...handle, adopted: true };
288
+ try {
289
+ const cp = await run(["cp", "-", `${made.id}:${TOKEN_DIR}`], { input: tarOneFile(TOKEN_FILE, Buffer.from(`${token}\n`)), timeoutMs: 60_000 });
290
+ if (!ok(cp))
291
+ throw new DockerHostError("START_FAILED", `docker cp of the mount token into ${name}: ${firstLine(cp)}`);
292
+ const st = await run(["start", made.id], { timeoutMs: 120_000 });
293
+ if (!ok(st))
294
+ throw new DockerHostError("START_FAILED", `docker start ${name}: ${firstLine(st)}`);
295
+ }
296
+ catch (err) {
297
+ await remove(made.id).catch(() => { });
298
+ throw err;
299
+ }
300
+ await collect(ref, attempt, made.id);
301
+ return { ...handle, id: made.id };
302
+ },
303
+ async status(h) {
304
+ if (!(await mine(h)))
305
+ return "unknown";
306
+ const found = await inspect(target(h));
307
+ return found ? containerStatus(found.state) : "gone";
308
+ },
309
+ /** The container's state with its exit code (75 fenced, 76 held, 65 and 70 data errors), or null when it is gone. */
310
+ async describe(h) {
311
+ if (!(await mine(h)))
312
+ return null;
313
+ const found = await inspect(target(h));
314
+ if (!found)
315
+ return null;
316
+ const s = found.state;
317
+ const time = (t) => (t && !t.startsWith("0001-") ? t : null);
318
+ return { status: containerStatus(s), state: s.Status ?? "", exitCode: s.Status === "exited" || s.Status === "dead" ? (s.ExitCode ?? null) : null, oomKilled: s.OOMKilled === true, error: s.Error ?? "", startedAt: time(s.StartedAt), finishedAt: time(s.FinishedAt) };
319
+ },
320
+ async stop(h) {
321
+ if (!(await mine(h)))
322
+ return;
323
+ const ref = target(h);
324
+ const found = await inspect(ref);
325
+ if (!found)
326
+ return;
327
+ if (found.labels["pda.fleet"] !== fleet)
328
+ return;
329
+ // SIGTERM drains (the instance releases its claim); a paused container is thawed by the daemon to take it. Then
330
+ // the container goes, and its mount with it: nothing is left on the host.
331
+ const st = await run(["stop", "-t", String(Math.max(1, Math.ceil(stopTimeoutMs / 1000))), ref], { timeoutMs: stopTimeoutMs + 30_000 });
332
+ if (!ok(st) && !NO_SUCH.test(st.stderr) && (await inspect(ref).then((f) => f && containerStatus(f.state) === "running"))) {
333
+ await run(["kill", ref], { timeoutMs: 30_000 });
334
+ }
335
+ await remove(ref);
336
+ if (await inspect(ref))
337
+ throw new DockerHostError("STOP_FAILED", `container ${ref} is still there after docker rm -f`);
338
+ },
339
+ };
340
+ }
341
+ /** The bind mount of the app's directory (read-only, its node_modules hidden) and the module's path inside the container. */
342
+ function appMount(app, appRoot) {
343
+ if (app === undefined) {
344
+ if (appRoot !== undefined)
345
+ throw new DockerHostError("INVALID_ARGUMENT", "appRoot without an app module");
346
+ return null;
347
+ }
348
+ const file = resolve(app);
349
+ const root = resolve(appRoot ?? dirname(file));
350
+ const rel = relative(root, file);
351
+ if (!rel || rel.startsWith("..") || isAbsolute(rel))
352
+ throw new DockerHostError("INVALID_ARGUMENT", `the app module ${file} is not under its root ${root}`);
353
+ // --mount takes comma-separated fields; a path with a comma or a quote would need quoting rules worth not depending on.
354
+ if (/[",\n]/.test(root))
355
+ throw new DockerHostError("INVALID_ARGUMENT", `the app root ${root} contains a comma, a quote or a newline`);
356
+ const mounts = ["--mount", `type=bind,source=${root},target=${APP_DIR},readonly`];
357
+ // The image's node_modules holds the one copy of pi-durable this package and the app must share: an app root's own
358
+ // node_modules (installed on the host, maybe for another platform) is hidden under an empty read-only tmpfs.
359
+ if (existsSync(join(root, "node_modules")))
360
+ mounts.push("--mount", `type=tmpfs,target=${APP_DIR}/node_modules,tmpfs-size=4096,tmpfs-mode=0555`);
361
+ return { inside: posix.join(APP_DIR, ...rel.split(sep)), mounts };
362
+ }
@@ -0,0 +1,71 @@
1
+ import { PdaError } from "../errors.js";
2
+ import type { HostDriver, HostStatus } from "../supervise.js";
3
+ export type Ran = {
4
+ code: number | null;
5
+ timedOut: boolean;
6
+ stdout: string;
7
+ stderr: string;
8
+ };
9
+ /** Runs one command; replaceable so tests can record argv without systemd. */
10
+ export type Runner = (argv: string[], opts?: {
11
+ input?: string;
12
+ timeoutMs?: number;
13
+ }) => Promise<Ran>;
14
+ export interface LocalHostOptions {
15
+ /** "systemd" (default) or "child" (development: no cgroup, so no guarantee about orphaned commands). */
16
+ mode?: "systemd" | "child";
17
+ /** Must be the same on every host of a deployment. Default /mnt/archil. */
18
+ mountRoot?: string;
19
+ /** The instance program; `run` and its flags are appended. Default: this Node and the package's CLI. */
20
+ command?: string[];
21
+ /** Extra flags for `run` (an app module, heartbeat periods). */
22
+ runArgs?: string[];
23
+ /** Unit names are `<unitPrefix><run id>-<stamp>`. Default "pda-". */
24
+ unitPrefix?: string;
25
+ /** The unprivileged user and group the instance and its tools run as. Default this process's; required as root. */
26
+ user?: string | number;
27
+ group?: string | number;
28
+ /** The name this driver answers to in `holder.host`; a handle with another host is not this driver's. */
29
+ hostName?: string;
30
+ /** Extra environment for the instance. The driver never copies its own environment (which holds the API key). */
31
+ env?: Record<string, string>;
32
+ /** The archil wrapper the instance mounts through (it takes the token on stdin); default this package's `bin/archil-scoped`. */
33
+ archil?: string;
34
+ /**
35
+ * TimeoutStopSec: SIGTERM, then SIGKILL of the whole control group after this. Default 30 s. The instance drains for
36
+ * this long minus a close reserve (half of it, at most 5 s) before it releases.
37
+ */
38
+ stopTimeoutMs?: number;
39
+ /**
40
+ * The instance parks a run whose live work only waits longer than this, which must be at least twice this driver's
41
+ * start time (about 1.5 s). Default 60 s. null: never park, the instance stays up through every wait.
42
+ */
43
+ parkThresholdMs?: number | null;
44
+ /** Restart=on-failure, except after a terminal exit (`TERMINAL_EXITS`: 65, 70, 75, 76). */
45
+ restart?: boolean;
46
+ /** Child mode: append the instance's output to `<logDir>/<unit>.log`. */
47
+ logDir?: string;
48
+ sudo?: string | false;
49
+ exec?: Runner;
50
+ procMounts?: string;
51
+ }
52
+ export declare class LocalHostError extends PdaError {
53
+ constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED", message: string, options?: {
54
+ cause?: unknown;
55
+ });
56
+ }
57
+ /** Exits a restart cannot fix: 65 data error, 70 store head unreadable, 75 fenced, 76 held. */
58
+ export declare const TERMINAL_EXITS: readonly [65, 70, 75, 76];
59
+ /** Minimal environment, a timeout that settles even if a grandchild keeps the pipes, and the input on stdin. */
60
+ export declare const runCommand: Runner;
61
+ export declare function currentBootId(): string;
62
+ /** `systemctl show` output as a map. */
63
+ export declare function parseShow(text: string): Record<string, string>;
64
+ /** A unit's state as a host status: loaded and active (or restarting) is running; unloaded is gone. */
65
+ export declare function unitStatus(show: Record<string, string>): HostStatus;
66
+ export declare function localHost(opts?: LocalHostOptions): HostDriver & {
67
+ readonly hostName: string;
68
+ readonly mountRoot: string;
69
+ };
70
+ /** Field 22 of /proc/<pid>/stat (start time in clock ticks), so a reused pid is never mistaken for the instance. */
71
+ export declare function procStartTicks(pid: number): number | null;