@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/claim.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
// The claim: one exclusive Archil mount of `runs/<id>/` per run. The supervisor side holds the API key and uses
|
|
2
|
+
// the control API: create the run directory, mint a mount token, revoke a delegation. The host side holds only
|
|
3
|
+
// a mount token and uses the archil client: mount, verify, barrier, release, dead-mount cleanup.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
import { mkdir, open, readdir, readFile, rmdir, stat } from "node:fs/promises";
|
|
7
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
8
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { ClaimError, FencedError, HeldError, ownWriteFenced } from "./errors.js";
|
|
11
|
+
export const DEFAULT_MOUNT_ROOT = "/mnt/archil";
|
|
12
|
+
/** This package's `bin/archil-scoped`. A deployment installs a root-owned copy and passes its path as `archil`. */
|
|
13
|
+
export const ARCHIL_SCOPED = fileURLToPath(new URL("../bin/archil-scoped", import.meta.url));
|
|
14
|
+
/** The file the claim writes and fsyncs to prove it holds the delegation; written on every acquire. */
|
|
15
|
+
export const CLAIM_PROBE = ".claim";
|
|
16
|
+
// The client exits 1 for every refused mount; this phrase is its only signal that another client holds the delegation
|
|
17
|
+
// ("another client has an outstanding delegation to the root of the disk" or "... which conflicts with the checkout").
|
|
18
|
+
const HELD_MARKER = "has an outstanding delegation";
|
|
19
|
+
// A daemon of this host still runs for the mountpoint (with no mount, or mid-unmount) and holds its control socket
|
|
20
|
+
// ("Failed to bind control socket ... in use"): a local refusal, never another client's delegation, though archil's
|
|
21
|
+
// advice text mentions orphaning "outstanding delegations".
|
|
22
|
+
const STALE_MARKER = /an older Archil process is still running|Failed to bind control socket/;
|
|
23
|
+
// archil refuses to mount over a directory that has entries: in plain words ("'<mountpoint>' is not empty"), or with a
|
|
24
|
+
// misleading error (`os error 115` or `Unspecified Error`, its daemon logging `try_delete_delegation ... in Removing
|
|
25
|
+
// state`). The misleading one also comes from an Archil lockout on an empty mountpoint, so it means "not empty" only
|
|
26
|
+
// when the mountpoint has entries.
|
|
27
|
+
const NOT_EMPTY_MARKER = "is not empty";
|
|
28
|
+
const MISLEADING_REFUSAL = /os error 115|Unspecified Error|in Removing state/;
|
|
29
|
+
// Sysbox (nestybox/sysbox#854, the runtime of Daytona's sandboxes) refuses every umount2 of a FUSE mount with ENOENT,
|
|
30
|
+
// live or dead, while the mount stays listed; `archil unmount` and `fusermount -u` both report that errno.
|
|
31
|
+
const UNMOUNT_ENOENT = /No such file or directory/;
|
|
32
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
33
|
+
const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
34
|
+
export function runPath(id) {
|
|
35
|
+
if (!RUN_ID.test(id))
|
|
36
|
+
throw new ClaimError("INVALID_ARGUMENT", `run id ${JSON.stringify(id)} is not one safe path segment`);
|
|
37
|
+
return `runs/${id}`;
|
|
38
|
+
}
|
|
39
|
+
// ---- supervisor side: the control API -------------------------------------------------------------------------------
|
|
40
|
+
/** PutObject of the `/`-terminated key; uid/gid are required or the directory is root:root. 409: held. */
|
|
41
|
+
export async function createRunDir(control, id, owner) {
|
|
42
|
+
const key = `${runPath(id)}/`;
|
|
43
|
+
try {
|
|
44
|
+
await control.putObject(key, "", { uid: owner.uid, gid: owner.gid, mode: owner.mode ?? 0o755 });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err.status === 409)
|
|
48
|
+
throw new HeldError("claim", `${key} is held by another client`, { cause: err });
|
|
49
|
+
throw new ClaimError("CONTROL_API_FAILED", `PutObject ${key} failed`, { cause: err });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** A mount token's lifetime: one day, a Go duration (the control plane rejects "1d"). */
|
|
53
|
+
export const MOUNT_TOKEN_TTL = "24h";
|
|
54
|
+
export const TOKEN_PREFIX = "pda-";
|
|
55
|
+
/** `<prefix><run id>-g<attempt>-<base36 ms>`: the token user names the run and start attempt it was minted for. */
|
|
56
|
+
export function tokenNickname(runId, attempt, at = Date.now(), prefix = TOKEN_PREFIX) {
|
|
57
|
+
runPath(runId);
|
|
58
|
+
if (!Number.isSafeInteger(attempt) || attempt < 0)
|
|
59
|
+
throw new ClaimError("INVALID_ARGUMENT", `attempt ${attempt} is not a count`);
|
|
60
|
+
const nickname = `${prefix}${runId}-g${attempt}-${at.toString(36)}`;
|
|
61
|
+
if (nickname.length > 255)
|
|
62
|
+
throw new ClaimError("INVALID_ARGUMENT", `token nickname for ${runId} is longer than the 255 characters the control plane takes`);
|
|
63
|
+
return nickname;
|
|
64
|
+
}
|
|
65
|
+
/** The run, attempt and minting time a nickname from `tokenNickname` names; null for anything else. */
|
|
66
|
+
export function parseTokenNickname(nickname, prefix = TOKEN_PREFIX) {
|
|
67
|
+
if (!nickname.startsWith(prefix))
|
|
68
|
+
return null;
|
|
69
|
+
const m = /^(.+)-g(\d+)-([0-9a-z]+)$/.exec(nickname.slice(prefix.length));
|
|
70
|
+
if (!m || !RUN_ID.test(m[1]))
|
|
71
|
+
return null;
|
|
72
|
+
return { runId: m[1], attempt: Number(m[2]), at: parseInt(m[3], 36) };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A reusable mount token with a one-day TTL by default, minted per start attempt. Not single-use: the client
|
|
76
|
+
* re-authenticates at its background endpoint refresh, 5 min after the mount and every 5 min after, and the control plane
|
|
77
|
+
* rejects a spent single-use token for good, after which every write on the mount fails (client 0.8.42). A token
|
|
78
|
+
* whose TTL passes fences its mount the same way at the next refresh, so an instance never outlives its token by more
|
|
79
|
+
* than one refresh. The nickname is given, or built from `run` so the user can be traced back to its run.
|
|
80
|
+
*/
|
|
81
|
+
export async function mintMountToken(control, opts) {
|
|
82
|
+
const nickname = opts.nickname ?? (opts.run ? tokenNickname(opts.run.id, opts.run.attempt, (opts.now ?? Date.now)(), opts.prefix) : undefined);
|
|
83
|
+
if (!nickname)
|
|
84
|
+
throw new ClaimError("INVALID_ARGUMENT", "a mount token needs a nickname or a run");
|
|
85
|
+
let user;
|
|
86
|
+
try {
|
|
87
|
+
user = await control.addUser({ type: "token", nickname, ttl: opts.ttl ?? MOUNT_TOKEN_TTL, oneUse: opts.oneUse ?? false });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new ClaimError("CONTROL_API_FAILED", "addUser failed", { cause: err });
|
|
91
|
+
}
|
|
92
|
+
if (!user.token || !user.identifier)
|
|
93
|
+
throw new ClaimError("CONTROL_API_FAILED", "addUser returned no token or identifier");
|
|
94
|
+
return { token: user.token, identifier: user.identifier, nickname };
|
|
95
|
+
}
|
|
96
|
+
export async function removeMountToken(control, identifier) {
|
|
97
|
+
try {
|
|
98
|
+
await control.removeUser("token", identifier);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
throw new ClaimError("CONTROL_API_FAILED", `removeUser ${identifier} failed`, { cause: err });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Delegations on `runs/<id>` and anything under it; never a sibling or a parent. The control API resolves a delegation's
|
|
106
|
+
* path best-effort: it can list one with no path, live or orphaned, on a never-reused run. So when nothing matches by
|
|
107
|
+
* path and some entry has none, the pathless entries are attributed by inode (`pathlessResolver`): those on the inode
|
|
108
|
+
* `runs/<id>` names now are the run's. A pathless entry that cannot be attributed fails CONTROL_API_FAILED: an unseen
|
|
109
|
+
* holder must never read as none, or a caller starts over it.
|
|
110
|
+
*/
|
|
111
|
+
export async function findDelegations(control, id, resolve) {
|
|
112
|
+
runPath(id);
|
|
113
|
+
return matchDelegations(await control.listDelegations(), id, resolve ?? pathlessResolver(control));
|
|
114
|
+
}
|
|
115
|
+
/** `findDelegations` over a listing the caller already holds; share `resolve` across the runs of one listing or pass. */
|
|
116
|
+
export async function matchDelegations(all, id, resolve) {
|
|
117
|
+
const path = runPath(id);
|
|
118
|
+
const byPath = all.filter((d) => {
|
|
119
|
+
const p = d.path?.replace(/^\/+/, "");
|
|
120
|
+
return p === path || p?.startsWith(`${path}/`) === true;
|
|
121
|
+
});
|
|
122
|
+
const pathless = all.filter((d) => !d.path);
|
|
123
|
+
if (byPath.length > 0 || pathless.length === 0)
|
|
124
|
+
return byPath;
|
|
125
|
+
const names = await resolve(pathless);
|
|
126
|
+
return pathless.filter((d) => names.get(d.inodeId) === id);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Attributes pathless delegations by inode: one `exec` maps every inode not seen before to the run directory that has
|
|
130
|
+
* it (`find runs -inum`, whose inode numbers are the control API's inode ids). Share one across a listing or a supervisor
|
|
131
|
+
* pass: it costs one exec per batch of new inodes, never one per run. The output names only the inodes asked for, so it
|
|
132
|
+
* stays small however many runs the disk holds. A failed exec fails this call and every later one, with no new exec.
|
|
133
|
+
* Inodes on no run directory are reported once on stderr: they block nothing, and only a revoke by client and inode
|
|
134
|
+
* removes them.
|
|
135
|
+
*/
|
|
136
|
+
export function pathlessResolver(control) {
|
|
137
|
+
const known = new Map();
|
|
138
|
+
let failed = null;
|
|
139
|
+
let queue = Promise.resolve();
|
|
140
|
+
return (pathless) => {
|
|
141
|
+
const call = queue.then(async () => {
|
|
142
|
+
if (failed)
|
|
143
|
+
throw failed;
|
|
144
|
+
const fresh = [...new Set(pathless.map((d) => d.inodeId))].filter((inode) => !known.has(inode));
|
|
145
|
+
if (fresh.length === 0)
|
|
146
|
+
return known;
|
|
147
|
+
let names;
|
|
148
|
+
try {
|
|
149
|
+
names = await runDirsByInode(control, fresh);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
failed = err instanceof ClaimError ? err : new ClaimError("CONTROL_API_FAILED", "attributing pathless delegations failed", { cause: err });
|
|
153
|
+
throw failed;
|
|
154
|
+
}
|
|
155
|
+
for (const inode of fresh)
|
|
156
|
+
known.set(inode, names.get(inode) ?? null);
|
|
157
|
+
const unlinked = pathless.filter((d) => fresh.includes(d.inodeId) && !names.has(d.inodeId));
|
|
158
|
+
if (unlinked.length) {
|
|
159
|
+
const sample = unlinked.slice(0, 10).map((d) => ({ clientId: d.clientId, inodeId: d.inodeId, orphaned: d.isOrphaned }));
|
|
160
|
+
process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "pathless delegations on no run directory", count: unlinked.length, delegations: sample })}\n`);
|
|
161
|
+
}
|
|
162
|
+
return known;
|
|
163
|
+
});
|
|
164
|
+
queue = call.catch(() => { });
|
|
165
|
+
return call;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* One exec: the run directories among `inodes`, by inode. `exec` runs at the disk root in shared mode. The disk root is
|
|
170
|
+
* listed first, so a missing `runs/` and an unreadable one are told apart: an unreadable root or `runs/` fails the exec,
|
|
171
|
+
* never reads as no run directory.
|
|
172
|
+
*/
|
|
173
|
+
async function runDirsByInode(control, inodes) {
|
|
174
|
+
if (!control.exec)
|
|
175
|
+
throw new ClaimError("CONTROL_API_FAILED", "a delegation with no path may be a run's, and the control API has no exec to attribute it");
|
|
176
|
+
if (!inodes.every((i) => Number.isSafeInteger(i) && i >= 0))
|
|
177
|
+
throw new ClaimError("CONTROL_API_FAILED", "a pathless delegation has no valid inode id");
|
|
178
|
+
const test = inodes.map((i) => `-inum ${i}`).join(" -o ");
|
|
179
|
+
let r;
|
|
180
|
+
try {
|
|
181
|
+
r = await control.exec(`top=$(find . -mindepth 1 -maxdepth 1 -name runs -print) || exit 3; [ -n "$top" ] || exit 0; ` +
|
|
182
|
+
`find runs -mindepth 1 -maxdepth 1 -type d \\( ${test} \\) -printf '%i %f\\n'`);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new ClaimError("CONTROL_API_FAILED", "resolving pathless delegations to run directories failed", { cause: err });
|
|
186
|
+
}
|
|
187
|
+
if (r.exitCode !== 0)
|
|
188
|
+
throw new ClaimError("CONTROL_API_FAILED", `resolving pathless delegations to run directories: exit ${r.exitCode}`);
|
|
189
|
+
const names = new Map();
|
|
190
|
+
for (const line of String(r.stdout ?? "").split("\n").filter(Boolean)) {
|
|
191
|
+
const m = /^(\d+) (.+)$/.exec(line);
|
|
192
|
+
if (!m)
|
|
193
|
+
throw new ClaimError("CONTROL_API_FAILED", `resolving pathless delegations: unexpected line ${JSON.stringify(line.slice(0, 80))}`);
|
|
194
|
+
names.set(Number(m[1]), m[2]);
|
|
195
|
+
}
|
|
196
|
+
return names;
|
|
197
|
+
}
|
|
198
|
+
/** A client's private directories on the disk: `.archil/client-<clientId>` and its `unlinked/`. */
|
|
199
|
+
const PRIVATE_DIR = /^\/*\.archil\/client-([A-Za-z0-9_-]+)(\/unlinked)?$/;
|
|
200
|
+
/**
|
|
201
|
+
* The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
|
|
202
|
+
* private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
|
|
203
|
+
* they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
|
|
204
|
+
* of its delegations is a companion.
|
|
205
|
+
*/
|
|
206
|
+
export function companionsOf(all, held, privateDirs) {
|
|
207
|
+
const clients = new Set(held.map((d) => d.clientId));
|
|
208
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
209
|
+
const others = all.filter((d) => clients.has(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`));
|
|
210
|
+
const own = (d) => (d.path ? PRIVATE_DIR.exec(d.path)?.[1] : privateDirs.get(d.inodeId)) === d.clientId;
|
|
211
|
+
const holdsMore = new Set(others.filter((d) => !own(d)).map((d) => d.clientId));
|
|
212
|
+
return others.filter((d) => !holdsMore.has(d.clientId));
|
|
213
|
+
}
|
|
214
|
+
/** The inodes of `clients`' private directories, by one `exec` (`stat`); none without `exec`. */
|
|
215
|
+
async function privateDirInodes(control, clients) {
|
|
216
|
+
const asked = clients.filter((c) => /^[A-Za-z0-9_-]+$/.test(c));
|
|
217
|
+
const dirs = new Map();
|
|
218
|
+
if (!control.exec || asked.length === 0)
|
|
219
|
+
return dirs;
|
|
220
|
+
const paths = asked.flatMap((c) => [`.archil/client-${c}`, `.archil/client-${c}/unlinked`]);
|
|
221
|
+
const r = await control.exec(`stat -c '%i %n' -- ${paths.join(" ")} 2>/dev/null; true`);
|
|
222
|
+
for (const line of String(r.stdout ?? "").split("\n")) {
|
|
223
|
+
const m = /^(\d+) (\S+)$/.exec(line);
|
|
224
|
+
const client = m && PRIVATE_DIR.exec(m[2])?.[1];
|
|
225
|
+
if (m && client && asked.includes(client))
|
|
226
|
+
dirs.set(Number(m[1]), client);
|
|
227
|
+
}
|
|
228
|
+
return dirs;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
|
|
232
|
+
* deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
|
|
233
|
+
* run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
|
|
234
|
+
*/
|
|
235
|
+
export async function revokeCompanions(control, held, all) {
|
|
236
|
+
if (held.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const listing = all ?? (await control.listDelegations().catch(() => []));
|
|
239
|
+
const clients = [...new Set(held.map((d) => d.clientId))];
|
|
240
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
241
|
+
if (!listing.some((d) => clients.includes(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`)))
|
|
242
|
+
return [];
|
|
243
|
+
const dirs = await privateDirInodes(control, clients).catch(() => new Map());
|
|
244
|
+
const revoked = [];
|
|
245
|
+
for (const d of companionsOf(listing, held, dirs)) {
|
|
246
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).then(() => revoked.push(d), () => { });
|
|
247
|
+
}
|
|
248
|
+
return revoked;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Revoke every delegation on the run through the control API, then its holders' private directories
|
|
252
|
+
* (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
|
|
253
|
+
*/
|
|
254
|
+
export async function revoke(control, id) {
|
|
255
|
+
const path = runPath(id);
|
|
256
|
+
let all;
|
|
257
|
+
let held;
|
|
258
|
+
try {
|
|
259
|
+
all = await control.listDelegations();
|
|
260
|
+
held = await matchDelegations(all, id, pathlessResolver(control));
|
|
261
|
+
for (const d of held)
|
|
262
|
+
await control.revokeDelegation(d);
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
throw new ClaimError("CONTROL_API_FAILED", `revoking ${path} failed`, { cause: err });
|
|
266
|
+
}
|
|
267
|
+
await revokeCompanions(control, held, all);
|
|
268
|
+
return held;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
|
|
272
|
+
* is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
|
|
273
|
+
*/
|
|
274
|
+
export async function revokeBestEffort(control, id) {
|
|
275
|
+
const all = await control.listDelegations();
|
|
276
|
+
const held = await matchDelegations(all, id, pathlessResolver(control));
|
|
277
|
+
for (const d of held)
|
|
278
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
|
|
279
|
+
await revokeCompanions(control, held, all);
|
|
280
|
+
return held;
|
|
281
|
+
}
|
|
282
|
+
/** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
|
|
283
|
+
export async function takeOver(control, opts) {
|
|
284
|
+
let force = false;
|
|
285
|
+
try {
|
|
286
|
+
await revoke(control, opts.ref.id);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err.code !== "CONTROL_API_FAILED")
|
|
290
|
+
throw err;
|
|
291
|
+
force = true;
|
|
292
|
+
}
|
|
293
|
+
return acquire({ ...opts, force });
|
|
294
|
+
}
|
|
295
|
+
function resolveHost(h = {}) {
|
|
296
|
+
return {
|
|
297
|
+
archil: h.archil ?? ARCHIL_SCOPED,
|
|
298
|
+
fusermount: h.fusermount ?? "/usr/bin/fusermount",
|
|
299
|
+
sudo: h.sudo ?? (process.getuid?.() === 0 ? false : "/usr/bin/sudo"),
|
|
300
|
+
procMounts: h.procMounts ?? "/proc/self/mounts",
|
|
301
|
+
proc: h.proc ?? "/proc",
|
|
302
|
+
timeoutMs: { mount: 120_000, sync: 300_000, unmount: 180_000, cli: 30_000, staleGrace: 5_000, ...h.timeoutMs },
|
|
303
|
+
stat: h.fs?.stat ?? stat,
|
|
304
|
+
persist: h.fs?.persist ?? persistFile,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async function persistFile(path, data) {
|
|
308
|
+
const fh = await open(path, "w", 0o644);
|
|
309
|
+
try {
|
|
310
|
+
await fh.writeFile(data);
|
|
311
|
+
await fh.sync();
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await fh.close();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Spawn with a minimal environment: the parent's (which may hold the API key) never reaches the child. `input` goes to
|
|
319
|
+
* the child's stdin, the only way a secret may travel: never in argv, never in an environment sudo would log.
|
|
320
|
+
*/
|
|
321
|
+
function sh(argv, timeoutMs, input) {
|
|
322
|
+
const env = { PATH: SAFE_PATH, LANG: "C.UTF-8", HOME: process.env.HOME ?? "/" };
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
const t0 = performance.now();
|
|
325
|
+
let stdout = "", stderr = "", timedOut = false, settled = false;
|
|
326
|
+
const child = spawn(argv[0], argv.slice(1), { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
327
|
+
// A child that exits before reading (sudo refusing, a missing wrapper) closes the pipe; its exit code tells why.
|
|
328
|
+
child.stdin.on("error", () => { });
|
|
329
|
+
child.stdin.end(input ?? "");
|
|
330
|
+
child.stdout.setEncoding("utf8").on("data", (c) => (stdout += c));
|
|
331
|
+
child.stderr.setEncoding("utf8").on("data", (c) => (stderr += c));
|
|
332
|
+
const done = (fn) => void (settled || ((settled = true), clearTimeout(timer), fn()));
|
|
333
|
+
// On timeout: SIGTERM (sudo relays it; it cannot relay SIGKILL), drop the pipes a grandchild may hold, settle now.
|
|
334
|
+
const timer = setTimeout(() => {
|
|
335
|
+
timedOut = true;
|
|
336
|
+
child.kill("SIGTERM");
|
|
337
|
+
child.stdout.destroy();
|
|
338
|
+
child.stderr.destroy();
|
|
339
|
+
done(() => resolve({ code: null, timedOut, stdout, stderr, ms: performance.now() - t0 }));
|
|
340
|
+
}, timeoutMs);
|
|
341
|
+
child.on("error", (err) => done(() => reject(new ClaimError("ARCHIL_CLI_FAILED", `cannot run ${argv[0]}`, { cause: err }))));
|
|
342
|
+
child.on("close", (code) => done(() => resolve({ code, timedOut, stdout, stderr, ms: performance.now() - t0 })));
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
function asRoot(host, argv) {
|
|
346
|
+
return host.sudo ? [host.sudo, "-n", ...argv] : argv;
|
|
347
|
+
}
|
|
348
|
+
const archil = (host, args, timeoutMs = host.timeoutMs.cli) => sh(asRoot(host, [host.archil, ...args]), timeoutMs);
|
|
349
|
+
const ok = (r) => r.code === 0 && !r.timedOut;
|
|
350
|
+
/** One line of the child's stderr for an error message, with the token scrubbed. */
|
|
351
|
+
function detail(r, secret = "") {
|
|
352
|
+
const text = secret ? r.stderr.split(secret).join("<token>") : r.stderr;
|
|
353
|
+
const lines = text.split(/[\r\n]+/).map((l) => l.trim()).filter(Boolean);
|
|
354
|
+
const line = lines.find((l) => l.startsWith("✗")) ?? lines.at(-1) ?? "";
|
|
355
|
+
return `${r.timedOut ? "timed out" : `exit ${r.code}`}${line ? `: ${line.slice(0, 400)}` : ""}`;
|
|
356
|
+
}
|
|
357
|
+
const unescapeMount = (s) => s.replace(/\\([0-7]{3})/g, (_, o) => String.fromCharCode(parseInt(o, 8)));
|
|
358
|
+
async function mountEntry(host, mountpoint) {
|
|
359
|
+
let found = null;
|
|
360
|
+
for (const line of (await readFile(host.procMounts, "utf8")).split("\n")) {
|
|
361
|
+
const f = line.split(" ");
|
|
362
|
+
if (f.length >= 3 && unescapeMount(f[1]) === mountpoint)
|
|
363
|
+
found = { source: unescapeMount(f[0]), fstype: f[2] };
|
|
364
|
+
}
|
|
365
|
+
return found;
|
|
366
|
+
}
|
|
367
|
+
function isOurs(entry, ref) {
|
|
368
|
+
const base = `${ref.disk}:/${runPath(ref.id)}`;
|
|
369
|
+
return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${ref.region}]`);
|
|
370
|
+
}
|
|
371
|
+
/** A FUSE mount whose daemon is gone answers stat with ENOTCONN; `archil` refuses it as "not running". */
|
|
372
|
+
async function isDead(host, mountpoint) {
|
|
373
|
+
try {
|
|
374
|
+
await host.stat(mountpoint);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return err.code === "ENOTCONN";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Exit codes of the mount tools are not evidence (a busy mount can report success and stay); the mount table decides.
|
|
382
|
+
const refusedWithEnoent = (r) => !ok(r) && UNMOUNT_ENOENT.test(`${r.stderr}\n${r.stdout}`);
|
|
383
|
+
/** Remove a dead mount with `fusermount -u`; where the kernel refuses with ENOENT (Sysbox), move it aside instead. */
|
|
384
|
+
async function cleanDead(host, mountpoint) {
|
|
385
|
+
const r = await sh(asRoot(host, [host.fusermount, "-u", mountpoint]), host.timeoutMs.cli);
|
|
386
|
+
if (!(await mountEntry(host, mountpoint)))
|
|
387
|
+
return "fusermount";
|
|
388
|
+
if (refusedWithEnoent(r)) {
|
|
389
|
+
await moveAside(host, mountpoint, "DEAD_MOUNT_CLEANUP_FAILED");
|
|
390
|
+
return "moved";
|
|
391
|
+
}
|
|
392
|
+
throw new ClaimError("DEAD_MOUNT_CLEANUP_FAILED", `${mountpoint} is still mounted after fusermount -u (${detail(r)})`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The wrapper's `retire`: move the mount to `<mountRoot>/.released/<run>-<tag>` and SIGKILL its daemon. Done only when
|
|
396
|
+
* the mount table, read here, no longer lists the path and the wrapper reports its daemon gone.
|
|
397
|
+
*/
|
|
398
|
+
async function moveAside(host, mountpoint, code) {
|
|
399
|
+
const r = await archil(host, ["retire", mountpoint, Date.now().toString(36)]);
|
|
400
|
+
if (await mountEntry(host, mountpoint))
|
|
401
|
+
throw new ClaimError(code, `${mountpoint} is still mounted after moving it aside (${detail(r)})`);
|
|
402
|
+
if (!ok(r))
|
|
403
|
+
throw new ClaimError(code, `${mountpoint} was moved aside but its daemon was not stopped (${detail(r)})`);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* 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, and only then is the mount moved aside and its daemon killed. Any failed step leaves the
|
|
408
|
+
* mount where it is, as UNMOUNT_FAILED: a mount that may still hold the delegation is never abandoned.
|
|
409
|
+
*/
|
|
410
|
+
async function checkinAndMoveAside(host, mountpoint) {
|
|
411
|
+
const c = await archil(host, ["checkin", mountpoint], host.timeoutMs.unmount);
|
|
412
|
+
if (!ok(c))
|
|
413
|
+
throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} cannot be unmounted on this host (ENOENT) and archil checkin failed: ${detail(c)}`);
|
|
414
|
+
const d = await archil(host, ["delegations", "--json", mountpoint]);
|
|
415
|
+
let list = null;
|
|
416
|
+
try {
|
|
417
|
+
list = ok(d) ? JSON.parse(d.stdout) : null;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
list = null;
|
|
421
|
+
}
|
|
422
|
+
const mine = (p) => p === mountpoint || (typeof p === "string" && p.startsWith(`${mountpoint}/`));
|
|
423
|
+
const held = !Array.isArray(list) || list.some((x) => x?.state === "Active" && mine(x.path));
|
|
424
|
+
if (held)
|
|
425
|
+
throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} still lists a delegation after archil checkin: ${(ok(d) ? d.stdout : detail(d)).trim().slice(0, 300)}`);
|
|
426
|
+
await moveAside(host, mountpoint, "UNMOUNT_FAILED");
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Unmount whatever the run has at `mountpoint`: `archil unmount` flushes and checks the delegation in; a dead mount
|
|
430
|
+
* (which `archil unmount` refuses) is cleaned with `fusermount -u`; a live mount refused with ENOENT (Sysbox) is checked
|
|
431
|
+
* in and moved aside. Any other live mount that refuses is never forced.
|
|
432
|
+
*/
|
|
433
|
+
export function unmountClaim(mountpoint, host) {
|
|
434
|
+
return unmountWith(resolveHost(host), mountpoint);
|
|
435
|
+
}
|
|
436
|
+
async function unmountWith(host, mountpoint) {
|
|
437
|
+
if (!(await mountEntry(host, mountpoint)))
|
|
438
|
+
return "none";
|
|
439
|
+
const r = await archil(host, ["unmount", mountpoint], host.timeoutMs.unmount);
|
|
440
|
+
let via = "archil";
|
|
441
|
+
if (await mountEntry(host, mountpoint)) {
|
|
442
|
+
if (await isDead(host, mountpoint))
|
|
443
|
+
via = await cleanDead(host, mountpoint);
|
|
444
|
+
else if (refusedWithEnoent(r)) {
|
|
445
|
+
await checkinAndMoveAside(host, mountpoint);
|
|
446
|
+
via = "moved";
|
|
447
|
+
}
|
|
448
|
+
else
|
|
449
|
+
throw new ClaimError("UNMOUNT_FAILED", `${mountpoint} is still mounted after archil unmount (${detail(r)})`);
|
|
450
|
+
}
|
|
451
|
+
await rmdir(mountpoint).catch(() => (host.sudo ? sh([host.sudo, "-n", "rmdir", mountpoint], host.timeoutMs.cli).catch(() => { }) : undefined));
|
|
452
|
+
return via;
|
|
453
|
+
}
|
|
454
|
+
async function ensureMountpoint(host, dir) {
|
|
455
|
+
try {
|
|
456
|
+
await mkdir(dir, { recursive: true });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
const code = err.code;
|
|
461
|
+
if (!host.sudo || (code !== "EACCES" && code !== "EPERM"))
|
|
462
|
+
throw new ClaimError("MOUNT_FAILED", `cannot create ${dir}`, { cause: err });
|
|
463
|
+
}
|
|
464
|
+
const r = await sh([host.sudo, "-n", "mkdir", "-p", dir], host.timeoutMs.cli);
|
|
465
|
+
if (!ok(r))
|
|
466
|
+
throw new ClaimError("MOUNT_FAILED", `cannot create ${dir}: ${detail(r)}`);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Exclusive mode is the absence of `--shared`, `--read-only` and `--conditional`; `verify` proves the mode took. The token
|
|
470
|
+
* goes to the wrapper as one line on stdin (`bin/archil-scoped`), with or without sudo.
|
|
471
|
+
*/
|
|
472
|
+
async function mountRun(host, ref, mountpoint, token, force) {
|
|
473
|
+
const path = runPath(ref.id);
|
|
474
|
+
const args = [host.archil, "mount", ...(force ? ["--force"] : []), `${ref.disk}:/${path}`, mountpoint, "--region", ref.region];
|
|
475
|
+
const r = await sh(asRoot(host, args), host.timeoutMs.mount, `${token}\n`);
|
|
476
|
+
const entry = await mountEntry(host, mountpoint);
|
|
477
|
+
if (ok(r) && entry && isOurs(entry, ref))
|
|
478
|
+
return;
|
|
479
|
+
if (entry)
|
|
480
|
+
await unmountWith(host, mountpoint).catch(() => { });
|
|
481
|
+
if (ok(r))
|
|
482
|
+
throw new ClaimError("MOUNT_FAILED", `archil mount ${path} exited 0 but ${mountpoint} is not an archil mount of the run`);
|
|
483
|
+
if (STALE_MARKER.test(r.stderr))
|
|
484
|
+
throw new ClaimError("MOUNT_FAILED", `an archil daemon of this host still runs for ${mountpoint}: ${detail(r, token)}`);
|
|
485
|
+
if (!force && r.stderr.includes(HELD_MARKER))
|
|
486
|
+
throw new HeldError("claim", `${path} is held by another client (${detail(r, token)})`);
|
|
487
|
+
if (r.stderr.includes(NOT_EMPTY_MARKER) || (MISLEADING_REFUSAL.test(r.stderr) && (await entries(mountpoint)) > 0)) {
|
|
488
|
+
throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `archil mount ${path} refused ${mountpoint}, which is not empty: ${detail(r, token)}`);
|
|
489
|
+
}
|
|
490
|
+
throw new ClaimError("MOUNT_FAILED", `archil mount ${path} failed: ${detail(r, token)}`);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Pids whose argument vector is an archil `mount` naming exactly `mountpoint`. The wrapper's `stale` matches the archil
|
|
494
|
+
* binary's full path; this match is what decides whether to call it. Read synchronously: one pass over a few thousand
|
|
495
|
+
* processes takes tens of milliseconds this way and several times that through the promise API.
|
|
496
|
+
*/
|
|
497
|
+
function archilDaemons(proc, mountpoint) {
|
|
498
|
+
const needle = Buffer.from(`\0${mountpoint}\0`);
|
|
499
|
+
const pids = [];
|
|
500
|
+
let names;
|
|
501
|
+
try {
|
|
502
|
+
names = readdirSync(proc);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
return pids;
|
|
506
|
+
}
|
|
507
|
+
for (const name of names) {
|
|
508
|
+
if (!/^\d+$/.test(name))
|
|
509
|
+
continue;
|
|
510
|
+
let raw;
|
|
511
|
+
try {
|
|
512
|
+
raw = readFileSync(join(proc, name, "cmdline"));
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (!raw.includes(needle))
|
|
518
|
+
continue;
|
|
519
|
+
const argv = raw.toString("utf8").split("\0");
|
|
520
|
+
if (basename(argv[0]) === "archil" && argv[1] === "mount" && argv.slice(2).includes(mountpoint))
|
|
521
|
+
pids.push(Number(name));
|
|
522
|
+
}
|
|
523
|
+
return pids;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* An archil daemon naming the run's mountpoint while no mount is listed there is stale: a client can drop its FUSE mount
|
|
527
|
+
* (after an authentication failure) and keep its process and the mountpoint's control socket, and archil then refuses
|
|
528
|
+
* every mount there ("an older Archil process is still running"). It gets `staleGrace` to exit by itself (a client
|
|
529
|
+
* unmounted moments ago may still be checking its delegation in), then the wrapper's `stale` SIGKILLs it as root. The
|
|
530
|
+
* wrapper refuses while a mount is listed there, so a live mount's daemon is never killed. A daemon that survives is
|
|
531
|
+
* MOUNT_FAILED (exit 1, retried), never held. One line on stderr names the run and the pids.
|
|
532
|
+
*/
|
|
533
|
+
async function clearStaleDaemon(host, ref, mountpoint) {
|
|
534
|
+
const deadline = performance.now() + host.timeoutMs.staleGrace;
|
|
535
|
+
let pids = archilDaemons(host.proc, mountpoint);
|
|
536
|
+
while (pids.length && performance.now() < deadline) {
|
|
537
|
+
await sleep(Math.min(250, Math.max(0, deadline - performance.now())));
|
|
538
|
+
pids = archilDaemons(host.proc, mountpoint);
|
|
539
|
+
}
|
|
540
|
+
if (!pids.length)
|
|
541
|
+
return [];
|
|
542
|
+
const r = await archil(host, ["stale", mountpoint]);
|
|
543
|
+
const left = archilDaemons(host.proc, mountpoint);
|
|
544
|
+
if (!ok(r) || left.length) {
|
|
545
|
+
const why = ok(r) ? `pid ${left.join(", ")} survived archil-scoped stale` : detail(r);
|
|
546
|
+
throw new ClaimError("MOUNT_FAILED", `a stale archil daemon (pid ${pids.join(", ")}) runs for ${mountpoint} with no mount and was not cleared: ${why}`);
|
|
547
|
+
}
|
|
548
|
+
process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stale archil daemon killed", run: ref.id, pids })}\n`);
|
|
549
|
+
return pids;
|
|
550
|
+
}
|
|
551
|
+
/** How many entries a directory holds; 0 when it cannot be read. */
|
|
552
|
+
const entries = (dir) => readdir(dir).then((names) => names.length, () => 0);
|
|
553
|
+
/**
|
|
554
|
+
* A run's mountpoint that is no mount but has entries (files a process wrote by path after the run's mount vanished)
|
|
555
|
+
* is moved aside whole, as root, by the wrapper's `stray` to `<mountRoot>/.stray/<run>-<tag>` and replaced by an empty
|
|
556
|
+
* directory with its owner and mode: archil refuses to mount over entries, and nothing in them may enter the run. One
|
|
557
|
+
* line on stderr names the run and the count of entries, never a name or a byte of them. An unreadable mountpoint goes
|
|
558
|
+
* to the wrapper too, which looks as root.
|
|
559
|
+
*/
|
|
560
|
+
async function moveStrayAside(host, ref, mountRoot, mountpoint) {
|
|
561
|
+
const names = await readdir(mountpoint).catch(() => null);
|
|
562
|
+
if (names && names.length === 0)
|
|
563
|
+
return null;
|
|
564
|
+
const tag = Date.now().toString(36);
|
|
565
|
+
const r = await archil(host, ["stray", mountpoint, tag]);
|
|
566
|
+
if (!ok(r))
|
|
567
|
+
throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `${mountpoint} is not empty and could not be moved aside: ${detail(r)}`);
|
|
568
|
+
const counted = /entries=(\d+)/.exec(r.stdout);
|
|
569
|
+
if (!counted)
|
|
570
|
+
return null;
|
|
571
|
+
const left = await readdir(mountpoint).catch(() => null);
|
|
572
|
+
if (!left || left.length)
|
|
573
|
+
throw new ClaimError("MOUNTPOINT_NOT_EMPTY", `${mountpoint} is not empty after its entries were moved aside`);
|
|
574
|
+
const moved = { to: join(mountRoot, ".stray", `${ref.id}-${tag}`), entries: Number(counted[1]) };
|
|
575
|
+
process.stderr.write(`${JSON.stringify({ at: new Date().toISOString(), event: "stray entries moved aside", run: ref.id, entries: moved.entries, to: moved.to })}\n`);
|
|
576
|
+
return moved;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Ownership is never inferred from a call the client can answer from its cache: on a revoked mount, opening the store,
|
|
580
|
+
* mkdir and writing a new file all succeed locally, and the client keeps listing its delegation Active. So:
|
|
581
|
+
* 1. the client must list an Active delegation on the run, which proves the mount is exclusive (a shared or conditional
|
|
582
|
+
* mount holds none); then
|
|
583
|
+
* 2. a write and fsync of the probe must succeed, which the server answers and refuses on a revoked mount. Any error
|
|
584
|
+
* there is a fence (`ownWriteFenced`).
|
|
585
|
+
* The control API cannot replace step 2: a host cannot learn its own client id, so the API can show that someone holds
|
|
586
|
+
* the run but not that this mount does.
|
|
587
|
+
*/
|
|
588
|
+
async function verify(host, ref, mountpoint) {
|
|
589
|
+
const path = runPath(ref.id);
|
|
590
|
+
const r = await archil(host, ["delegations", "--json", mountpoint]);
|
|
591
|
+
if (!ok(r))
|
|
592
|
+
throw new ClaimError("CLAIM_NOT_VERIFIED", `archil delegations ${mountpoint}: ${detail(r)}`);
|
|
593
|
+
let list;
|
|
594
|
+
try {
|
|
595
|
+
list = JSON.parse(r.stdout);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
list = null;
|
|
599
|
+
}
|
|
600
|
+
const accepted = [mountpoint, join(mountpoint, path)];
|
|
601
|
+
const active = Array.isArray(list) && list.some((d) => d?.state === "Active" && accepted.includes(d.path));
|
|
602
|
+
if (!active)
|
|
603
|
+
throw new ClaimError("CLAIM_NOT_VERIFIED", `${mountpoint} holds no Active delegation on ${path}: ${r.stdout.trim().slice(0, 300)}`);
|
|
604
|
+
try {
|
|
605
|
+
await host.persist(join(mountpoint, CLAIM_PROBE), `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
throw ownWriteFenced(join(mountpoint, CLAIM_PROBE), err);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Take the run's claim: mount `<disk>:/runs/<id>` exclusively at `<mountRoot>/runs/<id>` and verify it. A live mount of
|
|
613
|
+
* the same run already there is kept (in-place restart); a dead one is cleaned first. With no mount there, a stale archil
|
|
614
|
+
* daemon on the mountpoint is killed (`stale`) and entries in it are moved aside to `<mountRoot>/.stray` (`stray`) before
|
|
615
|
+
* the mount. Throws HeldError (exit 76) only when another client holds the delegation, FencedError (exit 75) when the
|
|
616
|
+
* verifying write is refused, MOUNTPOINT_NOT_EMPTY when the mountpoint cannot be emptied or archil still finds it not
|
|
617
|
+
* empty, MOUNT_FAILED (exit 1) for a daemon of this host that is still running there.
|
|
618
|
+
*/
|
|
619
|
+
export async function acquire(opts) {
|
|
620
|
+
const { ref, token } = opts;
|
|
621
|
+
const path = runPath(ref.id);
|
|
622
|
+
const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
|
|
623
|
+
if (!isAbsolute(mountRoot))
|
|
624
|
+
throw new ClaimError("INVALID_ARGUMENT", `mountRoot ${mountRoot} is not absolute`);
|
|
625
|
+
// The wrapper reads exactly one line, so a token with a line break would reach archil cut short.
|
|
626
|
+
if (!token || /[\r\n]/.test(token))
|
|
627
|
+
throw new ClaimError("INVALID_ARGUMENT", "no mount token, or one with a line break");
|
|
628
|
+
const host = resolveHost(opts.host);
|
|
629
|
+
const root = join(mountRoot, path);
|
|
630
|
+
const t0 = performance.now();
|
|
631
|
+
let reused = false;
|
|
632
|
+
const existing = await mountEntry(host, root);
|
|
633
|
+
if (existing) {
|
|
634
|
+
if (!isOurs(existing, ref))
|
|
635
|
+
throw new ClaimError("MOUNTPOINT_BUSY", `${root} already holds ${existing.fstype} ${existing.source}`);
|
|
636
|
+
if (await isDead(host, root))
|
|
637
|
+
await cleanDead(host, root);
|
|
638
|
+
else
|
|
639
|
+
reused = true;
|
|
640
|
+
}
|
|
641
|
+
let stray = null;
|
|
642
|
+
if (!reused) {
|
|
643
|
+
await ensureMountpoint(host, root);
|
|
644
|
+
await clearStaleDaemon(host, ref, root);
|
|
645
|
+
stray = await moveStrayAside(host, ref, mountRoot, root);
|
|
646
|
+
await mountRun(host, ref, root, token, opts.force === true);
|
|
647
|
+
}
|
|
648
|
+
const t1 = performance.now();
|
|
649
|
+
try {
|
|
650
|
+
await verify(host, ref, root);
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
await unmountWith(host, root).catch(() => { });
|
|
654
|
+
throw err;
|
|
655
|
+
}
|
|
656
|
+
return new ArchilClaim(host, ref, root, reused, !reused && opts.force === true, { mountMs: t1 - t0, verifyMs: performance.now() - t1 }, stray);
|
|
657
|
+
}
|
|
658
|
+
class ArchilClaim {
|
|
659
|
+
ref;
|
|
660
|
+
disk;
|
|
661
|
+
root;
|
|
662
|
+
work;
|
|
663
|
+
store;
|
|
664
|
+
reused;
|
|
665
|
+
forced;
|
|
666
|
+
timings;
|
|
667
|
+
stray;
|
|
668
|
+
#host;
|
|
669
|
+
#fence = null;
|
|
670
|
+
#released = false;
|
|
671
|
+
constructor(host, ref, root, reused, forced, timings, stray = null) {
|
|
672
|
+
this.#host = host;
|
|
673
|
+
this.ref = ref;
|
|
674
|
+
this.disk = ref.disk;
|
|
675
|
+
this.root = root;
|
|
676
|
+
this.work = join(root, "work");
|
|
677
|
+
this.store = join(root, "store");
|
|
678
|
+
this.reused = reused;
|
|
679
|
+
this.forced = forced;
|
|
680
|
+
this.timings = timings;
|
|
681
|
+
this.stray = stray;
|
|
682
|
+
}
|
|
683
|
+
get fenced() {
|
|
684
|
+
return this.#fence !== null;
|
|
685
|
+
}
|
|
686
|
+
markFenced(cause) {
|
|
687
|
+
this.#fence ??= { cause };
|
|
688
|
+
}
|
|
689
|
+
async barrier() {
|
|
690
|
+
if (this.#released)
|
|
691
|
+
throw new ClaimError("CLAIM_RELEASED", `${this.root} was released`);
|
|
692
|
+
if (this.#fence)
|
|
693
|
+
throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
|
|
694
|
+
const r = await archil(this.#host, ["sync", this.root], this.#host.timeoutMs.sync);
|
|
695
|
+
if (ok(r))
|
|
696
|
+
return { ms: r.ms };
|
|
697
|
+
// `archil sync` fails only when the mount has failed or is read-only: its pending writes did not become durable.
|
|
698
|
+
const err = new FencedError(`archil sync ${this.root}: ${detail(r)}`);
|
|
699
|
+
this.markFenced(err);
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
async release() {
|
|
703
|
+
if (this.#released)
|
|
704
|
+
return { via: "none" };
|
|
705
|
+
let lost = null;
|
|
706
|
+
if (!this.#fence) {
|
|
707
|
+
try {
|
|
708
|
+
await this.barrier();
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
if (!(err instanceof FencedError))
|
|
712
|
+
throw err;
|
|
713
|
+
lost = err;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const via = await unmountWith(this.#host, this.root);
|
|
717
|
+
this.#released = true;
|
|
718
|
+
if (lost)
|
|
719
|
+
throw lost;
|
|
720
|
+
return { via };
|
|
721
|
+
}
|
|
722
|
+
}
|