@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.
- package/CHANGELOG.md +329 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +695 -2
- package/bin/archil-scoped +165 -0
- package/dist/app.d.ts +40 -0
- package/dist/app.js +47 -0
- package/dist/claim.d.ts +222 -0
- package/dist/claim.js +722 -0
- package/dist/cli.d.ts +64 -0
- package/dist/cli.js +664 -0
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +91 -0
- package/dist/confine.js +341 -0
- package/dist/env.d.ts +129 -0
- package/dist/env.js +608 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +55 -0
- package/dist/fork.d.ts +41 -0
- package/dist/fork.js +181 -0
- package/dist/hosts/daytona-launch.d.ts +52 -0
- package/dist/hosts/daytona-launch.js +270 -0
- package/dist/hosts/daytona.d.ts +147 -0
- package/dist/hosts/daytona.js +368 -0
- package/dist/hosts/docker.d.ts +103 -0
- package/dist/hosts/docker.js +362 -0
- package/dist/hosts/local-host.d.ts +71 -0
- package/dist/hosts/local-host.js +356 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +17 -0
- package/dist/lease.d.ts +7 -0
- package/dist/lease.js +6 -0
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +92 -0
- package/dist/park.js +229 -0
- package/dist/run.d.ts +290 -0
- package/dist/run.js +785 -0
- package/dist/serve.d.ts +101 -0
- package/dist/serve.js +393 -0
- package/dist/status.d.ts +123 -0
- package/dist/status.js +264 -0
- package/dist/store.d.ts +103 -0
- package/dist/store.js +217 -0
- package/dist/supervise.d.ts +340 -0
- package/dist/supervise.js +528 -0
- package/dist/watchdog.d.ts +39 -0
- package/dist/watchdog.js +137 -0
- package/package.json +87 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pi-durable-disk: run an instance, supervise runs, read a run's status, release a run's mount on this host.
|
|
3
|
+
// run the instance a host driver starts: openDurableRun with an app module (exit 75 fenced, 76 held, 65 data
|
|
4
|
+
// error, 70 store head unreadable, 0 drained)
|
|
5
|
+
// supervise ensureRunning for each run, once or `--every 30s`; `--check` proves this host's fence first
|
|
6
|
+
// status run.json over S3, the run's delegations, and the holder's state if this host can see it
|
|
7
|
+
// release unmount the run's mount on this host (flush, check the delegation in; a dead mount is cleaned)
|
|
8
|
+
// fork copy a released, sealed run into a new run; its first open is generation 1
|
|
9
|
+
// The API key is read from the environment variable named by `--api-key-env` (default ARCHIL_API_KEY) and only by the
|
|
10
|
+
// supervisor commands; `run` never needs it and a host driver never passes it on.
|
|
11
|
+
import { closeSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
16
|
+
import { archilEnv } from "./env.js";
|
|
17
|
+
import { exitCodeFor, FencedError, PdaError } from "./errors.js";
|
|
18
|
+
import { fork } from "./fork.js";
|
|
19
|
+
import { drain, recordWake, watchParking } from "./park.js";
|
|
20
|
+
import { isLoopback, readServeToken, serveRun } from "./serve.js";
|
|
21
|
+
import { dockerHost } from "./hosts/docker.js";
|
|
22
|
+
import { localHost } from "./hosts/local-host.js";
|
|
23
|
+
import { checkHost, CONTROL_TIMEOUT_MS, readRunStatus, superviseRuns, sweepTokens, withTimeouts, } from "./supervise.js";
|
|
24
|
+
import { openDurableRun } from "./run.js";
|
|
25
|
+
import { AppError, loadApp } from "./app.js";
|
|
26
|
+
class UsageError extends PdaError {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super("USAGE", message, { exitCode: 2 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const USAGE = `usage:
|
|
32
|
+
pi-durable-disk run --disk D --region R --id ID --app MODULE [--mount-root DIR] [--archil BIN]
|
|
33
|
+
[--heartbeat-ms N] [--lease-expiry-ms N] [--lease-margin-ms N] [--on-sigterm resume|pause]
|
|
34
|
+
[--token-stdin] [--run-as USER] [--serve PORT] [--serve-host H] [--serve-url URL]
|
|
35
|
+
[--serve-token-file F] [--park-threshold 60s] [--park-idle 60s] [--drain-timeout 25s]
|
|
36
|
+
pi-durable-disk supervise --disk D --region R (--id ID ... | --all) [--every 30s] [--check]
|
|
37
|
+
[--host local|docker] [--driver systemd|child] [--mount-root DIR] [--host-name NAME] [--unit-prefix P]
|
|
38
|
+
[--user U] [--group G] [--app MODULE] [--run-arg ARG ...] [--lease-expiry 90s] [--stonith-timeout 30s]
|
|
39
|
+
[--start-grace 90s] [--start-backoff-max 10m] [--stop-timeout 30s] [--token-ttl 24h] [--token-prefix P] [--demand] [--create] [--env K=V ...] [--log-dir DIR]
|
|
40
|
+
[--archil BIN] [--control-timeout 10s] [--sweep-tokens] [--token-grace 15m]
|
|
41
|
+
[--park-threshold 60s] [--api-key-env NAME]
|
|
42
|
+
with --host docker: --image IMAGE [--app-root DIR] [--fleet F] [--name-prefix P] [--run-as USER]
|
|
43
|
+
[--docker-arg ARG ...] [--apparmor auto|PROFILE|none] [--docker BIN]
|
|
44
|
+
pi-durable-disk supervise --check --disk D --region R [--mount-root DIR] [--user U] [--group G] [--check-id-prefix P]
|
|
45
|
+
pi-durable-disk status --disk D --region R --id ID [--host-name NAME] [--api-key-env NAME] [--docker BIN]
|
|
46
|
+
pi-durable-disk release --id ID [--mount-root DIR]
|
|
47
|
+
pi-durable-disk fork --disk D --region R --id ID --new-id NEW [--mount-root DIR] [--archil BIN] [--token-prefix P]
|
|
48
|
+
[--api-key-env NAME]`;
|
|
49
|
+
/** "30s", "500ms", "2m", "1h", or plain milliseconds. */
|
|
50
|
+
/**
|
|
51
|
+
* Whether the lease watchdog may kill every other process in the instance's cgroup: only inside its own systemd unit,
|
|
52
|
+
* whose cgroup holds nothing but the instance and its commands. Elsewhere the cgroup may be shared (a login session, an
|
|
53
|
+
* agent host's service), so the watchdog kills only the instance's own command groups.
|
|
54
|
+
*
|
|
55
|
+
* The holder handle only claims a unit (`mode: "systemd"`, `unit`), and it arrives in an environment variable that any
|
|
56
|
+
* child inherits, so /proc decides: this process's cgroup v2 path must end in `/<unit>.service`, and its parent's cgroup
|
|
57
|
+
* must be another (a process that merely inherited the handle shares its parent's cgroup). When the handle claims a
|
|
58
|
+
* unit that /proc does not confirm, `warning` says why, for one log line.
|
|
59
|
+
*/
|
|
60
|
+
export function watchdogOwnsCgroup(holder, options = {}) {
|
|
61
|
+
if (holder.mode !== "systemd")
|
|
62
|
+
return { owns: false };
|
|
63
|
+
const proc = options.proc ?? "/proc";
|
|
64
|
+
const cgroupOf = (pid) => {
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(join(proc, pid, "cgroup"), "utf8").split("\n").find((line) => line.startsWith("0::"))?.slice(3);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const refuse = (why) => ({ owns: false, warning: `the watchdog will not kill the cgroup: ${why}` });
|
|
73
|
+
const unit = holder.unit;
|
|
74
|
+
if (typeof unit !== "string" || unit === "")
|
|
75
|
+
return refuse("the holder names no unit");
|
|
76
|
+
const own = cgroupOf("self");
|
|
77
|
+
if (own === undefined)
|
|
78
|
+
return refuse("this process has no cgroup v2 path");
|
|
79
|
+
if (!own.endsWith(`/${unit}.service`))
|
|
80
|
+
return refuse(`this process's cgroup ${own} is not ${unit}.service`);
|
|
81
|
+
const parent = cgroupOf(String(options.ppid ?? process.ppid));
|
|
82
|
+
if (parent === undefined)
|
|
83
|
+
return refuse("the parent's cgroup cannot be read");
|
|
84
|
+
if (parent === own)
|
|
85
|
+
return refuse(`the parent shares cgroup ${own}, so this process only inherited the handle`);
|
|
86
|
+
return { owns: true };
|
|
87
|
+
}
|
|
88
|
+
export function parseDuration(text) {
|
|
89
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(text.trim());
|
|
90
|
+
if (!m)
|
|
91
|
+
throw new UsageError(`not a duration: ${text}`);
|
|
92
|
+
return Math.round(Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[(m[2] ?? "ms")]);
|
|
93
|
+
}
|
|
94
|
+
function parse(argv, options) {
|
|
95
|
+
try {
|
|
96
|
+
return parseArgs({ args: argv, options, allowPositionals: false, strict: true }).values;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new UsageError(err.message);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** A user or group name or number, as a number (from /etc/passwd or /etc/group). */
|
|
103
|
+
function idOf(value, file, fallback) {
|
|
104
|
+
if (value === undefined)
|
|
105
|
+
return fallback;
|
|
106
|
+
if (/^\d+$/.test(value))
|
|
107
|
+
return Number(value);
|
|
108
|
+
const row = readFileSync(file, "utf8").split("\n").map((l) => l.split(":")).find((f) => f[0] === value);
|
|
109
|
+
if (!row)
|
|
110
|
+
throw new UsageError(`no such ${file === "/etc/passwd" ? "user" : "group"}: ${value}`);
|
|
111
|
+
return Number(row[2]);
|
|
112
|
+
}
|
|
113
|
+
const ownerOf = (values) => ({
|
|
114
|
+
uid: idOf(str(values.user), "/etc/passwd", process.getuid?.() ?? 0),
|
|
115
|
+
gid: idOf(str(values.group), "/etc/group", process.getgid?.() ?? 0),
|
|
116
|
+
});
|
|
117
|
+
/** `--run-as NAME|UID[:GID]`: the user the agent's commands run as, with its group and home from /etc/passwd when listed. */
|
|
118
|
+
export function runUser(spec, passwd = "/etc/passwd") {
|
|
119
|
+
const rows = (() => {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(passwd, "utf8").split("\n").map((l) => l.split(":"));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
const m = /^(\d+)(?::(\d+))?$/.exec(spec);
|
|
128
|
+
const row = rows.find((f) => f.length >= 6 && (m ? f[2] === m[1] : f[0] === spec));
|
|
129
|
+
if (!m && !row)
|
|
130
|
+
throw new UsageError(`no such user: ${spec}`);
|
|
131
|
+
const uid = m ? Number(m[1]) : Number(row[2]);
|
|
132
|
+
const gid = m?.[2] !== undefined ? Number(m[2]) : row ? Number(row[3]) : uid;
|
|
133
|
+
if (uid === 0)
|
|
134
|
+
throw new UsageError("--run-as names root: the agent's commands would run as root, next to the archil daemon that holds the mount token");
|
|
135
|
+
return { uid, gid, home: row?.[5] || "/", name: row?.[0] ?? String(uid) };
|
|
136
|
+
}
|
|
137
|
+
const need = (v, ...keys) => {
|
|
138
|
+
for (const k of keys)
|
|
139
|
+
if (typeof v[k] !== "string" || v[k] === "")
|
|
140
|
+
throw new UsageError(`--${k} is required`);
|
|
141
|
+
};
|
|
142
|
+
const str = (v, fallback) => (typeof v === "string" ? v : fallback);
|
|
143
|
+
const emit = (line) => process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), ...line })}\n`);
|
|
144
|
+
/** The disk's control API for the supervisor, plus the two lists only the disk record carries (users, clients). */
|
|
145
|
+
async function control(values) {
|
|
146
|
+
const name = str(values["api-key-env"], "ARCHIL_API_KEY");
|
|
147
|
+
const apiKey = process.env[name];
|
|
148
|
+
if (!apiKey)
|
|
149
|
+
throw new UsageError(`no API key in $${name}`);
|
|
150
|
+
const { configure, getDisk } = await import("disk");
|
|
151
|
+
configure({ apiKey, region: str(values.region) });
|
|
152
|
+
const id = str(values.disk);
|
|
153
|
+
const disk = await getDisk(id);
|
|
154
|
+
return {
|
|
155
|
+
getObject: (key) => disk.getObject(key),
|
|
156
|
+
headObject: (key) => disk.headObject(key),
|
|
157
|
+
putObject: (key, body, options) => disk.putObject(key, body, options),
|
|
158
|
+
addUser: (user) => disk.addUser(user),
|
|
159
|
+
removeUser: (type, identifier) => disk.removeUser(type, identifier),
|
|
160
|
+
listDelegations: () => disk.listDelegations(),
|
|
161
|
+
revokeDelegation: (d) => disk.revokeDelegation(d),
|
|
162
|
+
exec: (command) => disk.exec(command),
|
|
163
|
+
listObjects: (prefix, options) => disk.listObjects(prefix, options),
|
|
164
|
+
deleteObjects: (keys, options) => disk.deleteObjects(keys, options),
|
|
165
|
+
listUsers: async () => (await getDisk(id)).authorizedUsers ?? [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ---- run ---------------------------------------------------------------------------------------------------------------
|
|
169
|
+
/**
|
|
170
|
+
* The mount token from stdin, after which stdin is closed: the token is reusable for a day, and a process of the run's
|
|
171
|
+
* user could otherwise reopen it through /proc/<pid>/fd/0. A restart in place reads nothing (the driver emptied the file)
|
|
172
|
+
* and needs nothing: acquire reuses the live mount without the token. A fresh mount with the placeholder is refused.
|
|
173
|
+
*/
|
|
174
|
+
async function readToken(values) {
|
|
175
|
+
if (!values["token-stdin"])
|
|
176
|
+
return "none";
|
|
177
|
+
let text = "";
|
|
178
|
+
for await (const chunk of process.stdin)
|
|
179
|
+
text += chunk;
|
|
180
|
+
try {
|
|
181
|
+
closeSync(0);
|
|
182
|
+
openSync("/dev/null", "r");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// already closed
|
|
186
|
+
}
|
|
187
|
+
return text.trim() || "none";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The instance a host driver starts: openDurableRun with the app module's Harness options (src/app.ts), the
|
|
191
|
+
* driver's handle as run.json's holder, the mount token from stdin (read once, then stdin is closed), and the lease
|
|
192
|
+
* periods. The app loads before anything mounts; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
|
|
193
|
+
* so the unit retries within systemd's start limit; a restart in place reuses the claim). A fence of any kind kills the
|
|
194
|
+
* run's commands and exits 75 inside openDurableRun.
|
|
195
|
+
*
|
|
196
|
+
* `--serve` listens before the run opens and writes its address into the holder, so a client finds it in run.json; an
|
|
197
|
+
* address that is not loopback needs `--serve-token-file` (a 0600 file holding the bearer token), and a wildcard bind
|
|
198
|
+
* (0.0.0.0, ::) needs `--serve-url`, the address clients reach, which is what run.json then carries. `--park-threshold`
|
|
199
|
+
* parks the run when its work only waits longer than that, and with `--serve` an idle run parks too after `--park-idle`
|
|
200
|
+
* (default the threshold), since a request can wake it. `--drain-timeout` bounds the drain on SIGTERM. What happens
|
|
201
|
+
* between the open and the exit is `serveUntilDone`.
|
|
202
|
+
*/
|
|
203
|
+
async function runInstance(values) {
|
|
204
|
+
need(values, "disk", "region", "id", "app");
|
|
205
|
+
const ref = { disk: str(values.disk), region: str(values.region), id: str(values.id) };
|
|
206
|
+
const runAs = typeof values["run-as"] === "string" ? runUser(values["run-as"]) : undefined;
|
|
207
|
+
if (runAs && process.getuid?.() !== 0)
|
|
208
|
+
throw new UsageError("--run-as needs an instance that runs as root (inside a container); elsewhere run the instance as the run user");
|
|
209
|
+
const onSigterm = str(values["on-sigterm"], "resume");
|
|
210
|
+
if (onSigterm !== "resume" && onSigterm !== "pause")
|
|
211
|
+
throw new UsageError("--on-sigterm is resume or pause");
|
|
212
|
+
const ms = (name) => (typeof values[name] === "string" ? parseDuration(values[name]) : undefined);
|
|
213
|
+
const lease = { heartbeatMs: ms("heartbeat-ms"), expiryMs: ms("lease-expiry-ms"), marginMs: ms("lease-margin-ms") };
|
|
214
|
+
const parkMs = ms("park-threshold") || undefined;
|
|
215
|
+
const drainMs = ms("drain-timeout") ?? 0;
|
|
216
|
+
const servePort = str(values.serve);
|
|
217
|
+
if (servePort !== undefined && !/^\d{1,5}$/.test(servePort))
|
|
218
|
+
throw new UsageError("--serve takes a port number (0: any free port)");
|
|
219
|
+
const serveHost = str(values["serve-host"], "127.0.0.1");
|
|
220
|
+
const tokenFile = str(values["serve-token-file"]);
|
|
221
|
+
if (servePort !== undefined && tokenFile === undefined && !isLoopback(serveHost))
|
|
222
|
+
throw new UsageError(`--serve-host ${serveHost} is not loopback: name a --serve-token-file`);
|
|
223
|
+
const serveToken = tokenFile === undefined ? undefined : readServeToken(tokenFile);
|
|
224
|
+
const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
|
|
225
|
+
const root = join(mountRoot, runPath(ref.id));
|
|
226
|
+
const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
|
|
227
|
+
let app;
|
|
228
|
+
try {
|
|
229
|
+
app = await loadApp(str(values.app), { ref, root, work: join(root, "work"), store: join(root, "store") });
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
log("app failed", { code: err.code, message: err.message });
|
|
233
|
+
return exitCodeFor(err);
|
|
234
|
+
}
|
|
235
|
+
const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
|
|
236
|
+
const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
|
|
237
|
+
const cgroup = watchdogOwnsCgroup(holder);
|
|
238
|
+
if (cgroup.warning)
|
|
239
|
+
log("cgroup not owned", { warning: cgroup.warning });
|
|
240
|
+
// The server is up before the run opens (its address goes into the holder); parking starts after.
|
|
241
|
+
let parking;
|
|
242
|
+
let server;
|
|
243
|
+
if (servePort !== undefined) {
|
|
244
|
+
const url = str(values["serve-url"]);
|
|
245
|
+
server = await serveRun({ port: Number(servePort), host: serveHost, ...(url ? { url } : {}), token: serveToken, root: rootOptions, onIdle: () => parking?.check() });
|
|
246
|
+
holder.serve = server.url;
|
|
247
|
+
}
|
|
248
|
+
const idleMs = ms("park-idle") ?? (server ? parkMs : undefined);
|
|
249
|
+
const steps = {};
|
|
250
|
+
let run;
|
|
251
|
+
try {
|
|
252
|
+
run = await openDurableRun(ref, {
|
|
253
|
+
mountToken: await readToken(values),
|
|
254
|
+
mountRoot,
|
|
255
|
+
host: { archil: str(values.archil) },
|
|
256
|
+
harness,
|
|
257
|
+
...(runAs
|
|
258
|
+
? { env: (c) => archilEnv(c, { runAs: { uid: runAs.uid, gid: runAs.gid }, shellEnv: { HOME: runAs.home, USER: runAs.name, LOGNAME: runAs.name } }) }
|
|
259
|
+
: {}),
|
|
260
|
+
holder,
|
|
261
|
+
ownCgroup: cgroup.owns,
|
|
262
|
+
lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
|
|
263
|
+
onStep: (step, t) => void (steps[step] = Math.round(t)),
|
|
264
|
+
...(beforeResume ? { beforeResume } : {}),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
log("open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
|
|
269
|
+
await server?.close();
|
|
270
|
+
return exitCodeFor(err);
|
|
271
|
+
}
|
|
272
|
+
server?.attach(run);
|
|
273
|
+
log("running", { generation: run.generation, steps, ...(server ? { serve: server.url } : {}) });
|
|
274
|
+
return serveUntilDone(run, onOpen, onSigterm, log, process, {
|
|
275
|
+
drainMs,
|
|
276
|
+
...(server ? { server } : {}),
|
|
277
|
+
...(appWake ? { wake: appWake } : {}),
|
|
278
|
+
...(parkMs === undefined ? {} : { park: { thresholdMs: parkMs, ...(idleMs === undefined ? {} : { idleMs }) } }),
|
|
279
|
+
onParking: (p) => void (parking = p),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The instance's life after the run opened: the app's `onOpen` runs; the run parks when `life.park` allows (the wake
|
|
284
|
+
* written, the run released, exit 0); SIGTERM or SIGINT drains and releases, and the drain's outcome is the exit code.
|
|
285
|
+
* A drain stops new submissions and waits up to `life.drainMs` for running work; with `resume` it then writes the run
|
|
286
|
+
* sleeping with its wake (the deadline its work waits for, null when idle and idle parking is on, otherwise now), so
|
|
287
|
+
* the supervisor starts it again and a deploy never strands it; a wake hook that refuses falls back to a due wake.
|
|
288
|
+
* `pause` seals it paused. A rejection of `onOpen` is the app failing (exit 1), except once a drain or a park's release
|
|
289
|
+
* began: closing the Harness under the app makes its work reject ("Session is closed"), and ending the process there
|
|
290
|
+
* would cut the release before its unmount (a container's FUSE daemon dies with the process, leaving the delegation
|
|
291
|
+
* orphaned).
|
|
292
|
+
*/
|
|
293
|
+
export function serveUntilDone(run, onOpen, onSigterm, log, signals = process, life = {}) {
|
|
294
|
+
// Parking and a drain with a limit read the Harness; a caller that asks for them passes the whole run.
|
|
295
|
+
const durable = run;
|
|
296
|
+
const drainMs = life.drainMs ?? 0;
|
|
297
|
+
return new Promise((settle) => {
|
|
298
|
+
// The run's heartbeat and lease timers are unref'd (a library caller's process may end); an instance stays up, with or
|
|
299
|
+
// without work, until it parks, drains or fails, so an ended event loop never exits it without a release.
|
|
300
|
+
const keepAlive = setInterval(() => { }, 2 ** 31 - 1);
|
|
301
|
+
const done = (code) => {
|
|
302
|
+
clearInterval(keepAlive);
|
|
303
|
+
settle(code);
|
|
304
|
+
};
|
|
305
|
+
let releasing = false;
|
|
306
|
+
let parking;
|
|
307
|
+
const sleeping = (at) => run.setStatus("sleeping", { reason: "drained" }, { wakeAt: at === null ? null : new Date(at).toISOString() });
|
|
308
|
+
const shutdown = async (signal) => {
|
|
309
|
+
if (releasing)
|
|
310
|
+
return;
|
|
311
|
+
releasing = true;
|
|
312
|
+
log("draining", { signal, onSigterm, drainMs });
|
|
313
|
+
try {
|
|
314
|
+
if (await parking?.stop())
|
|
315
|
+
return done(0);
|
|
316
|
+
life.server?.pause();
|
|
317
|
+
const state = drainMs > 0 ? await drain(durable, { deadline: Date.now() + drainMs }) : { kind: "busy" };
|
|
318
|
+
if (onSigterm === "resume") {
|
|
319
|
+
const at = state.kind === "waiting" ? state.until : state.kind === "idle" && life.park?.idleMs !== undefined ? null : Date.now();
|
|
320
|
+
await (life.wake ? life.wake(durable, at) : sleeping(at)).catch((err) => {
|
|
321
|
+
if (err instanceof FencedError || durable.fenced)
|
|
322
|
+
throw err;
|
|
323
|
+
return sleeping(Date.now());
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
await run.release();
|
|
327
|
+
log("released", { drained: state.kind, record: run.record });
|
|
328
|
+
done(0);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
log("release failed", { code: err.code, message: err.message });
|
|
332
|
+
done(exitCodeFor(err));
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
signals.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
336
|
+
signals.once("SIGINT", () => void shutdown("SIGINT"));
|
|
337
|
+
if (life.park) {
|
|
338
|
+
parking = watchParking(durable, {
|
|
339
|
+
...life.park,
|
|
340
|
+
// The release follows the wake at once, so from here on the app's work may reject under it.
|
|
341
|
+
wake: async (_target, at) => {
|
|
342
|
+
await (life.wake ?? recordWake)(durable, at);
|
|
343
|
+
releasing = true;
|
|
344
|
+
},
|
|
345
|
+
keepAwake: () => (life.server?.active ?? 0) > 0,
|
|
346
|
+
...(life.server ? { quiesce: () => life.server.pause() } : {}),
|
|
347
|
+
log: (event, detail) => log(event, detail),
|
|
348
|
+
});
|
|
349
|
+
life.onParking?.(parking);
|
|
350
|
+
parking.parked.then((parked) => {
|
|
351
|
+
log("parked", { wakeAt: parked.wakeAt === null ? null : new Date(parked.wakeAt).toISOString(), blocked: parked.blocked, generation: durable.generation });
|
|
352
|
+
done(0);
|
|
353
|
+
}, (err) => {
|
|
354
|
+
log("park release failed", { code: err.code, message: err.message });
|
|
355
|
+
done(exitCodeFor(err));
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
// onOpen may return at once (work submitted) or run for the instance's life.
|
|
359
|
+
Promise.resolve()
|
|
360
|
+
.then(() => onOpen?.(durable))
|
|
361
|
+
.catch((err) => {
|
|
362
|
+
if (releasing)
|
|
363
|
+
return;
|
|
364
|
+
const e = new AppError(`onOpen failed: ${err.message}`, { cause: err });
|
|
365
|
+
log("app failed", { code: e.code, message: e.message });
|
|
366
|
+
done(exitCodeFor(e));
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
// ---- supervise, status, release ----------------------------------------------------------------------------------------
|
|
371
|
+
/**
|
|
372
|
+
* The flags every instance this supervisor starts gets after `run`: `--app MODULE` (made absolute, since the unit's working
|
|
373
|
+
* directory is not the supervisor's) and any `--run-arg`, in that order.
|
|
374
|
+
*/
|
|
375
|
+
export function instanceRunArgs(values) {
|
|
376
|
+
return [...(typeof values.app === "string" ? ["--app", resolve(values.app)] : []), ...(values["run-arg"] ?? [])];
|
|
377
|
+
}
|
|
378
|
+
/** `--park-threshold` for either driver: absent keeps the driver's default, 0 turns parking off. */
|
|
379
|
+
const parkThreshold = (values) => values["park-threshold"] === undefined ? {} : { parkThresholdMs: parseDuration(str(values["park-threshold"])) || null };
|
|
380
|
+
function driverFrom(values) {
|
|
381
|
+
const env = Object.fromEntries((values.env ?? []).map((kv) => {
|
|
382
|
+
const i = kv.indexOf("=");
|
|
383
|
+
if (i <= 0)
|
|
384
|
+
throw new UsageError(`--env takes KEY=VALUE, got ${kv}`);
|
|
385
|
+
return [kv.slice(0, i), kv.slice(i + 1)];
|
|
386
|
+
}));
|
|
387
|
+
const host = str(values.host, "local");
|
|
388
|
+
if (host === "docker") {
|
|
389
|
+
if (typeof values.image !== "string" || !values.image)
|
|
390
|
+
throw new UsageError("--host docker needs --image");
|
|
391
|
+
const apparmor = str(values.apparmor);
|
|
392
|
+
return dockerHost({
|
|
393
|
+
image: values.image,
|
|
394
|
+
docker: str(values.docker),
|
|
395
|
+
fleet: str(values.fleet),
|
|
396
|
+
namePrefix: str(values["name-prefix"]),
|
|
397
|
+
mountRoot: str(values["mount-root"]),
|
|
398
|
+
app: str(values.app),
|
|
399
|
+
appRoot: str(values["app-root"]),
|
|
400
|
+
runArgs: values["run-arg"] ?? [],
|
|
401
|
+
runAs: str(values["run-as"]),
|
|
402
|
+
env,
|
|
403
|
+
dockerArgs: values["docker-arg"] ?? [],
|
|
404
|
+
apparmor: apparmor === "none" ? false : apparmor,
|
|
405
|
+
note: (line) => emit({ event: "docker", note: line }),
|
|
406
|
+
stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
|
|
407
|
+
...parkThreshold(values),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (host !== "local")
|
|
411
|
+
throw new UsageError(`--host is local or docker, got ${host}`);
|
|
412
|
+
const runArgs = instanceRunArgs(values);
|
|
413
|
+
return localHost({
|
|
414
|
+
env,
|
|
415
|
+
mode: str(values.driver, "systemd"),
|
|
416
|
+
mountRoot: str(values["mount-root"]),
|
|
417
|
+
hostName: str(values["host-name"]),
|
|
418
|
+
unitPrefix: str(values["unit-prefix"]),
|
|
419
|
+
user: str(values.user),
|
|
420
|
+
group: str(values.group),
|
|
421
|
+
runArgs,
|
|
422
|
+
stopTimeoutMs: values["stop-timeout"] ? parseDuration(str(values["stop-timeout"])) : undefined,
|
|
423
|
+
restart: values["no-restart"] ? false : undefined,
|
|
424
|
+
logDir: str(values["log-dir"]),
|
|
425
|
+
archil: str(values.archil),
|
|
426
|
+
...parkThreshold(values),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/** Root executes the archil wrapper through sudo, so anyone who can write it is root. */
|
|
430
|
+
function wrapperWarnings(path) {
|
|
431
|
+
let st;
|
|
432
|
+
try {
|
|
433
|
+
st = statSync(path);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return [`${path} does not exist`];
|
|
437
|
+
}
|
|
438
|
+
const out = [];
|
|
439
|
+
if (st.uid !== 0)
|
|
440
|
+
out.push(`${path} is owned by uid ${st.uid}, not root, and root runs it through sudo`);
|
|
441
|
+
if (st.mode & 0o022)
|
|
442
|
+
out.push(`${path} is writable by its group or others (mode ${(st.mode & 0o777).toString(8)})`);
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
async function supervise(values) {
|
|
446
|
+
need(values, "disk", "region");
|
|
447
|
+
const docker = str(values.host, "local") === "docker";
|
|
448
|
+
if (!docker && str(values.host, "local") !== "local")
|
|
449
|
+
throw new UsageError(`--host is local or docker, got ${str(values.host)}`);
|
|
450
|
+
if (docker && values.check)
|
|
451
|
+
throw new UsageError("--check proves this host's own mounts; with --host docker the containers mount, so it does not apply");
|
|
452
|
+
if (docker && (typeof values.image !== "string" || !values.image))
|
|
453
|
+
throw new UsageError("--host docker needs --image");
|
|
454
|
+
const disk = await control(values);
|
|
455
|
+
const controlTimeoutMs = values["control-timeout"] ? parseDuration(str(values["control-timeout"])) : CONTROL_TIMEOUT_MS;
|
|
456
|
+
if (values.check) {
|
|
457
|
+
const report = await checkHost({
|
|
458
|
+
control: disk,
|
|
459
|
+
disk: str(values.disk),
|
|
460
|
+
region: str(values.region),
|
|
461
|
+
mountRoot: str(values["mount-root"], DEFAULT_MOUNT_ROOT),
|
|
462
|
+
owner: ownerOf(values),
|
|
463
|
+
tokenPrefix: str(values["token-prefix"]),
|
|
464
|
+
idPrefix: str(values["check-id-prefix"]),
|
|
465
|
+
controlTimeoutMs,
|
|
466
|
+
onResource: (kind, id, detail) => emit({ event: "check-resource", kind, id, detail }),
|
|
467
|
+
});
|
|
468
|
+
emit({ event: "check", ...report, warnings: str(values.driver, "systemd") === "systemd" ? wrapperWarnings(str(values.archil, ARCHIL_SCOPED)) : [] });
|
|
469
|
+
if (!report.ok)
|
|
470
|
+
return 1;
|
|
471
|
+
if (!values.id && !values.all)
|
|
472
|
+
return 0;
|
|
473
|
+
}
|
|
474
|
+
const ids = async () => {
|
|
475
|
+
if (!values.all)
|
|
476
|
+
return values.id ?? [];
|
|
477
|
+
const listed = await withTimeouts(disk, controlTimeoutMs).listObjects("runs/");
|
|
478
|
+
return listed.commonPrefixes.map((p) => p.replace(/^runs\//, "").replace(/\/$/, "")).filter(Boolean);
|
|
479
|
+
};
|
|
480
|
+
if (!values.id && !values.all && !values["sweep-tokens"])
|
|
481
|
+
throw new UsageError("name runs with --id or --all (or only --sweep-tokens)");
|
|
482
|
+
const host = driverFrom(values);
|
|
483
|
+
const opts = {
|
|
484
|
+
control: disk,
|
|
485
|
+
leaseExpiryMs: values["lease-expiry"] ? parseDuration(str(values["lease-expiry"])) : undefined,
|
|
486
|
+
stonithTimeoutMs: values["stonith-timeout"] ? parseDuration(str(values["stonith-timeout"])) : undefined,
|
|
487
|
+
startGraceMs: values["start-grace"] !== undefined ? parseDuration(str(values["start-grace"])) : undefined,
|
|
488
|
+
startBackoffMaxMs: values["start-backoff-max"] !== undefined ? parseDuration(str(values["start-backoff-max"])) : undefined,
|
|
489
|
+
tokenTtl: str(values["token-ttl"]),
|
|
490
|
+
tokenPrefix: str(values["token-prefix"]),
|
|
491
|
+
demand: Boolean(values.demand),
|
|
492
|
+
// In a container the instance is root and its commands are the run user: the run's root belongs to root, and the
|
|
493
|
+
// instance gives `work/` to the run user (run --run-as).
|
|
494
|
+
create: values.create ? (docker && values.user === undefined ? { uid: 0, gid: 0 } : ownerOf(values)) : undefined,
|
|
495
|
+
controlTimeoutMs,
|
|
496
|
+
};
|
|
497
|
+
let failures = 0;
|
|
498
|
+
const tick = async () => {
|
|
499
|
+
const refs = (await ids()).map((id) => ({ disk: str(values.disk), region: str(values.region), id }));
|
|
500
|
+
for (const line of await superviseRuns(refs, host, opts)) {
|
|
501
|
+
if (line.action === "error")
|
|
502
|
+
failures++;
|
|
503
|
+
emit(line);
|
|
504
|
+
// One line per backoff step: a start that follows failed ones, with the grace it now gets.
|
|
505
|
+
if (line.action === "started" && line.failures > 0)
|
|
506
|
+
emit({ event: "start-backoff", run: line.run, failures: line.failures, lastExit: line.lastExit, graceMs: line.graceMs });
|
|
507
|
+
}
|
|
508
|
+
// Token users no live mount needs: those of released runs past the grace, and with --sweep-tokens the expired ones.
|
|
509
|
+
// A failure here costs this line only; the runs were decided above.
|
|
510
|
+
const timed = withTimeouts(disk, controlTimeoutMs);
|
|
511
|
+
const r = await sweepTokens({
|
|
512
|
+
listUsers: () => timed.listUsers(),
|
|
513
|
+
control: timed,
|
|
514
|
+
prefix: str(values["token-prefix"], TOKEN_PREFIX),
|
|
515
|
+
runs: values.id ? refs.map((x) => x.id) : undefined,
|
|
516
|
+
expired: Boolean(values["sweep-tokens"]),
|
|
517
|
+
graceMs: values["token-grace"] ? parseDuration(str(values["token-grace"])) : undefined,
|
|
518
|
+
}).catch((e) => ({ removed: [], failed: [{ identifier: "*", error: e.message }] }));
|
|
519
|
+
if (r.removed.length || r.failed.length)
|
|
520
|
+
emit({ event: "token-sweep", ...r });
|
|
521
|
+
};
|
|
522
|
+
if (!values.every) {
|
|
523
|
+
await tick();
|
|
524
|
+
return failures ? 1 : 0;
|
|
525
|
+
}
|
|
526
|
+
const every = parseDuration(str(values.every));
|
|
527
|
+
let stopped = false;
|
|
528
|
+
process.once("SIGTERM", () => (stopped = true));
|
|
529
|
+
process.once("SIGINT", () => (stopped = true));
|
|
530
|
+
while (!stopped) {
|
|
531
|
+
const t0 = performance.now();
|
|
532
|
+
// A tick that fails as a whole (the run listing, say) is one line; the loop goes on.
|
|
533
|
+
await tick().catch((err) => emit({ event: "tick-failed", error: err.code ?? "ERROR", message: err.message }));
|
|
534
|
+
for (let left = every - (performance.now() - t0); left > 0 && !stopped; left -= 100)
|
|
535
|
+
await new Promise((r) => setTimeout(r, Math.min(100, left)));
|
|
536
|
+
}
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
async function status(values) {
|
|
540
|
+
need(values, "disk", "region", "id");
|
|
541
|
+
const disk = await control(values);
|
|
542
|
+
const id = str(values.id);
|
|
543
|
+
const run = await readRunStatus(disk, id);
|
|
544
|
+
const { findDelegations } = await import("./claim.js");
|
|
545
|
+
const delegations = await findDelegations(disk, id);
|
|
546
|
+
let holderStatus = null;
|
|
547
|
+
let container = null;
|
|
548
|
+
if (run?.holder?.driver === "docker") {
|
|
549
|
+
const info = await dockerHost({ fleet: String(run.holder.fleet), docker: str(values.docker) }).describe(run.holder).catch(() => null);
|
|
550
|
+
holderStatus = info?.status ?? "unknown";
|
|
551
|
+
container = info;
|
|
552
|
+
}
|
|
553
|
+
else if (run?.holder) {
|
|
554
|
+
holderStatus = await localHost({ hostName: str(values["host-name"]), mode: "systemd", user: process.getuid?.() === 0 ? 0 : undefined }).status(run.holder);
|
|
555
|
+
}
|
|
556
|
+
emit({ run: id, runJson: run, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), holderStatus, ...(container ? { container } : {}) });
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
async function release(values) {
|
|
560
|
+
need(values, "id");
|
|
561
|
+
const via = await unmountClaim(join(str(values["mount-root"], DEFAULT_MOUNT_ROOT), runPath(str(values.id))));
|
|
562
|
+
emit({ run: values.id, released: via });
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
async function forkRun(values) {
|
|
566
|
+
need(values, "disk", "region", "id", "new-id");
|
|
567
|
+
const disk = await control(values);
|
|
568
|
+
const result = await fork({ disk: str(values.disk), region: str(values.region), id: str(values.id) }, str(values["new-id"]), {
|
|
569
|
+
control: disk,
|
|
570
|
+
mountRoot: str(values["mount-root"], DEFAULT_MOUNT_ROOT),
|
|
571
|
+
host: { archil: str(values.archil) },
|
|
572
|
+
tokenPrefix: str(values["token-prefix"]),
|
|
573
|
+
onResource: (kind, id, detail) => emit({ event: "fork-resource", kind, id, detail }),
|
|
574
|
+
});
|
|
575
|
+
emit({ event: "forked", ...result });
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
const COMMON = { disk: { type: "string" }, region: { type: "string" }, id: { type: "string" }, "mount-root": { type: "string" }, "api-key-env": { type: "string" }, "host-name": { type: "string" } };
|
|
579
|
+
export async function main(argv) {
|
|
580
|
+
const [command, ...rest] = argv;
|
|
581
|
+
try {
|
|
582
|
+
switch (command) {
|
|
583
|
+
case "run":
|
|
584
|
+
return await runInstance(parse(rest, {
|
|
585
|
+
...COMMON,
|
|
586
|
+
archil: { type: "string" },
|
|
587
|
+
app: { type: "string" },
|
|
588
|
+
"heartbeat-ms": { type: "string" },
|
|
589
|
+
"lease-expiry-ms": { type: "string" },
|
|
590
|
+
"lease-margin-ms": { type: "string" },
|
|
591
|
+
"on-sigterm": { type: "string" },
|
|
592
|
+
"token-stdin": { type: "boolean" },
|
|
593
|
+
serve: { type: "string" },
|
|
594
|
+
"serve-host": { type: "string" },
|
|
595
|
+
"serve-token-file": { type: "string" },
|
|
596
|
+
"serve-url": { type: "string" },
|
|
597
|
+
"park-threshold": { type: "string" },
|
|
598
|
+
"park-idle": { type: "string" },
|
|
599
|
+
"drain-timeout": { type: "string" },
|
|
600
|
+
"run-as": { type: "string" },
|
|
601
|
+
}));
|
|
602
|
+
case "supervise":
|
|
603
|
+
return await supervise(parse(rest, {
|
|
604
|
+
...COMMON,
|
|
605
|
+
id: { type: "string", multiple: true },
|
|
606
|
+
all: { type: "boolean" },
|
|
607
|
+
every: { type: "string" },
|
|
608
|
+
check: { type: "boolean" },
|
|
609
|
+
driver: { type: "string" },
|
|
610
|
+
"unit-prefix": { type: "string" },
|
|
611
|
+
user: { type: "string" },
|
|
612
|
+
group: { type: "string" },
|
|
613
|
+
app: { type: "string" },
|
|
614
|
+
"run-arg": { type: "string", multiple: true },
|
|
615
|
+
"lease-expiry": { type: "string" },
|
|
616
|
+
"stonith-timeout": { type: "string" },
|
|
617
|
+
"start-grace": { type: "string" },
|
|
618
|
+
"start-backoff-max": { type: "string" },
|
|
619
|
+
"stop-timeout": { type: "string" },
|
|
620
|
+
"token-ttl": { type: "string" },
|
|
621
|
+
"token-prefix": { type: "string" },
|
|
622
|
+
"no-restart": { type: "boolean" },
|
|
623
|
+
"log-dir": { type: "string" },
|
|
624
|
+
demand: { type: "boolean" },
|
|
625
|
+
create: { type: "boolean" },
|
|
626
|
+
env: { type: "string", multiple: true },
|
|
627
|
+
"check-id-prefix": { type: "string" },
|
|
628
|
+
archil: { type: "string" },
|
|
629
|
+
"control-timeout": { type: "string" },
|
|
630
|
+
"sweep-tokens": { type: "boolean" },
|
|
631
|
+
"token-grace": { type: "string" },
|
|
632
|
+
"park-threshold": { type: "string" },
|
|
633
|
+
host: { type: "string" },
|
|
634
|
+
image: { type: "string" },
|
|
635
|
+
docker: { type: "string" },
|
|
636
|
+
fleet: { type: "string" },
|
|
637
|
+
"name-prefix": { type: "string" },
|
|
638
|
+
"app-root": { type: "string" },
|
|
639
|
+
"run-as": { type: "string" },
|
|
640
|
+
"docker-arg": { type: "string", multiple: true },
|
|
641
|
+
apparmor: { type: "string" },
|
|
642
|
+
}));
|
|
643
|
+
case "status":
|
|
644
|
+
return await status(parse(rest, { ...COMMON, docker: { type: "string" } }));
|
|
645
|
+
case "release":
|
|
646
|
+
return await release(parse(rest, COMMON));
|
|
647
|
+
case "fork":
|
|
648
|
+
return await forkRun(parse(rest, { ...COMMON, "new-id": { type: "string" }, archil: { type: "string" }, "token-prefix": { type: "string" } }));
|
|
649
|
+
default:
|
|
650
|
+
throw new UsageError(command ? `unknown command ${command}` : "no command");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
if (err instanceof UsageError)
|
|
655
|
+
process.stderr.write(`${err.message}\n${USAGE}\n`);
|
|
656
|
+
else
|
|
657
|
+
process.stderr.write(`${JSON.stringify({ error: err.code ?? "ERROR", message: err.message })}\n`);
|
|
658
|
+
return exitCodeFor(err);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const invoked = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : "";
|
|
662
|
+
if (import.meta.url === invoked) {
|
|
663
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
664
|
+
}
|