@parcha/pi-durable-disk 0.1.0-beta.13 → 0.1.0-beta.15
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 +262 -0
- package/README.md +281 -47
- package/dist/app.d.ts +5 -1
- package/dist/claim.d.ts +51 -3
- package/dist/claim.js +157 -17
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +112 -47
- package/dist/commands.d.ts +4 -1
- package/dist/commands.js +48 -90
- package/dist/confine.d.ts +36 -7
- package/dist/confine.js +125 -18
- package/dist/env.d.ts +7 -22
- package/dist/env.js +25 -60
- package/dist/errors.d.ts +2 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/attempt-env.d.ts +3 -0
- package/dist/hosts/attempt-env.js +15 -0
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +35 -12
- package/dist/hosts/daytona.d.ts +2 -2
- package/dist/hosts/daytona.js +48 -5
- package/dist/hosts/docker.d.ts +2 -2
- package/dist/hosts/docker.js +22 -5
- package/dist/hosts/local-host.d.ts +9 -3
- package/dist/hosts/local-host.js +59 -22
- package/dist/index.d.ts +35 -16
- package/dist/index.js +27 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +4 -1
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/place.d.ts +190 -0
- package/dist/place.js +526 -0
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +61 -10
- package/dist/run.js +69 -33
- package/dist/status.d.ts +19 -3
- package/dist/status.js +25 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +43 -1
- package/dist/supervise.js +26 -1
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/dist/with-claim.d.ts +63 -0
- package/dist/with-claim.js +325 -0
- package/package.json +9 -3
package/dist/claim.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface ArchilHost {
|
|
|
55
55
|
cli?: number;
|
|
56
56
|
staleGrace?: number;
|
|
57
57
|
};
|
|
58
|
-
/** File access on the mount; replaceable so tests can inject a dead mount (ENOTCONN) or a fence (EIO). */
|
|
58
|
+
/** File access on the mount; replaceable so tests can inject a dead mount (ENOTCONN, ECONNABORTED) or a fence (EIO). */
|
|
59
59
|
fs?: {
|
|
60
60
|
stat?(path: string): Promise<unknown>;
|
|
61
61
|
persist?(path: string, data: string): Promise<void>;
|
|
@@ -143,7 +143,7 @@ export declare function parseTokenNickname(nickname: string, prefix?: string): {
|
|
|
143
143
|
* whose TTL passes fences its mount the same way at the next refresh, so an instance never outlives its token by more
|
|
144
144
|
* than one refresh. The nickname is given, or built from `run` so the user can be traced back to its run.
|
|
145
145
|
*/
|
|
146
|
-
export declare function mintMountToken(control: ControlApi, opts: {
|
|
146
|
+
export declare function mintMountToken(control: Pick<ControlApi, "addUser">, opts: {
|
|
147
147
|
nickname?: string;
|
|
148
148
|
run?: {
|
|
149
149
|
id: string;
|
|
@@ -158,7 +158,7 @@ export declare function mintMountToken(control: ControlApi, opts: {
|
|
|
158
158
|
identifier: string;
|
|
159
159
|
nickname: string;
|
|
160
160
|
}>;
|
|
161
|
-
export declare function removeMountToken(control: ControlApi, identifier: string): Promise<void>;
|
|
161
|
+
export declare function removeMountToken(control: Pick<ControlApi, "removeUser">, identifier: string): Promise<void>;
|
|
162
162
|
/**
|
|
163
163
|
* Delegations on `runs/<id>` and anything under it; never a sibling or a parent. The control API resolves a delegation's
|
|
164
164
|
* path best-effort: it can list one with no path, live or orphaned, on a never-reused run. So when nothing matches by
|
|
@@ -205,12 +205,35 @@ export declare function revoke(control: ControlApi, id: string): Promise<Delegat
|
|
|
205
205
|
export declare function revokeBestEffort(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, id: string): Promise<Delegation[]>;
|
|
206
206
|
/** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
|
|
207
207
|
export declare function takeOver(control: ControlApi, opts: AcquireOptions): Promise<Claim>;
|
|
208
|
+
/** A path of a disk, as one exclusive mount names it: `runs/<id>` for a run, any safe path for `withClaim`. */
|
|
209
|
+
export type MountTarget = {
|
|
210
|
+
readonly disk: string;
|
|
211
|
+
readonly region: string;
|
|
212
|
+
readonly path: string;
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
|
|
216
|
+
* for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
|
|
217
|
+
*/
|
|
218
|
+
export declare const DEAD_CONNECTION: ReadonlySet<string>;
|
|
208
219
|
/**
|
|
209
220
|
* Unmount whatever the run has at `mountpoint`: `archil unmount` flushes and checks the delegation in; a dead mount
|
|
210
221
|
* (which `archil unmount` refuses) is cleaned with `fusermount -u`; a live mount refused with ENOENT (Sysbox) is checked
|
|
211
222
|
* in and moved aside. Any other live mount that refuses is never forced.
|
|
212
223
|
*/
|
|
213
224
|
export declare function unmountClaim(mountpoint: string, host?: ArchilHost): Promise<Unmounted>;
|
|
225
|
+
/** The most entries of a bare mountpoint the stray check looks at; past it, nothing is moved aside. */
|
|
226
|
+
export declare const STRAY_CHECK_LIMIT = 100000;
|
|
227
|
+
/**
|
|
228
|
+
* The entries under `dir` written since `since` (ms since the epoch), as paths relative to it, walked without following
|
|
229
|
+
* links. `unchecked` says why the walk could not look at every entry: more than `limit` of them, or one it could not
|
|
230
|
+
* read (an entry gone meanwhile is not one). A directory keeps its time when a file in it is rewritten in place, so only
|
|
231
|
+
* a walk that looked at every entry shows there is none.
|
|
232
|
+
*/
|
|
233
|
+
export declare function writtenSince(dir: string, since: number, limit?: number): Promise<{
|
|
234
|
+
fresh: string[];
|
|
235
|
+
unchecked: string | null;
|
|
236
|
+
}>;
|
|
214
237
|
/**
|
|
215
238
|
* Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
|
|
216
239
|
* the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
|
|
@@ -219,4 +242,29 @@ export declare function unmountClaim(mountpoint: string, host?: ArchilHost): Pro
|
|
|
219
242
|
* verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
|
|
220
243
|
* empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
|
|
221
244
|
*/
|
|
245
|
+
/**
|
|
246
|
+
* The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
|
|
247
|
+
* to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
|
|
248
|
+
*/
|
|
249
|
+
export declare const NO_TOKEN = "none";
|
|
222
250
|
export declare function acquire(opts: AcquireOptions): Promise<Claim>;
|
|
251
|
+
/** One exclusive mount of a path, as `withClaim` holds it: its own mountpoint, never reused. */
|
|
252
|
+
export interface ExclusiveMount {
|
|
253
|
+
readonly root: string;
|
|
254
|
+
/** `archil sync`; a refusal (the mount was revoked) is a FencedError. */
|
|
255
|
+
barrier(): Promise<{
|
|
256
|
+
ms: number;
|
|
257
|
+
}>;
|
|
258
|
+
/** `archil unmount` (a dead mount is cleaned), and the mountpoint removed. */
|
|
259
|
+
unmount(): Promise<Unmounted>;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Mount `<disk>:/<path>` exclusively at `mountpoint`, a fresh directory of the caller's own, and verify it, with `probe` as
|
|
263
|
+
* the claim probe's content. Another client's delegation is HeldError CLAIM_HELD (`held` is added to its message); the
|
|
264
|
+
* rest fails as `acquire` does. Unlike `acquire`, a mount already there is never reused, and nothing is forced.
|
|
265
|
+
*/
|
|
266
|
+
export declare function exclusiveMount(target: MountTarget, mountpoint: string, token: string, options: {
|
|
267
|
+
readonly host?: ArchilHost;
|
|
268
|
+
readonly probe: string;
|
|
269
|
+
readonly held?: string;
|
|
270
|
+
}): Promise<ExclusiveMount>;
|
package/dist/claim.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// a mount token and uses the archil client: mount, verify, barrier, release, dead-mount cleanup.
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { readdirSync, readFileSync } from "node:fs";
|
|
6
|
-
import { mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
|
|
6
|
+
import { lstat, mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
|
|
7
7
|
import { basename, isAbsolute, join } from "node:path";
|
|
8
8
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -364,18 +364,29 @@ async function mountEntry(host, mountpoint) {
|
|
|
364
364
|
}
|
|
365
365
|
return found;
|
|
366
366
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
367
|
+
const targetOf = (ref) => ({ disk: ref.disk, region: ref.region, path: runPath(ref.id) });
|
|
368
|
+
function isOurs(entry, target) {
|
|
369
|
+
const base = `${target.disk}:/${target.path}`;
|
|
370
|
+
return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${target.region}]`);
|
|
370
371
|
}
|
|
371
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
|
|
374
|
+
* for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
|
|
375
|
+
*/
|
|
376
|
+
export const DEAD_CONNECTION = new Set(["ENOTCONN", "ECONNABORTED"]);
|
|
377
|
+
/**
|
|
378
|
+
* Whether the FUSE daemon behind a mount is gone, which `archil` reports as "not running". Its connection is torn down,
|
|
379
|
+
* so a stat fails with ENOTCONN; a stat already in flight while the dying daemon's connection is torn down (right after a
|
|
380
|
+
* kill -9, while its threads exit) fails with ECONNABORTED instead. Both are the connection's own end: a live daemon,
|
|
381
|
+
* however slow, answers, and a stopped one keeps the stat waiting.
|
|
382
|
+
*/
|
|
372
383
|
async function isDead(host, mountpoint) {
|
|
373
384
|
try {
|
|
374
385
|
await host.stat(mountpoint);
|
|
375
386
|
return false;
|
|
376
387
|
}
|
|
377
388
|
catch (err) {
|
|
378
|
-
return err.code
|
|
389
|
+
return DEAD_CONNECTION.has(String(err.code));
|
|
379
390
|
}
|
|
380
391
|
}
|
|
381
392
|
// Exit codes of the mount tools are not evidence (a busy mount can report success and stay); the mount table decides.
|
|
@@ -402,10 +413,13 @@ async function moveAside(host, mountpoint, code) {
|
|
|
402
413
|
if (!ok(r))
|
|
403
414
|
throw new ClaimError(code, `${mountpoint} was moved aside but its daemon was not stopped (${detail(r)})`);
|
|
404
415
|
}
|
|
416
|
+
/** The archil client's own directory on the disk, where it keeps its bookkeeping (`client-<n>`, `unlinked`). */
|
|
417
|
+
const ARCHIL_BOOKKEEPING = ".archil";
|
|
405
418
|
/**
|
|
406
419
|
* Release a live mount the kernel will not unmount (Sysbox): `archil checkin` gives the delegation back, the client must
|
|
407
|
-
* then list none on the run
|
|
408
|
-
* mount where it is, as UNMOUNT_FAILED: a mount that may still
|
|
420
|
+
* then list none on the run (its own bookkeeping under `.archil/` is not the run's), and only then is the mount moved
|
|
421
|
+
* aside and its daemon killed. Any failed step leaves the mount where it is, as UNMOUNT_FAILED: a mount that may still
|
|
422
|
+
* hold the delegation is never abandoned.
|
|
409
423
|
*/
|
|
410
424
|
async function checkinAndMoveAside(host, mountpoint) {
|
|
411
425
|
const c = await archil(host, ["checkin", mountpoint], host.timeoutMs.unmount);
|
|
@@ -419,7 +433,11 @@ async function checkinAndMoveAside(host, mountpoint) {
|
|
|
419
433
|
catch {
|
|
420
434
|
list = null;
|
|
421
435
|
}
|
|
422
|
-
|
|
436
|
+
// The client also lists its own bookkeeping on the disk, `<mp>/.archil/client-<n>` and its `unlinked` (made when a
|
|
437
|
+
// file under the mount is deleted), which a checkin of the run leaves and none can clear once the mount is read-only.
|
|
438
|
+
// It is outside the run's tree, so it never holds the run: every other delegation under the mount does.
|
|
439
|
+
const bookkeeping = (p) => p === `${mountpoint}/${ARCHIL_BOOKKEEPING}` || p.startsWith(`${mountpoint}/${ARCHIL_BOOKKEEPING}/`);
|
|
440
|
+
const mine = (p) => p === mountpoint || (typeof p === "string" && p.startsWith(`${mountpoint}/`) && !bookkeeping(p));
|
|
423
441
|
const held = !Array.isArray(list) || list.some((x) => x?.state === "Active" && mine(x.path));
|
|
424
442
|
if (held)
|
|
425
443
|
throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} still lists a delegation after archil checkin: ${(ok(d) ? d.stdout : detail(d)).trim().slice(0, 300)}`);
|
|
@@ -470,20 +488,23 @@ async function ensureMountpoint(host, dir) {
|
|
|
470
488
|
* goes to the wrapper as one line on stdin (`bin/archil-scoped`), with or without sudo.
|
|
471
489
|
*/
|
|
472
490
|
async function mountRun(host, ref, mountpoint, token, force) {
|
|
473
|
-
|
|
474
|
-
|
|
491
|
+
await mountTarget(host, targetOf(ref), mountpoint, token, force);
|
|
492
|
+
}
|
|
493
|
+
async function mountTarget(host, target, mountpoint, token, force, held = "") {
|
|
494
|
+
const { path } = target;
|
|
495
|
+
const args = [host.archil, "mount", ...(force ? ["--force"] : []), `${target.disk}:/${path}`, mountpoint, "--region", target.region];
|
|
475
496
|
const r = await sh(asRoot(host, args), host.timeoutMs.mount, `${token}\n`);
|
|
476
497
|
const entry = await mountEntry(host, mountpoint);
|
|
477
|
-
if (ok(r) && entry && isOurs(entry,
|
|
498
|
+
if (ok(r) && entry && isOurs(entry, target))
|
|
478
499
|
return;
|
|
479
500
|
if (entry)
|
|
480
501
|
await unmountWith(host, mountpoint).catch(() => { });
|
|
481
502
|
if (ok(r))
|
|
482
|
-
throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of
|
|
503
|
+
throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of ${path}`);
|
|
483
504
|
if (STALE_MARKER.test(r.stderr))
|
|
484
505
|
throw new ClaimError("MOUNT_FAILED", `an archil daemon of this host still runs for ${mountpoint}: ${detail(r, token)}`);
|
|
485
506
|
if (!force && r.stderr.includes(HELD_MARKER))
|
|
486
|
-
throw new HeldError("claim", `${path} is held by another client (${detail(r, token)})`);
|
|
507
|
+
throw new HeldError("claim", `${path} is held by another client${held} (${detail(r, token)})`);
|
|
487
508
|
if (r.stderr.includes(NOT_EMPTY_MARKER) || (MISLEADING_REFUSAL.test(r.stderr) && (await entries(mountpoint)) > 0)) {
|
|
488
509
|
throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `archil mount ${path} refused ${mountpoint}, which is not empty: ${detail(r, token)}`);
|
|
489
510
|
}
|
|
@@ -548,6 +569,58 @@ async function clearStaleDaemon(host, ref, mountpoint) {
|
|
|
548
569
|
process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stale archil daemon killed", run: ref.id, pids })}\n`);
|
|
549
570
|
return pids;
|
|
550
571
|
}
|
|
572
|
+
/** The most entries of a bare mountpoint the stray check looks at; past it, nothing is moved aside. */
|
|
573
|
+
export const STRAY_CHECK_LIMIT = 100_000;
|
|
574
|
+
/**
|
|
575
|
+
* The entries under `dir` written since `since` (ms since the epoch), as paths relative to it, walked without following
|
|
576
|
+
* links. `unchecked` says why the walk could not look at every entry: more than `limit` of them, or one it could not
|
|
577
|
+
* read (an entry gone meanwhile is not one). A directory keeps its time when a file in it is rewritten in place, so only
|
|
578
|
+
* a walk that looked at every entry shows there is none.
|
|
579
|
+
*/
|
|
580
|
+
export async function writtenSince(dir, since, limit = STRAY_CHECK_LIMIT) {
|
|
581
|
+
const fresh = [];
|
|
582
|
+
const dirs = [""];
|
|
583
|
+
let seen = 0;
|
|
584
|
+
const failed = (error) => {
|
|
585
|
+
const code = error.code;
|
|
586
|
+
return code === "ENOENT" ? null : `an entry it cannot read (${code ?? "an unknown error"})`;
|
|
587
|
+
};
|
|
588
|
+
for (let i = 0; i < dirs.length; i++) {
|
|
589
|
+
const rel = dirs[i];
|
|
590
|
+
let names;
|
|
591
|
+
try {
|
|
592
|
+
names = await readdir(join(dir, rel));
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
const unchecked = failed(error);
|
|
596
|
+
if (unchecked)
|
|
597
|
+
return { fresh, unchecked };
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
for (const name of names.sort()) {
|
|
601
|
+
if (++seen > limit)
|
|
602
|
+
return { fresh, unchecked: `more than ${limit} entries` };
|
|
603
|
+
const path = rel ? `${rel}/${name}` : name;
|
|
604
|
+
let st;
|
|
605
|
+
try {
|
|
606
|
+
st = await lstat(join(dir, path));
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
const unchecked = failed(error);
|
|
610
|
+
if (unchecked)
|
|
611
|
+
return { fresh, unchecked };
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
if (st.mtimeMs >= since)
|
|
615
|
+
fresh.push(path);
|
|
616
|
+
if (st.isDirectory())
|
|
617
|
+
dirs.push(path);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return { fresh, unchecked: null };
|
|
621
|
+
}
|
|
622
|
+
/** The exit of a start refused for the app's own writes before its claim (EX_DATAERR): a restart would write them again. */
|
|
623
|
+
const EXIT_WRITTEN_BEFORE_MOUNT = 65;
|
|
551
624
|
/** How many entries a directory holds; 0 when it cannot be read. */
|
|
552
625
|
const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
|
|
553
626
|
/**
|
|
@@ -556,11 +629,28 @@ const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
|
|
|
556
629
|
* directory with its owner and mode: archil refuses to mount over entries, and nothing in them may enter the run. One
|
|
557
630
|
* line on stderr names the run and the count of entries, never a name or a byte of them. An unreadable mountpoint goes
|
|
558
631
|
* to the wrapper too, which looks as root.
|
|
632
|
+
*
|
|
633
|
+
* Entries written since this process started are not an earlier incarnation's leftovers but this one's own writes
|
|
634
|
+
* before its claim mounted (an app's factory writing under ctx.root, which runs before the mount): moving them aside
|
|
635
|
+
* would lose them silently and the run would later find them gone (ENOENT). The start is refused instead,
|
|
636
|
+
* MOUNTPOINT_WRITTEN (exit 65, terminal: a restart writes them again), naming the first of them. A mountpoint whose
|
|
637
|
+
* entries the check cannot all look at (more than STRAY_CHECK_LIMIT, or one this process cannot read) may hide such a
|
|
638
|
+
* write, so it is refused too, MOUNTPOINT_UNCHECKED (exit 65: someone looks at it), naming none of them.
|
|
559
639
|
*/
|
|
560
640
|
async function moveStrayAside(host, ref, mountRoot, mountpoint) {
|
|
561
641
|
const names = await readdir(mountpoint).catch(() => null);
|
|
562
642
|
if (names && names.length === 0)
|
|
563
643
|
return null;
|
|
644
|
+
if (names) {
|
|
645
|
+
const { fresh, unchecked } = await writtenSince(mountpoint, performance.timeOrigin);
|
|
646
|
+
if (fresh.length > 0) {
|
|
647
|
+
const named = fresh.slice(0, 4).map((p) => `<mp>/${p}`).join(", ");
|
|
648
|
+
throw new ClaimError("MOUNTPOINT_WRITTEN", `the bare mountpoint <mp> of ${runPath(ref.id)} holds ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"} written since this incarnation started, before its claim mounted: ${named}`.slice(0, 240), { exitCode: EXIT_WRITTEN_BEFORE_MOUNT });
|
|
649
|
+
}
|
|
650
|
+
if (unchecked) {
|
|
651
|
+
throw new ClaimError("MOUNTPOINT_UNCHECKED", `the bare mountpoint <mp> of ${runPath(ref.id)} holds ${unchecked}, so this incarnation cannot rule out its own writes before its claim mounted: nothing is moved aside. Look at it, then move it aside by hand`, { exitCode: EXIT_WRITTEN_BEFORE_MOUNT });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
564
654
|
const tag = Date.now().toString(36);
|
|
565
655
|
const r = await archil(host, ["stray", mountpoint, tag]);
|
|
566
656
|
if (!ok(r))
|
|
@@ -586,7 +676,10 @@ async function moveStrayAside(host, ref, mountRoot, mountpoint) {
|
|
|
586
676
|
* the run but not that this mount does.
|
|
587
677
|
*/
|
|
588
678
|
async function verify(host, ref, mountpoint) {
|
|
589
|
-
|
|
679
|
+
await verifyTarget(host, runPath(ref.id), mountpoint, `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
|
|
680
|
+
}
|
|
681
|
+
/** `verify` for any path; `probe` is what the claim probe holds. */
|
|
682
|
+
async function verifyTarget(host, path, mountpoint, probe) {
|
|
590
683
|
const r = await archil(host, ["delegations", "--json", mountpoint]);
|
|
591
684
|
if (!ok(r))
|
|
592
685
|
throw new ClaimError("CLAIM_NOT_VERIFIED", `archil delegations ${mountpoint}: ${detail(r)}`);
|
|
@@ -602,7 +695,7 @@ async function verify(host, ref, mountpoint) {
|
|
|
602
695
|
if (!active)
|
|
603
696
|
throw new ClaimError("CLAIM_NOT_VERIFIED", `${mountpoint} holds no Active delegation on ${path}: ${r.stdout.trim().slice(0, 300)}`);
|
|
604
697
|
try {
|
|
605
|
-
await host.persist(join(mountpoint, CLAIM_PROBE),
|
|
698
|
+
await host.persist(join(mountpoint, CLAIM_PROBE), probe);
|
|
606
699
|
}
|
|
607
700
|
catch (err) {
|
|
608
701
|
throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
|
|
@@ -616,6 +709,11 @@ async function verify(host, ref, mountpoint) {
|
|
|
616
709
|
* verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
|
|
617
710
|
* empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
|
|
618
711
|
*/
|
|
712
|
+
/**
|
|
713
|
+
* The token an instance holds when it was given none (`--token-stdin` read nothing): enough to reuse its live mount, never
|
|
714
|
+
* to mount. A restart in place gets it from a driver that hands the token to the first incarnation only (localHost).
|
|
715
|
+
*/
|
|
716
|
+
export const NO_TOKEN = "none";
|
|
619
717
|
export async function acquire(opts) {
|
|
620
718
|
const { ref, token } = opts;
|
|
621
719
|
const path = runPath(ref.id);
|
|
@@ -631,7 +729,7 @@ export async function acquire(opts) {
|
|
|
631
729
|
let reused = false;
|
|
632
730
|
const existing = await mountEntry(host, root);
|
|
633
731
|
if (existing) {
|
|
634
|
-
if (!isOurs(existing, ref))
|
|
732
|
+
if (!isOurs(existing, targetOf(ref)))
|
|
635
733
|
throw new ClaimError("MOUNTPOINT_BUSY", `${root} already holds ${existing.fstype} ${existing.source}`);
|
|
636
734
|
if (await isDead(host, root))
|
|
637
735
|
await cleanDead(host, root);
|
|
@@ -639,6 +737,11 @@ export async function acquire(opts) {
|
|
|
639
737
|
reused = true;
|
|
640
738
|
}
|
|
641
739
|
let stray = null;
|
|
740
|
+
// No live mount to reuse and no token to mount with: a restart whose mount went with the failed incarnation. It cannot
|
|
741
|
+
// hold the run again, so it is fenced (75, terminal) at once, and the next start of the run brings a new token.
|
|
742
|
+
if (!reused && token === NO_TOKEN) {
|
|
743
|
+
throw new FencedError(`no live mount of ${path} to reuse at ${root}, and this incarnation was given no mount token: it cannot mount the run again; a new start brings a new token`, { code: "CLAIM_GONE_NO_TOKEN" });
|
|
744
|
+
}
|
|
642
745
|
if (!reused) {
|
|
643
746
|
await ensureMountpoint(host, root);
|
|
644
747
|
await clearStaleDaemon(host, ref, root);
|
|
@@ -720,3 +823,40 @@ class ArchilClaim {
|
|
|
720
823
|
return { via };
|
|
721
824
|
}
|
|
722
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* Mount `<disk>:/<path>` exclusively at `mountpoint`, a fresh directory of the caller's own, and verify it, with `probe` as
|
|
828
|
+
* the claim probe's content. Another client's delegation is HeldError CLAIM_HELD (`held` is added to its message); the
|
|
829
|
+
* rest fails as `acquire` does. Unlike `acquire`, a mount already there is never reused, and nothing is forced.
|
|
830
|
+
*/
|
|
831
|
+
export async function exclusiveMount(target, mountpoint, token, options) {
|
|
832
|
+
if (!token || /[\r\n]/.test(token))
|
|
833
|
+
throw new ClaimError("INVALID_ARGUMENT", "no mount token, or one with a line break");
|
|
834
|
+
const host = resolveHost(options.host);
|
|
835
|
+
if (await mountEntry(host, mountpoint))
|
|
836
|
+
throw new ClaimError("MOUNTPOINT_BUSY", `${mountpoint} is already a mount`);
|
|
837
|
+
await ensureMountpoint(host, mountpoint);
|
|
838
|
+
try {
|
|
839
|
+
await mountTarget(host, target, mountpoint, token, false, options.held);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
await rmdir(mountpoint).catch(() => { });
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
await verifyTarget(host, target.path, mountpoint, options.probe);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
await unmountWith(host, mountpoint).catch(() => { });
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
root: mountpoint,
|
|
854
|
+
async barrier() {
|
|
855
|
+
const r = await archil(host, ["sync", mountpoint], host.timeoutMs.sync);
|
|
856
|
+
if (ok(r))
|
|
857
|
+
return { ms: r.ms };
|
|
858
|
+
throw new FencedError(`archil sync ${mountpoint}: ${detail(r)}`);
|
|
859
|
+
},
|
|
860
|
+
unmount: () => unmountWith(host, mountpoint),
|
|
861
|
+
};
|
|
862
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type Parking } from "./park.js";
|
|
3
3
|
import { type RunServer } from "./serve.js";
|
|
4
|
+
import { type ArchilControl, type DiskClient } from "./supervise.js";
|
|
4
5
|
import { type DurableRun } from "./run.js";
|
|
5
6
|
/** "30s", "500ms", "2m", "1h", or plain milliseconds. */
|
|
6
7
|
/**
|
|
@@ -28,6 +29,11 @@ export declare function runUser(spec: string, passwd?: string): {
|
|
|
28
29
|
home: string;
|
|
29
30
|
name: string;
|
|
30
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The disk's control API for the supervisor (`archilControl`), from the API key in `--api-key-env` (default
|
|
34
|
+
* ARCHIL_API_KEY) and `--disk` and `--region`. `client` replaces the `disk` client (tests).
|
|
35
|
+
*/
|
|
36
|
+
export declare function cliControl(values: Record<string, unknown>, client?: DiskClient): Promise<ArchilControl>;
|
|
31
37
|
/** What the instance does besides the app between its open and its exit. */
|
|
32
38
|
export interface InstanceLife {
|
|
33
39
|
/** How long a drain waits for running work before the release; 0 (default) releases at once. */
|