@parcha/pi-durable-disk 0.1.0-beta.14 → 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 +166 -0
- package/README.md +181 -14
- package/dist/app.d.ts +5 -1
- package/dist/claim.d.ts +45 -2
- package/dist/claim.js +145 -15
- package/dist/cli.js +16 -15
- package/dist/errors.d.ts +2 -1
- package/dist/hosts/attempt-env.d.ts +3 -0
- package/dist/hosts/attempt-env.js +15 -0
- package/dist/hosts/daytona-launch.js +8 -7
- package/dist/hosts/daytona.d.ts +2 -2
- package/dist/hosts/daytona.js +33 -2
- package/dist/hosts/docker.d.ts +2 -2
- package/dist/hosts/docker.js +22 -5
- package/dist/hosts/local-host.d.ts +2 -2
- package/dist/hosts/local-host.js +34 -6
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/lease.d.ts +1 -1
- package/dist/place.d.ts +190 -0
- package/dist/place.js +526 -0
- package/dist/run.d.ts +51 -10
- package/dist/run.js +65 -32
- package/dist/status.d.ts +8 -0
- package/dist/status.js +10 -0
- package/dist/supervise.d.ts +24 -1
- package/dist/supervise.js +1 -1
- package/dist/with-claim.d.ts +63 -0
- package/dist/with-claim.js +325 -0
- package/package.json +2 -1
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,9 +364,10 @@ 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
|
/**
|
|
372
373
|
* The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
|
|
@@ -412,10 +413,13 @@ async function moveAside(host, mountpoint, code) {
|
|
|
412
413
|
if (!ok(r))
|
|
413
414
|
throw new ClaimError(code, `${mountpoint} was moved aside but its daemon was not stopped (${detail(r)})`);
|
|
414
415
|
}
|
|
416
|
+
/** The archil client's own directory on the disk, where it keeps its bookkeeping (`client-<n>`, `unlinked`). */
|
|
417
|
+
const ARCHIL_BOOKKEEPING = ".archil";
|
|
415
418
|
/**
|
|
416
419
|
* Release a live mount the kernel will not unmount (Sysbox): `archil checkin` gives the delegation back, the client must
|
|
417
|
-
* then list none on the run
|
|
418
|
-
* 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.
|
|
419
423
|
*/
|
|
420
424
|
async function checkinAndMoveAside(host, mountpoint) {
|
|
421
425
|
const c = await archil(host, ["checkin", mountpoint], host.timeoutMs.unmount);
|
|
@@ -429,7 +433,11 @@ async function checkinAndMoveAside(host, mountpoint) {
|
|
|
429
433
|
catch {
|
|
430
434
|
list = null;
|
|
431
435
|
}
|
|
432
|
-
|
|
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));
|
|
433
441
|
const held = !Array.isArray(list) || list.some((x) => x?.state === "Active" && mine(x.path));
|
|
434
442
|
if (held)
|
|
435
443
|
throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} still lists a delegation after archil checkin: ${(ok(d) ? d.stdout : detail(d)).trim().slice(0, 300)}`);
|
|
@@ -480,20 +488,23 @@ async function ensureMountpoint(host, dir) {
|
|
|
480
488
|
* goes to the wrapper as one line on stdin (`bin/archil-scoped`), with or without sudo.
|
|
481
489
|
*/
|
|
482
490
|
async function mountRun(host, ref, mountpoint, token, force) {
|
|
483
|
-
|
|
484
|
-
|
|
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];
|
|
485
496
|
const r = await sh(asRoot(host, args), host.timeoutMs.mount, `${token}\n`);
|
|
486
497
|
const entry = await mountEntry(host, mountpoint);
|
|
487
|
-
if (ok(r) && entry && isOurs(entry,
|
|
498
|
+
if (ok(r) && entry && isOurs(entry, target))
|
|
488
499
|
return;
|
|
489
500
|
if (entry)
|
|
490
501
|
await unmountWith(host, mountpoint).catch(() => { });
|
|
491
502
|
if (ok(r))
|
|
492
|
-
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}`);
|
|
493
504
|
if (STALE_MARKER.test(r.stderr))
|
|
494
505
|
throw new ClaimError("MOUNT_FAILED", `an archil daemon of this host still runs for ${mountpoint}: ${detail(r, token)}`);
|
|
495
506
|
if (!force && r.stderr.includes(HELD_MARKER))
|
|
496
|
-
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)})`);
|
|
497
508
|
if (r.stderr.includes(NOT_EMPTY_MARKER) || (MISLEADING_REFUSAL.test(r.stderr) && (await entries(mountpoint)) > 0)) {
|
|
498
509
|
throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `archil mount ${path} refused ${mountpoint}, which is not empty: ${detail(r, token)}`);
|
|
499
510
|
}
|
|
@@ -558,6 +569,58 @@ async function clearStaleDaemon(host, ref, mountpoint) {
|
|
|
558
569
|
process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stale archil daemon killed", run: ref.id, pids })}\n`);
|
|
559
570
|
return pids;
|
|
560
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;
|
|
561
624
|
/** How many entries a directory holds; 0 when it cannot be read. */
|
|
562
625
|
const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
|
|
563
626
|
/**
|
|
@@ -566,11 +629,28 @@ const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
|
|
|
566
629
|
* directory with its owner and mode: archil refuses to mount over entries, and nothing in them may enter the run. One
|
|
567
630
|
* line on stderr names the run and the count of entries, never a name or a byte of them. An unreadable mountpoint goes
|
|
568
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.
|
|
569
639
|
*/
|
|
570
640
|
async function moveStrayAside(host, ref, mountRoot, mountpoint) {
|
|
571
641
|
const names = await readdir(mountpoint).catch(() => null);
|
|
572
642
|
if (names && names.length === 0)
|
|
573
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
|
+
}
|
|
574
654
|
const tag = Date.now().toString(36);
|
|
575
655
|
const r = await archil(host, ["stray", mountpoint, tag]);
|
|
576
656
|
if (!ok(r))
|
|
@@ -596,7 +676,10 @@ async function moveStrayAside(host, ref, mountRoot, mountpoint) {
|
|
|
596
676
|
* the run but not that this mount does.
|
|
597
677
|
*/
|
|
598
678
|
async function verify(host, ref, mountpoint) {
|
|
599
|
-
|
|
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) {
|
|
600
683
|
const r = await archil(host, ["delegations", "--json", mountpoint]);
|
|
601
684
|
if (!ok(r))
|
|
602
685
|
throw new ClaimError("CLAIM_NOT_VERIFIED", `archil delegations ${mountpoint}: ${detail(r)}`);
|
|
@@ -612,7 +695,7 @@ async function verify(host, ref, mountpoint) {
|
|
|
612
695
|
if (!active)
|
|
613
696
|
throw new ClaimError("CLAIM_NOT_VERIFIED", `${mountpoint} holds no Active delegation on ${path}: ${r.stdout.trim().slice(0, 300)}`);
|
|
614
697
|
try {
|
|
615
|
-
await host.persist(join(mountpoint, CLAIM_PROBE),
|
|
698
|
+
await host.persist(join(mountpoint, CLAIM_PROBE), probe);
|
|
616
699
|
}
|
|
617
700
|
catch (err) {
|
|
618
701
|
throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
|
|
@@ -626,6 +709,11 @@ async function verify(host, ref, mountpoint) {
|
|
|
626
709
|
* verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
|
|
627
710
|
* empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
|
|
628
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";
|
|
629
717
|
export async function acquire(opts) {
|
|
630
718
|
const { ref, token } = opts;
|
|
631
719
|
const path = runPath(ref.id);
|
|
@@ -641,7 +729,7 @@ export async function acquire(opts) {
|
|
|
641
729
|
let reused = false;
|
|
642
730
|
const existing = await mountEntry(host, root);
|
|
643
731
|
if (existing) {
|
|
644
|
-
if (!isOurs(existing, ref))
|
|
732
|
+
if (!isOurs(existing, targetOf(ref)))
|
|
645
733
|
throw new ClaimError("MOUNTPOINT_BUSY", `${root} already holds ${existing.fstype} ${existing.source}`);
|
|
646
734
|
if (await isDead(host, root))
|
|
647
735
|
await cleanDead(host, root);
|
|
@@ -649,6 +737,11 @@ export async function acquire(opts) {
|
|
|
649
737
|
reused = true;
|
|
650
738
|
}
|
|
651
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
|
+
}
|
|
652
745
|
if (!reused) {
|
|
653
746
|
await ensureMountpoint(host, root);
|
|
654
747
|
await clearStaleDaemon(host, ref, root);
|
|
@@ -730,3 +823,40 @@ class ArchilClaim {
|
|
|
730
823
|
return { via };
|
|
731
824
|
}
|
|
732
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.js
CHANGED
|
@@ -12,7 +12,7 @@ import { closeSync, openSync, readFileSync, realpathSync, statSync } from "node:
|
|
|
12
12
|
import { basename, join, resolve } from "node:path";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { parseArgs } from "node:util";
|
|
15
|
-
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
15
|
+
import { ARCHIL_SCOPED, DEFAULT_MOUNT_ROOT, NO_TOKEN, runPath, TOKEN_PREFIX, unmountClaim } from "./claim.js";
|
|
16
16
|
import { archilEnv } from "./env.js";
|
|
17
17
|
import { exitCodeFor, FencedError, PdaError } from "./errors.js";
|
|
18
18
|
import { fork } from "./fork.js";
|
|
@@ -165,7 +165,7 @@ export async function cliControl(values, client) {
|
|
|
165
165
|
*/
|
|
166
166
|
async function readToken(values) {
|
|
167
167
|
if (!values["token-stdin"])
|
|
168
|
-
return
|
|
168
|
+
return NO_TOKEN;
|
|
169
169
|
let text = "";
|
|
170
170
|
for await (const chunk of process.stdin)
|
|
171
171
|
text += chunk;
|
|
@@ -176,12 +176,13 @@ async function readToken(values) {
|
|
|
176
176
|
catch {
|
|
177
177
|
// already closed
|
|
178
178
|
}
|
|
179
|
-
return text.trim() ||
|
|
179
|
+
return text.trim() || NO_TOKEN;
|
|
180
180
|
}
|
|
181
181
|
/**
|
|
182
182
|
* The instance a host driver starts: openDurableRun with the app module's Harness options (src/app.ts), the
|
|
183
183
|
* driver's handle as run.json's holder, the mount token from stdin (read once, then stdin is closed), and the lease
|
|
184
|
-
* periods. The app
|
|
184
|
+
* periods. The app's factory runs once the claim is held (`afterClaim`: the run's directory is mounted, so `ctx.root` is
|
|
185
|
+
* the live directory) and the Harness opens with what it returns; a missing or throwing module, or a failed `onOpen`, is AppError (exit 1,
|
|
185
186
|
* so the unit retries within systemd's start limit; a restart in place reuses the claim). A fence of any kind kills the
|
|
186
187
|
* run's commands and exits 75 inside openDurableRun.
|
|
187
188
|
*
|
|
@@ -220,15 +221,10 @@ async function runInstance(values) {
|
|
|
220
221
|
const mountRoot = str(values["mount-root"], DEFAULT_MOUNT_ROOT);
|
|
221
222
|
const root = dir ?? join(mountRoot, runPath(ref.id));
|
|
222
223
|
const log = (event, extra = {}) => process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event, run: ref.id, ...extra })}\n`);
|
|
224
|
+
// The app's factory runs once the claim is held (`afterClaim`), so `ctx.root` is the live directory whenever app code
|
|
225
|
+
// runs; what it returns opens the Harness. Serve starts before the open, with the root options the factory fills in.
|
|
223
226
|
let app;
|
|
224
|
-
|
|
225
|
-
app = await loadApp(str(values.app), { ref, root, work: join(root, "work"), store: join(root, "store") });
|
|
226
|
-
}
|
|
227
|
-
catch (err) {
|
|
228
|
-
log("app failed", { code: err.code, message: err.message });
|
|
229
|
-
return exitCodeFor(err);
|
|
230
|
-
}
|
|
231
|
-
const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
|
|
227
|
+
const rootOptions = {};
|
|
232
228
|
const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
|
|
233
229
|
// On a local directory nothing else knows this instance: its start ticks go into the holder, so `status --dir` tells it
|
|
234
230
|
// from a later process that reuses its pid (a driver's handle already carries them).
|
|
@@ -252,7 +248,12 @@ async function runInstance(values) {
|
|
|
252
248
|
run = await openDurableRun(ref, {
|
|
253
249
|
// A local directory is its own claim (no mount, no token); a disk's run is mounted with a token.
|
|
254
250
|
...(dir === undefined ? { mountToken: await readToken(values), mountRoot, host: { archil: str(values.archil) } } : localRun(dir)),
|
|
255
|
-
|
|
251
|
+
afterClaim: async (claim) => {
|
|
252
|
+
app = await loadApp(str(values.app), { ref, root: claim.root, work: claim.work, store: claim.store });
|
|
253
|
+
const { onOpen: _onOpen, beforeResume, root: appRoot, wake: _wake, ...harness } = app;
|
|
254
|
+
Object.assign(rootOptions, appRoot ?? {});
|
|
255
|
+
return { harness, ...(beforeResume ? { beforeResume } : {}) };
|
|
256
|
+
},
|
|
256
257
|
...(runAs
|
|
257
258
|
? { env: (c) => archilEnv(c, { runAs: { uid: runAs.uid, gid: runAs.gid }, shellEnv: { HOME: runAs.home, USER: runAs.name, LOGNAME: runAs.name } }) }
|
|
258
259
|
: {}),
|
|
@@ -260,16 +261,16 @@ async function runInstance(values) {
|
|
|
260
261
|
ownCgroup: cgroup.owns,
|
|
261
262
|
lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
|
|
262
263
|
onStep: (step, t) => void (steps[step] = Math.round(t)),
|
|
263
|
-
...(beforeResume ? { beforeResume } : {}),
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
catch (err) {
|
|
267
|
-
log("open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
|
|
267
|
+
log(err instanceof AppError ? "app failed" : "open failed", { code: err.code, exitCode: exitCodeFor(err), message: err.message, steps });
|
|
268
268
|
await server?.close();
|
|
269
269
|
return exitCodeFor(err);
|
|
270
270
|
}
|
|
271
271
|
server?.attach(run);
|
|
272
272
|
log("running", { generation: run.generation, steps, ...(server ? { serve: server.url } : {}) });
|
|
273
|
+
const { onOpen, wake: appWake } = app;
|
|
273
274
|
return serveUntilDone(run, onOpen, onSigterm, log, process, {
|
|
274
275
|
drainMs,
|
|
275
276
|
...(server ? { server } : {}),
|
package/dist/errors.d.ts
CHANGED
|
@@ -30,11 +30,12 @@ export declare class HeldError extends PdaError {
|
|
|
30
30
|
code?: string;
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
-
export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED";
|
|
33
|
+
export type ClaimErrorCode = "INVALID_ARGUMENT" | "MOUNTPOINT_BUSY" | "MOUNTPOINT_NOT_EMPTY" | "MOUNTPOINT_WRITTEN" | "MOUNTPOINT_UNCHECKED" | "MOUNT_FAILED" | "CLAIM_NOT_VERIFIED" | "ARCHIL_CLI_FAILED" | "UNMOUNT_FAILED" | "DEAD_MOUNT_CLEANUP_FAILED" | "CLAIM_RELEASED" | "CONTROL_API_FAILED" | "NOT_RELEASED";
|
|
34
34
|
/** Any other claim failure (bad input, a refused or unverifiable mount, a CLI or control API failure). Exit 1. */
|
|
35
35
|
export declare class ClaimError extends PdaError {
|
|
36
36
|
constructor(code: ClaimErrorCode, message: string, options?: {
|
|
37
37
|
cause?: unknown;
|
|
38
|
+
exitCode?: number;
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
41
|
export declare function exitCodeFor(err: unknown): number;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { StartAttempt } from "../supervise.js";
|
|
2
|
+
/** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
|
|
3
|
+
export declare function attemptEnv(attempt: StartAttempt | undefined, refuse: (message: string) => Error): Record<string, string>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2
|
+
const DRIVER_KEYS = new Set(["PATH", "PDA_HOLDER"]);
|
|
3
|
+
/** The start's extra environment, checked: PATH, PDA_HOLDER, a name that is not a variable name, or a value with a NUL, is `refuse`'s error. */
|
|
4
|
+
export function attemptEnv(attempt, refuse) {
|
|
5
|
+
const env = attempt?.env ?? {};
|
|
6
|
+
for (const [name, value] of Object.entries(env)) {
|
|
7
|
+
if (!ENV_NAME.test(name))
|
|
8
|
+
throw refuse(`${JSON.stringify(name)} is not an environment variable name`);
|
|
9
|
+
if (DRIVER_KEYS.has(name))
|
|
10
|
+
throw refuse(`${name} is the driver's: a start cannot set it`);
|
|
11
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
12
|
+
throw refuse(`the value of ${name} is not a string without NUL`);
|
|
13
|
+
}
|
|
14
|
+
return env;
|
|
15
|
+
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// status <name> print running, stopped or failed from the state file and whether the processes still live
|
|
10
10
|
// stop <name> SIGTERM the launcher (it drains the instance), SIGKILL both after the timeout, print the state
|
|
11
11
|
// Files live in a root-only directory (default /run/pda, 0700), which the run user can never traverse: `<name>.json`
|
|
12
|
-
// (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output).
|
|
13
|
-
//
|
|
12
|
+
// (the spec), `<name>.token` (gone once read), `<name>.state`, `<name>.log` (the instance's output). Every incarnation of
|
|
13
|
+
// the attempt gets its token on stdin: a restart whose mount is still live reuses it and never needs the token, and one
|
|
14
|
+
// whose mount went with the failed incarnation mounts again (a takeover in between makes that mount HeldError, 76,
|
|
15
|
+
// terminal). The token lives in this launcher's memory for the attempt.
|
|
14
16
|
// No cgroup per incarnation: commands a crashed instance detached can outlive it until the box is deleted.
|
|
15
17
|
import { spawn } from "node:child_process";
|
|
16
18
|
import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
@@ -128,9 +130,9 @@ export async function serve(dir, name) {
|
|
|
128
130
|
try {
|
|
129
131
|
for (;;) {
|
|
130
132
|
const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: ids.home, ...spec.env };
|
|
131
|
-
const
|
|
133
|
+
const piped = Boolean(token);
|
|
132
134
|
try {
|
|
133
|
-
child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [
|
|
135
|
+
child = spawn(spec.argv[0], spec.argv.slice(1), { uid: ids.uid, gid: ids.gid, env, cwd: "/", stdio: [piped ? "pipe" : "ignore", log, log], detached: true });
|
|
134
136
|
await new Promise((resolve, reject) => {
|
|
135
137
|
child.once("spawn", resolve);
|
|
136
138
|
child.once("error", reject);
|
|
@@ -140,10 +142,9 @@ export async function serve(dir, name) {
|
|
|
140
142
|
save({ phase: "exited", instance: null, instanceTicks: null, exit: 1, reason: `spawn failed: ${err.message}` });
|
|
141
143
|
return state;
|
|
142
144
|
}
|
|
143
|
-
if (
|
|
145
|
+
if (piped) {
|
|
144
146
|
child.stdin.on("error", () => { });
|
|
145
|
-
child.stdin.end(
|
|
146
|
-
token = null;
|
|
147
|
+
child.stdin.end(`${token}\n`);
|
|
147
148
|
}
|
|
148
149
|
save({ phase: stopping ? "stopping" : "running", instance: child.pid, instanceTicks: procStartTicks(child.pid), spawned: state.spawned + 1, exit: null, signal: null });
|
|
149
150
|
if (stopping)
|
package/dist/hosts/daytona.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export interface DaytonaRestOptions {
|
|
|
60
60
|
}
|
|
61
61
|
/** The REST API with an API key (Bearer). Error messages carry the method, path and status, never a header or a body sent. */
|
|
62
62
|
export declare function daytonaRest(options: DaytonaRestOptions): DaytonaClient;
|
|
63
|
-
export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DAYTONA_API_FAILED";
|
|
63
|
+
export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED" | "DAYTONA_API_FAILED";
|
|
64
64
|
export declare class DaytonaHostError extends PdaError {
|
|
65
65
|
constructor(code: DaytonaHostErrorCode, message: string, options?: {
|
|
66
66
|
cause?: unknown;
|
|
@@ -133,7 +133,7 @@ export interface DaytonaHostOptions {
|
|
|
133
133
|
export declare function sandboxStatus(state: string | undefined): HostStatus | "check";
|
|
134
134
|
/** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
|
|
135
135
|
export declare function sandboxName(prefix: string, runId: string, now: number): string;
|
|
136
|
-
export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & {
|
|
136
|
+
export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
|
|
137
137
|
readonly fleet: string;
|
|
138
138
|
readonly mountRoot: string;
|
|
139
139
|
};
|
package/dist/hosts/daytona.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// in it, the commands a crashed incarnation detached included. The supervisor holds the API key; boxes never do.
|
|
16
16
|
import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
|
|
17
17
|
import { PdaError } from "../errors.js";
|
|
18
|
+
import { attemptEnv } from "./attempt-env.js";
|
|
18
19
|
import { LAUNCH_DIR } from "./daytona-launch.js";
|
|
19
20
|
import { TERMINAL_EXITS } from "./local-host.js";
|
|
20
21
|
export const DAYTONA_API_URL = "https://app.daytona.io/api";
|
|
@@ -258,10 +259,12 @@ export function daytonaHost(opts) {
|
|
|
258
259
|
return {
|
|
259
260
|
fleet,
|
|
260
261
|
mountRoot,
|
|
261
|
-
|
|
262
|
+
kills: "box",
|
|
263
|
+
async start(ref, mountToken, attempt) {
|
|
262
264
|
runPath(ref.id);
|
|
263
265
|
if (!mountToken)
|
|
264
266
|
throw new DaytonaHostError("INVALID_ARGUMENT", "no mount token");
|
|
267
|
+
const extra = attemptEnv(attempt, (m) => new DaytonaHostError("INVALID_ARGUMENT", m));
|
|
265
268
|
const t0 = now();
|
|
266
269
|
const name = sandboxName(prefix, ref.id, t0);
|
|
267
270
|
const body = {
|
|
@@ -285,7 +288,7 @@ export function daytonaHost(opts) {
|
|
|
285
288
|
const handle = { driver: "daytona", fleet, target: box.target ?? opts.target ?? null, sandboxId: box.id, name, mountpoint: `${mountRoot}/${runPath(ref.id)}` };
|
|
286
289
|
const spec = {
|
|
287
290
|
argv: [...command, "run", "--disk", ref.disk, "--region", ref.region, "--id", ref.id, "--mount-root", mountRoot, "--archil", archil, ...(opts.runArgs ?? []), "--token-stdin"],
|
|
288
|
-
env: { ...opts.env, PDA_HOLDER: JSON.stringify(handle) },
|
|
291
|
+
env: { ...opts.env, ...extra, PDA_HOLDER: JSON.stringify(handle) },
|
|
289
292
|
user,
|
|
290
293
|
group,
|
|
291
294
|
restart: opts.restart !== false,
|
|
@@ -364,6 +367,34 @@ export function daytonaHost(opts) {
|
|
|
364
367
|
}
|
|
365
368
|
}
|
|
366
369
|
},
|
|
370
|
+
async kill(h) {
|
|
371
|
+
if (!mine(h))
|
|
372
|
+
return;
|
|
373
|
+
const id = h.sandboxId;
|
|
374
|
+
let box;
|
|
375
|
+
try {
|
|
376
|
+
box = await client.get(id);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
throw new DaytonaHostError("KILL_FAILED", `reading sandbox ${h.name} to confirm it is this fleet's: ${err.message}`, { cause: err });
|
|
380
|
+
}
|
|
381
|
+
if (box === null || box.labels?.[LABEL_FLEET] !== fleet)
|
|
382
|
+
return;
|
|
383
|
+
// The box goes with everything in it, the instance and its mount: no launcher stop, no drain.
|
|
384
|
+
try {
|
|
385
|
+
await client.remove(id);
|
|
386
|
+
}
|
|
387
|
+
catch (first) {
|
|
388
|
+
// A delete refused mid-transition: power the box off, then delete again.
|
|
389
|
+
await client.stop(id, true).catch(() => { });
|
|
390
|
+
try {
|
|
391
|
+
await client.remove(id);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
throw new DaytonaHostError("KILL_FAILED", `deleting sandbox ${h.name} failed: ${err.message}`, { cause: first });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
},
|
|
367
398
|
};
|
|
368
399
|
}
|
|
369
400
|
/** Delete every box of a fleet (the janitor after a crash of whoever started them); returns what it asked to delete. */
|
package/dist/hosts/docker.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface DockerHostOptions {
|
|
|
58
58
|
exec?: DockerRunner;
|
|
59
59
|
}
|
|
60
60
|
export declare class DockerHostError extends PdaError {
|
|
61
|
-
constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DOCKER_FAILED", message: string, options?: {
|
|
61
|
+
constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED" | "DOCKER_FAILED", message: string, options?: {
|
|
62
62
|
cause?: unknown;
|
|
63
63
|
});
|
|
64
64
|
}
|
|
@@ -95,7 +95,7 @@ export type ContainerInfo = {
|
|
|
95
95
|
startedAt: string | null;
|
|
96
96
|
finishedAt: string | null;
|
|
97
97
|
};
|
|
98
|
-
export declare function dockerHost(opts?: DockerHostOptions): HostDriver & {
|
|
98
|
+
export declare function dockerHost(opts?: DockerHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
|
|
99
99
|
readonly fleet: string;
|
|
100
100
|
readonly mountRoot: string;
|
|
101
101
|
describe(handle: HostHandle): Promise<ContainerInfo | null>;
|
package/dist/hosts/docker.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:p
|
|
|
23
23
|
import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
|
|
24
24
|
import { PdaError } from "../errors.js";
|
|
25
25
|
import { LOCAL_PARK_THRESHOLD_MS } from "../park.js";
|
|
26
|
+
import { attemptEnv } from "./attempt-env.js";
|
|
26
27
|
export class DockerHostError extends PdaError {
|
|
27
28
|
constructor(code, message, options = {}) {
|
|
28
29
|
super(code, message, options);
|
|
@@ -191,8 +192,8 @@ export function dockerHost(opts = {}) {
|
|
|
191
192
|
if (!ok(r) && !NO_SUCH.test(r.stderr))
|
|
192
193
|
throw new DockerHostError("DOCKER_FAILED", `docker rm -f ${ref}: ${firstLine(r)}`);
|
|
193
194
|
}
|
|
194
|
-
function createArgs(ref, name, attempt, holder, profile) {
|
|
195
|
-
const env = { ...opts.env, PDA_HOLDER: JSON.stringify(holder) };
|
|
195
|
+
function createArgs(ref, name, attempt, holder, profile, extra) {
|
|
196
|
+
const env = { ...opts.env, ...extra, PDA_HOLDER: JSON.stringify(holder) };
|
|
196
197
|
const runFlags = [
|
|
197
198
|
"run",
|
|
198
199
|
"--disk", ref.disk,
|
|
@@ -228,10 +229,10 @@ export function dockerHost(opts = {}) {
|
|
|
228
229
|
...runFlags,
|
|
229
230
|
];
|
|
230
231
|
}
|
|
231
|
-
async function create(ref, name, attempt, holder) {
|
|
232
|
+
async function create(ref, name, attempt, holder, extra) {
|
|
232
233
|
const profile = await apparmor();
|
|
233
234
|
for (let tries = 0;; tries++) {
|
|
234
|
-
const r = await run(createArgs(ref, name, attempt, holder, profile), { timeoutMs: 120_000 });
|
|
235
|
+
const r = await run(createArgs(ref, name, attempt, holder, profile, extra), { timeoutMs: 120_000 });
|
|
235
236
|
if (ok(r))
|
|
236
237
|
return { id: r.stdout.trim().split("\n").at(-1).trim() };
|
|
237
238
|
if (!IN_USE.test(r.stderr) || tries > 0)
|
|
@@ -270,6 +271,7 @@ export function dockerHost(opts = {}) {
|
|
|
270
271
|
return {
|
|
271
272
|
fleet,
|
|
272
273
|
mountRoot,
|
|
274
|
+
kills: "box",
|
|
273
275
|
async start(ref, token, attemptInfo) {
|
|
274
276
|
runPath(ref.id);
|
|
275
277
|
if (!opts.image)
|
|
@@ -277,11 +279,12 @@ export function dockerHost(opts = {}) {
|
|
|
277
279
|
if (!token || /[\r\n]/.test(token))
|
|
278
280
|
throw new DockerHostError("INVALID_ARGUMENT", "no mount token, or one with a line break");
|
|
279
281
|
const attempt = attemptInfo?.attempt ?? null;
|
|
282
|
+
const extra = attemptEnv(attemptInfo, (m) => new DockerHostError("INVALID_ARGUMENT", m));
|
|
280
283
|
const name = `${prefix}${ref.id}-${diskKey(ref)}-${attempt === null ? `t${Date.now().toString(36)}` : `g${attempt}`}`;
|
|
281
284
|
if (!NAME_SAFE.test(name))
|
|
282
285
|
throw new DockerHostError("INVALID_ARGUMENT", `container name ${name} is not safe`);
|
|
283
286
|
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);
|
|
287
|
+
const made = await create(ref, name, attempt, handle, extra);
|
|
285
288
|
// An earlier start of this attempt made the container: the token given to this start was not used.
|
|
286
289
|
if ("adopted" in made)
|
|
287
290
|
return { ...handle, adopted: true };
|
|
@@ -336,6 +339,20 @@ export function dockerHost(opts = {}) {
|
|
|
336
339
|
if (await inspect(ref))
|
|
337
340
|
throw new DockerHostError("STOP_FAILED", `container ${ref} is still there after docker rm -f`);
|
|
338
341
|
},
|
|
342
|
+
async kill(h) {
|
|
343
|
+
if (!(await mine(h)))
|
|
344
|
+
return;
|
|
345
|
+
const ref = target(h);
|
|
346
|
+
const found = await inspect(ref);
|
|
347
|
+
if (!found || found.labels["pda.fleet"] !== fleet)
|
|
348
|
+
return;
|
|
349
|
+
// SIGKILL, no stop timeout: the container dies with its mount (its own namespace), as on a power loss. It is left
|
|
350
|
+
// exited, not removed; the next start of the run removes it.
|
|
351
|
+
const r = await run(["kill", ref], { timeoutMs: 30_000 });
|
|
352
|
+
if (!ok(r) && !NO_SUCH.test(r.stderr) && (await inspect(ref).then((f) => f && containerStatus(f.state) === "running"))) {
|
|
353
|
+
throw new DockerHostError("KILL_FAILED", `docker kill ${ref}: ${firstLine(r)}`);
|
|
354
|
+
}
|
|
355
|
+
},
|
|
339
356
|
};
|
|
340
357
|
}
|
|
341
358
|
/** The bind mount of the app's directory (read-only, its node_modules hidden) and the module's path inside the container. */
|
|
@@ -50,7 +50,7 @@ export interface LocalHostOptions {
|
|
|
50
50
|
procMounts?: string;
|
|
51
51
|
}
|
|
52
52
|
export declare class LocalHostError extends PdaError {
|
|
53
|
-
constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED", message: string, options?: {
|
|
53
|
+
constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "KILL_FAILED", message: string, options?: {
|
|
54
54
|
cause?: unknown;
|
|
55
55
|
});
|
|
56
56
|
}
|
|
@@ -66,7 +66,7 @@ export declare function currentBootId(): string;
|
|
|
66
66
|
export declare function parseShow(text: string): Record<string, string>;
|
|
67
67
|
/** A unit's state as a host status: loaded and active (or restarting) is running; unloaded is gone. */
|
|
68
68
|
export declare function unitStatus(show: Record<string, string>): HostStatus;
|
|
69
|
-
export declare function localHost(opts?: LocalHostOptions): HostDriver & {
|
|
69
|
+
export declare function localHost(opts?: LocalHostOptions): HostDriver & Required<Pick<HostDriver, "kill" | "kills">> & {
|
|
70
70
|
readonly hostName: string;
|
|
71
71
|
readonly mountRoot: string;
|
|
72
72
|
};
|