@parcha/pi-durable-disk 0.1.0-beta.21 → 0.1.0-beta.26
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 +33 -1
- package/README.md +12 -8
- package/dist/claim.d.ts +1 -1
- package/dist/cli.d.ts +11 -2
- package/dist/cli.js +63 -18
- package/dist/env.js +3 -0
- package/dist/hosts/attempt-env.d.ts +9 -0
- package/dist/hosts/attempt-env.js +12 -1
- package/dist/hosts/daytona.d.ts +1 -1
- package/dist/hosts/daytona.js +2 -0
- package/dist/hosts/docker.d.ts +1 -1
- package/dist/hosts/docker.js +1 -0
- package/dist/hosts/local-host.d.ts +1 -1
- package/dist/hosts/local-host.js +119 -26
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/place.d.ts +64 -1
- package/dist/place.js +133 -4
- package/dist/run-files.d.ts +72 -0
- package/dist/run-files.js +259 -0
- package/dist/supervise.d.ts +12 -0
- package/package.json +3 -3
package/dist/hosts/local-host.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
16
|
import { hostname } from "node:os";
|
|
17
|
-
import { extname, join } from "node:path";
|
|
17
|
+
import { basename, extname, join } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { ARCHIL_SCOPED, DEAD_CONNECTION, DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
|
|
20
20
|
import { EXIT_DATAERR, EXIT_FENCED, EXIT_HELD, EXIT_SOFTWARE, PdaError } from "../errors.js";
|
|
@@ -73,6 +73,14 @@ export function currentBootId() {
|
|
|
73
73
|
throw new Error("this machine's boot id cannot be read");
|
|
74
74
|
return boot;
|
|
75
75
|
}
|
|
76
|
+
/** The uid a user number or name stands for (a name through /etc/passwd), or undefined when nothing names it. */
|
|
77
|
+
async function uidOf(user) {
|
|
78
|
+
if (/^\d+$/.test(user))
|
|
79
|
+
return Number(user);
|
|
80
|
+
const rows = await readFile("/etc/passwd", "utf8").then((text) => text.split("\n").map((line) => line.split(":")), () => []);
|
|
81
|
+
const row = rows.find((fields) => fields.length >= 3 && fields[0] === user);
|
|
82
|
+
return row ? Number(row[2]) : undefined;
|
|
83
|
+
}
|
|
76
84
|
const firstLine = (r) => (r.stderr.trim().split("\n").at(-1) ?? "").slice(0, 300);
|
|
77
85
|
const okRan = (r) => r.code === 0 && !r.timedOut;
|
|
78
86
|
/** `systemctl show` output as a map. */
|
|
@@ -104,6 +112,35 @@ export function unitStatus(show) {
|
|
|
104
112
|
return "unknown";
|
|
105
113
|
}
|
|
106
114
|
}
|
|
115
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
116
|
+
/**
|
|
117
|
+
* The text of an `EnvironmentFile=`: one `NAME="value"` line each, with `\` and `"` escaped inside the double quotes (nothing
|
|
118
|
+
* else is special there: systemd expands no `$` in this file). A name that is not a variable name, or a value with a newline,
|
|
119
|
+
* a carriage return or a NUL, cannot be written faithfully, so it is refused rather than mangled.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* The unit's own `ExecStopPost=` (`+`: as root, whatever `User=` is) that removes its environment file when the unit ends for
|
|
123
|
+
* good, so an instance that parks or finishes on its own leaves nothing behind. A restart in place loads the file again, so it
|
|
124
|
+
* stays after any exit systemd will restart: with `Restart=on-failure`, one that is not `success` and not in `TERMINAL_EXITS`.
|
|
125
|
+
* (`$$` is systemd's escape for `$`; it hands the script `SERVICE_RESULT` and `EXIT_STATUS`.)
|
|
126
|
+
*/
|
|
127
|
+
function environmentCleanup(path, restarts) {
|
|
128
|
+
if (!restarts)
|
|
129
|
+
return `+/bin/rm -f ${path}`;
|
|
130
|
+
const final = ["success:*", ...TERMINAL_EXITS.map((code) => `exit-code:${code}`)].join("|");
|
|
131
|
+
return `+/bin/sh -c 'case "$$SERVICE_RESULT:$$EXIT_STATUS" in ${final}) /bin/rm -f "$$1";; esac' sh ${path}`;
|
|
132
|
+
}
|
|
133
|
+
function environmentFile(env) {
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (const [name, value] of Object.entries(env)) {
|
|
136
|
+
if (!ENV_NAME.test(name))
|
|
137
|
+
throw new LocalHostError("INVALID_ARGUMENT", `${JSON.stringify(name)} is not an environment variable name`);
|
|
138
|
+
if (typeof value !== "string" || /[\r\n\0]/.test(value))
|
|
139
|
+
throw new LocalHostError("INVALID_ARGUMENT", `the value of ${name} is not a string without a newline, a carriage return or a NUL`);
|
|
140
|
+
lines.push(`${name}="${value.replace(/[\\"]/g, "\\$&")}"`);
|
|
141
|
+
}
|
|
142
|
+
return `${lines.join("\n")}\n`;
|
|
143
|
+
}
|
|
107
144
|
export function localHost(opts = {}) {
|
|
108
145
|
const mode = opts.mode ?? "systemd";
|
|
109
146
|
const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
|
|
@@ -126,6 +163,28 @@ export function localHost(opts = {}) {
|
|
|
126
163
|
const procMounts = opts.procMounts ?? "/proc/self/mounts";
|
|
127
164
|
const mine = (h) => h.driver === "local" && h.host === hostName;
|
|
128
165
|
const mountpointOf = (ref) => join(mountRoot, runPath(ref.id));
|
|
166
|
+
const runMountpoint = (mp) => {
|
|
167
|
+
try {
|
|
168
|
+
return mp === join(mountRoot, runPath(basename(mp)));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* The unit of a systemd handle this driver could have made: a safe name with its prefix, whose mountpoint is one run's
|
|
176
|
+
* under its mount root; undefined for any other. A handle comes back from run.json, which the run's user can write, and
|
|
177
|
+
* stop and kill act on it as root (systemctl, unmount, rmdir), as the docker and daytona drivers act only on their fleet's.
|
|
178
|
+
* They refuse any other with the reason, so a supervisor's log never reports a stop that did not happen.
|
|
179
|
+
*/
|
|
180
|
+
const ownUnit = (h) => {
|
|
181
|
+
const { unit, mountpoint } = h;
|
|
182
|
+
if (typeof unit !== "string" || !UNIT_SAFE.test(unit) || !unit.startsWith(prefix))
|
|
183
|
+
return undefined;
|
|
184
|
+
return typeof mountpoint === "string" && runMountpoint(mountpoint) ? unit : undefined;
|
|
185
|
+
};
|
|
186
|
+
const notOwnUnit = (h, what) => new LocalHostError("INVALID_ARGUMENT", `the handle's unit ${JSON.stringify(h.unit)} at ${JSON.stringify(h.mountpoint)} is not one this driver starts (${prefix}<run id>-<stamp>, a run's mountpoint under ${mountRoot}): nothing ${what}`);
|
|
187
|
+
const notOwnPid = (h, what) => new LocalHostError("INVALID_ARGUMENT", `the handle names pid ${JSON.stringify(h.pid)} with start ticks ${JSON.stringify(h.startTicks)}, not a process this driver started: nothing ${what}`);
|
|
129
188
|
const mounted = async (mp) => (await readFile(procMounts, "utf8")).split("\n").some((l) => l.split(" ")[1] === mp);
|
|
130
189
|
const parkMs = opts.parkThresholdMs === undefined ? LOCAL_PARK_THRESHOLD_MS : opts.parkThresholdMs;
|
|
131
190
|
const drainMs = Math.max(0, stopTimeoutMs - Math.min(5_000, stopTimeoutMs / 2));
|
|
@@ -137,15 +196,18 @@ export function localHost(opts = {}) {
|
|
|
137
196
|
];
|
|
138
197
|
const holderEnv = (h) => JSON.stringify(h);
|
|
139
198
|
const tokenFile = (unit) => join(TOKEN_DIR, `${unit}.mount-token`);
|
|
140
|
-
|
|
199
|
+
/** The unit's environment, loaded with `EnvironmentFile=`: a value on `systemd-run`'s argv is logged by sudo and shown by `systemctl show`. */
|
|
200
|
+
const envFile = (unit) => join(TOKEN_DIR, `${unit}.env`);
|
|
201
|
+
/** Writes a root-only (0600) file under the token directory; `content` goes through stdin, never an argument. */
|
|
202
|
+
async function writeSecretFile(path, content, what) {
|
|
141
203
|
if (!sudo) {
|
|
142
204
|
await mkdir(TOKEN_DIR, { recursive: true, mode: 0o700 });
|
|
143
|
-
await writeFile(path,
|
|
205
|
+
await writeFile(path, content, { mode: 0o600 });
|
|
144
206
|
return;
|
|
145
207
|
}
|
|
146
|
-
const r = await root(["/bin/sh", "-c", 'umask 077 && mkdir -p "$1" && cat > "$2"', "sh", TOKEN_DIR, path], { input:
|
|
208
|
+
const r = await root(["/bin/sh", "-c", 'umask 077 && mkdir -p "$1" && cat > "$2"', "sh", TOKEN_DIR, path], { input: content });
|
|
147
209
|
if (!okRan(r))
|
|
148
|
-
throw new LocalHostError("START_FAILED", `cannot write the
|
|
210
|
+
throw new LocalHostError("START_FAILED", `cannot write the ${what}: ${firstLine(r)}`);
|
|
149
211
|
}
|
|
150
212
|
/** Replace the token file by an empty one: the started instance's open fd keeps the token, the path no longer has it. */
|
|
151
213
|
async function emptyTokenFile(path) {
|
|
@@ -165,10 +227,13 @@ export function localHost(opts = {}) {
|
|
|
165
227
|
const mountpoint = mountpointOf(ref);
|
|
166
228
|
const handle = { driver: "local", mode: "systemd", host: hostName, bootId, unit, mountpoint };
|
|
167
229
|
const env = { ...opts.env, ...extra, PATH: SAFE_PATH, PDA_HOLDER: holderEnv(handle) };
|
|
230
|
+
const environment = environmentFile(env);
|
|
168
231
|
const path = tokenFile(unit);
|
|
169
|
-
|
|
232
|
+
const envPath = envFile(unit);
|
|
233
|
+
await writeSecretFile(path, token, "mount token file");
|
|
170
234
|
let started = false;
|
|
171
235
|
try {
|
|
236
|
+
await writeSecretFile(envPath, environment, "environment file");
|
|
172
237
|
const argv = [
|
|
173
238
|
SYSTEMD_RUN,
|
|
174
239
|
`--unit=${unit}`,
|
|
@@ -181,7 +246,8 @@ export function localHost(opts = {}) {
|
|
|
181
246
|
"-p", `User=${user}`,
|
|
182
247
|
"-p", `Group=${group}`,
|
|
183
248
|
"-p", `StandardInput=file:${path}`,
|
|
184
|
-
|
|
249
|
+
"-p", `EnvironmentFile=${envPath}`,
|
|
250
|
+
"-p", `ExecStopPost=${environmentCleanup(envPath, opts.restart !== false)}`,
|
|
185
251
|
"--",
|
|
186
252
|
...command,
|
|
187
253
|
...runFlags(ref),
|
|
@@ -195,8 +261,13 @@ export function localHost(opts = {}) {
|
|
|
195
261
|
}
|
|
196
262
|
finally {
|
|
197
263
|
// Type=exec: systemd-run returns after the exec, so the instance's stdin is already open on the token. The path
|
|
198
|
-
// stays (empty) because a restart in place opens it again; stop removes it.
|
|
199
|
-
|
|
264
|
+
// stays (empty) because a restart in place opens it again; stop removes it. The environment file stays whole: a
|
|
265
|
+
// restart in place loads it again, and stop removes it. The unit's ExecStopPost removes the file when
|
|
266
|
+
// it ends for good (a park, a finish, a terminal exit). A start that failed leaves neither.
|
|
267
|
+
if (started)
|
|
268
|
+
await emptyTokenFile(path).catch(() => { });
|
|
269
|
+
else
|
|
270
|
+
await Promise.all([removeFile(path), removeFile(envPath)]).catch(() => { });
|
|
200
271
|
}
|
|
201
272
|
}
|
|
202
273
|
async function startChild(ref, token, extra) {
|
|
@@ -286,8 +357,25 @@ export function localHost(opts = {}) {
|
|
|
286
357
|
hostName,
|
|
287
358
|
mountRoot,
|
|
288
359
|
kills: "process",
|
|
360
|
+
instanceOf: (h) => {
|
|
361
|
+
if (h.driver !== "local" || typeof h.host !== "string")
|
|
362
|
+
return undefined;
|
|
363
|
+
const which = h.mode === "systemd" && typeof h.unit === "string" ? `unit\0${h.unit}` : h.mode === "child" && typeof h.tag === "string" ? `tag\0${h.tag}` : undefined;
|
|
364
|
+
return which === undefined ? undefined : `local\0${h.host}\0${String(h.bootId)}\0${which}`;
|
|
365
|
+
},
|
|
289
366
|
async start(ref, token, attempt) {
|
|
290
367
|
runPath(ref.id);
|
|
368
|
+
// Never as root: the instance and the agent's tools would share a uid with the archil daemon that holds the mount
|
|
369
|
+
// token, and own run.json and the store outright.
|
|
370
|
+
if (mode === "systemd" && user.trim() === "") {
|
|
371
|
+
throw new LocalHostError("INVALID_ARGUMENT", "the user is empty, and systemd runs a unit with an empty User= as root: name the unprivileged user the instance runs as (user)");
|
|
372
|
+
}
|
|
373
|
+
if (mode === "systemd" && (await uidOf(user)) === 0) {
|
|
374
|
+
throw new LocalHostError("INVALID_ARGUMENT", `user ${user} is root: name the unprivileged user the instance runs as (user)`);
|
|
375
|
+
}
|
|
376
|
+
if (mode === "child" && process.getuid?.() === 0) {
|
|
377
|
+
throw new LocalHostError("INVALID_ARGUMENT", "child mode runs the instance as this process's user, which is root: use systemd mode with a user");
|
|
378
|
+
}
|
|
291
379
|
const extra = attemptEnv(attempt, (m) => new LocalHostError("INVALID_ARGUMENT", m));
|
|
292
380
|
return mode === "systemd" ? startUnit(ref, token, extra) : startChild(ref, token, extra);
|
|
293
381
|
},
|
|
@@ -313,7 +401,7 @@ export function localHost(opts = {}) {
|
|
|
313
401
|
// real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
|
|
314
402
|
// process it may signal), and without start ticks nothing tells a reused pid from the instance.
|
|
315
403
|
if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
|
|
316
|
-
|
|
404
|
+
throw notOwnPid(h, "stopped");
|
|
317
405
|
const alive = () => procStartTicks(pid) === h.startTicks;
|
|
318
406
|
if (!alive())
|
|
319
407
|
return;
|
|
@@ -329,22 +417,24 @@ export function localHost(opts = {}) {
|
|
|
329
417
|
process.kill(-pid, "SIGKILL");
|
|
330
418
|
return;
|
|
331
419
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
420
|
+
const unit = ownUnit(h);
|
|
421
|
+
if (unit === undefined)
|
|
422
|
+
throw notOwnUnit(h, "stopped");
|
|
423
|
+
const stopUnit = () => root([SYSTEMCTL, "stop", `${unit}.service`], { timeoutMs: stopTimeoutMs + 10_000 });
|
|
335
424
|
let r = await stopUnit();
|
|
336
|
-
if (!okRan(r) && unitStatus(await show(
|
|
425
|
+
if (!okRan(r) && unitStatus(await show(unit)) === "running") {
|
|
337
426
|
// A unit that outlives SIGKILL is wedged in its mount (a FUSE request its daemon never answers): kill the
|
|
338
427
|
// daemon so those requests fail and the processes can die, then stop once more.
|
|
339
|
-
await killDaemons(
|
|
428
|
+
await killDaemons(unit);
|
|
340
429
|
r = await stopUnit();
|
|
341
|
-
if (!okRan(r) && unitStatus(await show(
|
|
342
|
-
throw new LocalHostError("STOP_FAILED", `systemctl stop ${
|
|
430
|
+
if (!okRan(r) && unitStatus(await show(unit)) === "running") {
|
|
431
|
+
throw new LocalHostError("STOP_FAILED", `systemctl stop ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
|
|
343
432
|
}
|
|
344
433
|
}
|
|
345
|
-
await cleanMount(
|
|
346
|
-
await root([SYSTEMCTL, "reset-failed", `${
|
|
347
|
-
await removeFile(tokenFile(
|
|
434
|
+
await cleanMount(unit, String(h.mountpoint));
|
|
435
|
+
await root([SYSTEMCTL, "reset-failed", `${unit}.service`]);
|
|
436
|
+
await removeFile(tokenFile(unit));
|
|
437
|
+
await removeFile(envFile(unit));
|
|
348
438
|
},
|
|
349
439
|
async kill(h) {
|
|
350
440
|
if (!mine(h) || h.bootId !== bootId)
|
|
@@ -352,7 +442,9 @@ export function localHost(opts = {}) {
|
|
|
352
442
|
if (h.mode === "child") {
|
|
353
443
|
const pid = Number(h.pid);
|
|
354
444
|
// As in stop: only a real process, and only the one the handle names (its start ticks).
|
|
355
|
-
if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number"
|
|
445
|
+
if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
|
|
446
|
+
throw notOwnPid(h, "killed");
|
|
447
|
+
if (procStartTicks(pid) !== h.startTicks)
|
|
356
448
|
return;
|
|
357
449
|
try {
|
|
358
450
|
process.kill(-pid, "SIGKILL");
|
|
@@ -362,13 +454,14 @@ export function localHost(opts = {}) {
|
|
|
362
454
|
}
|
|
363
455
|
return;
|
|
364
456
|
}
|
|
365
|
-
|
|
366
|
-
|
|
457
|
+
const unit = ownUnit(h);
|
|
458
|
+
if (unit === undefined)
|
|
459
|
+
throw notOwnUnit(h, "killed");
|
|
367
460
|
// Every process of the unit's control group at once, the instance's commands included. Its FUSE daemon runs in a
|
|
368
461
|
// scope of its own and keeps the mount, as after an OOM kill; the unit restarts in place unless restart is off.
|
|
369
|
-
const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${
|
|
370
|
-
if (!okRan(r) && unitStatus(await show(
|
|
371
|
-
throw new LocalHostError("KILL_FAILED", `systemctl kill ${
|
|
462
|
+
const r = await root([SYSTEMCTL, "kill", "--signal=SIGKILL", "--kill-whom=all", `${unit}.service`]);
|
|
463
|
+
if (!okRan(r) && unitStatus(await show(unit)) === "running") {
|
|
464
|
+
throw new LocalHostError("KILL_FAILED", `systemctl kill ${unit}: ${r.timedOut ? "timed out" : firstLine(r)}`);
|
|
372
465
|
}
|
|
373
466
|
},
|
|
374
467
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
|
|
|
10
10
|
export type { AcquireOptions, ArchilHost, Claim, ControlApi, PathlessResolver, RunRef, Unmounted } from "./claim.js";
|
|
11
11
|
export { readReleased, withClaim } from "./with-claim.js";
|
|
12
12
|
export type { ClaimControl, HeldPath, WithClaimOptions } from "./with-claim.js";
|
|
13
|
+
export { listRunFiles, readRunFile, RunFileError } from "./run-files.js";
|
|
14
|
+
export type { RunFileControl, RunFileErrorCode } from "./run-files.js";
|
|
13
15
|
export { PathOutsideWorkError } from "./confine.js";
|
|
14
16
|
export type { RefusalReason } from "./confine.js";
|
|
15
17
|
export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
|
|
@@ -28,7 +30,7 @@ export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHos
|
|
|
28
30
|
export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
|
|
29
31
|
export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
|
|
30
32
|
export { archilPlace, localPlace, PlaceError } from "./place.js";
|
|
31
|
-
export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecordHome, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RecordControl, RunnerOrder } from "./place.js";
|
|
33
|
+
export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecordHome, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RecordControl, RunnerOrder, UnheldHook, UnheldRun, UnheldStart } from "./place.js";
|
|
32
34
|
export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
|
|
33
35
|
export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
|
|
34
36
|
export { fork, ForkError, forkLocal } from "./fork.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export { ArchilCodingTools, archilEnv, EnvClosedError, EnvError, markReadOnlySaf
|
|
|
9
9
|
export { acquire, ARCHIL_SCOPED, revoke, takeOver } from "./claim.js";
|
|
10
10
|
// A short exclusive claim on any path of a disk, and a consistent S3 read of a released file (an agent home beside the runs).
|
|
11
11
|
export { readReleased, withClaim } from "./with-claim.js";
|
|
12
|
+
// A run's own files, read over the control: never a mount, a write, or a wait on the claim.
|
|
13
|
+
export { listRunFiles, readRunFile, RunFileError } from "./run-files.js";
|
|
12
14
|
export { PathOutsideWorkError } from "./confine.js";
|
|
13
15
|
export { ClaimError, FencedError, HeldError, PdaError } from "./errors.js";
|
|
14
16
|
// The app contract: what `--app` names.
|
package/dist/place.d.ts
CHANGED
|
@@ -148,8 +148,16 @@ export interface Place {
|
|
|
148
148
|
release(): Promise<void>;
|
|
149
149
|
/** A place whose runners record where the supervisor cannot read says where, and brings the recording home. */
|
|
150
150
|
readonly recordHome?: PlaceRecordHome;
|
|
151
|
+
/**
|
|
152
|
+
* The bytes of the run's own file `path` (`runs/<id>/<path>`), or null when there is no such file; every other failure
|
|
153
|
+
* throws a `RunFileError` (INVALID_PATH, RUN_MISSING, NOT_A_FILE, CONTROL_API_FAILED). Read only: never a write, never
|
|
154
|
+
* the claim. A place never parses what it reads.
|
|
155
|
+
*/
|
|
156
|
+
readRunFile?(path: string): Promise<Uint8Array | null>;
|
|
157
|
+
/** The names directly under `dir` in the run (`""` for its root), sorted; [] for a directory not there yet. A missing run, or a failed read, throws. */
|
|
158
|
+
listRunFiles?(dir: string): Promise<string[]>;
|
|
151
159
|
}
|
|
152
|
-
export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED" | "BOX_GONE" | "CARRY_FAILED" | "BLOB_MISMATCH" | "DIRS_REFUSED";
|
|
160
|
+
export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED" | "BOX_GONE" | "CARRY_FAILED" | "BLOB_MISMATCH" | "DIRS_REFUSED" | "WINDOW_CLOSED" | "CONTROL_API_FAILED" | "UNHELD_FAILED";
|
|
153
161
|
export declare class PlaceError extends PdaError {
|
|
154
162
|
constructor(code: PlaceErrorCode, message: string, options?: {
|
|
155
163
|
cause?: unknown;
|
|
@@ -203,7 +211,62 @@ export interface ArchilPlaceOptions {
|
|
|
203
211
|
* `signals-failed` and ends. Default 60 s.
|
|
204
212
|
*/
|
|
205
213
|
signalFailMs?: number;
|
|
214
|
+
/**
|
|
215
|
+
* A hook in each start's window while nobody holds the run: after ensureRunning's revoke and start mark and after the
|
|
216
|
+
* run's directories are made, before the driver starts anything. It runs once per start, a takeover's included, and
|
|
217
|
+
* what it returns is added to that start's environment. Needs `ensure.create` (what it puts is the run user's) and a
|
|
218
|
+
* control that lists. See `UnheldHook`.
|
|
219
|
+
*/
|
|
220
|
+
unheld?: UnheldHook;
|
|
221
|
+
/** How long `unheld` may take. Default 30 s; past it the hook's signal aborts and the start fails (UNHELD_FAILED). */
|
|
222
|
+
unheldTimeoutMs?: number;
|
|
206
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* The run's own files during a start's window (`runs/<id>/` only, K8's path rules): nobody holds the run, so the place
|
|
226
|
+
* is the only writer for this start. Two supervisors can still be in one window at once (the mount, not the window,
|
|
227
|
+
* admits one of their instances), so a put is not exclusive between them.
|
|
228
|
+
*/
|
|
229
|
+
export interface UnheldRun {
|
|
230
|
+
readonly id: string;
|
|
231
|
+
/** Aborted at the hook's bound (`unheldTimeoutMs`). */
|
|
232
|
+
readonly signal: AbortSignal;
|
|
233
|
+
/** The file's bytes, or null when there is no such file yet (`readRunFile`). */
|
|
234
|
+
read(path: string): Promise<Uint8Array | null>;
|
|
235
|
+
/** The names directly under `dir` ("" for the run's root), sorted; [] for a directory not made yet (`listRunFiles`). */
|
|
236
|
+
list(dir: string): Promise<string[]>;
|
|
237
|
+
/**
|
|
238
|
+
* Create `path` holding `text`, owned by the run user, mode 0644, making each missing parent first (the run user's,
|
|
239
|
+
* 0755). A path already there is EXISTS, and nothing is written. For non-secret run files (labels): a secret never
|
|
240
|
+
* goes through put, since a put file is readable in the run. A put the server refuses because a delegation holds the
|
|
241
|
+
* run (409 OperationAborted), and any put after the hook returned or overran, is WINDOW_CLOSED; any other failure is
|
|
242
|
+
* CONTROL_API_FAILED (`RunFileError`). A put the hook starts and does not await still settles inside the window: the
|
|
243
|
+
* driver starts only once every put has settled, within the hook's bound.
|
|
244
|
+
*/
|
|
245
|
+
put(path: string, text: string): Promise<void>;
|
|
246
|
+
}
|
|
247
|
+
/** Which start the window is for: the generation it opens, and the host box it will make when the place hands labels over. */
|
|
248
|
+
export type UnheldStart = {
|
|
249
|
+
readonly attempt: number;
|
|
250
|
+
/** Exactly the HOST_BOX this start carries; absent when the place hands no label over. */
|
|
251
|
+
readonly box?: {
|
|
252
|
+
readonly provider: string;
|
|
253
|
+
readonly key: string;
|
|
254
|
+
readonly run: string;
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* A hook in a start's window (`ArchilPlaceOptions.unheld`). What it returns is added to that start's environment, which
|
|
259
|
+
* the instance can read: plain values only. A name the start already sets (PATH, PDA_HOLDER, HOST_BOX or one in the
|
|
260
|
+
* order's environment), a name that is not a variable name, a key-shaped name (a KEY, TOKEN, SECRET, PASSWORD, PASSWD,
|
|
261
|
+
* CREDENTIAL or CREDENTIALS segment) or a value that is not a string without NUL fails the start (INVALID_ARGUMENT,
|
|
262
|
+
* naming the variable, never its value).
|
|
263
|
+
*
|
|
264
|
+
* A start fails, and the driver never starts a box, when any put in the hook was refused because the run is held
|
|
265
|
+
* (WINDOW_CLOSED, even if the hook caught it), when the hook throws a CONTROL_API_FAILED of the control
|
|
266
|
+
* (CONTROL_API_FAILED), or when it throws anything else or overruns its bound (UNHELD_FAILED). The start's caller sees
|
|
267
|
+
* the driver's start failure from ensureRunning (HOST_START_FAILED), with that PlaceError as its cause.
|
|
268
|
+
*/
|
|
269
|
+
export type UnheldHook = (run: UnheldRun, start: UnheldStart) => Promise<Readonly<Record<string, string>> | void>;
|
|
207
270
|
/** The listing `recordHome` reads the run's recordings with: S3 ListObjects, each object's size when the API gives it. */
|
|
208
271
|
export interface RecordControl {
|
|
209
272
|
listObjects(prefix: string, options?: {
|
package/dist/place.js
CHANGED
|
@@ -13,6 +13,8 @@ import { DEFAULT_MOUNT_ROOT, findDelegations, mintMountToken, pathlessResolver,
|
|
|
13
13
|
import { reapCommands } from "./commands.js";
|
|
14
14
|
import { HeldError, PdaError } from "./errors.js";
|
|
15
15
|
import { takeOwnerLock } from "./run.js";
|
|
16
|
+
import { listLocalRunFiles, listRunFiles, putRunFile, readLocalRunFile, readRunFile, RunFileError } from "./run-files.js";
|
|
17
|
+
import { ENV_NAME, keyShaped } from "./hosts/attempt-env.js";
|
|
16
18
|
import { readRunRecord } from "./status.js";
|
|
17
19
|
import { CONTROL_TIMEOUT_MS, ensureRunning, readRunStatus, readStartMark, TOKEN_TTL, withTimeouts, } from "./supervise.js";
|
|
18
20
|
export class PlaceError extends PdaError {
|
|
@@ -173,6 +175,8 @@ export function localPlace(options) {
|
|
|
173
175
|
return {
|
|
174
176
|
name: "local",
|
|
175
177
|
paths,
|
|
178
|
+
readRunFile: (path) => readLocalRunFile(root, paths.id, path),
|
|
179
|
+
listRunFiles: (dir) => listLocalRunFiles(root, paths.id, dir),
|
|
176
180
|
kills: ctx.destroyBoxes ? ["process", "box"] : ["process"],
|
|
177
181
|
start,
|
|
178
182
|
// The runner is a child of this machine, so its kill request is the supervisor's own file.
|
|
@@ -589,6 +593,17 @@ const requestName = (attempt) => `kill-${countFrom1(attempt)}.json`;
|
|
|
589
593
|
*/
|
|
590
594
|
export function archilPlace(options) {
|
|
591
595
|
const { host } = options;
|
|
596
|
+
const unheldMs = options.unheldTimeoutMs ?? 30_000;
|
|
597
|
+
if (options.unheld !== undefined) {
|
|
598
|
+
if (typeof options.unheld !== "function")
|
|
599
|
+
throw new PlaceError("INVALID_ARGUMENT", "unheld is not a function");
|
|
600
|
+
if (!options.ensure?.create)
|
|
601
|
+
throw new PlaceError("INVALID_ARGUMENT", "unheld needs ensure.create: what its hook puts is the run user's, or the run's process could not rely on it");
|
|
602
|
+
if (typeof options.control.listObjects !== "function")
|
|
603
|
+
throw new PlaceError("INVALID_ARGUMENT", "unheld needs a control that can list (listObjects): its hook's read and list use it");
|
|
604
|
+
if (!(Number.isFinite(unheldMs) && unheldMs > 0))
|
|
605
|
+
throw new PlaceError("INVALID_ARGUMENT", "unheldTimeoutMs is not a positive number of milliseconds");
|
|
606
|
+
}
|
|
592
607
|
// Every call the place makes on the disk is bounded as ensureRunning bounds its own: one that does not answer fails as
|
|
593
608
|
// CONTROL_API_FAILED, so a stalled request never leaves a recovery, a release or a runner's end waiting forever.
|
|
594
609
|
const control = withTimeouts(options.control, options.ensure?.controlTimeoutMs ?? CONTROL_TIMEOUT_MS);
|
|
@@ -611,6 +626,12 @@ export function archilPlace(options) {
|
|
|
611
626
|
let resolver = pathlessResolver(control);
|
|
612
627
|
const records = `${runPath(ref.id)}/${RECORDS}/`;
|
|
613
628
|
const recording = typeof options.control.listObjects === "function";
|
|
629
|
+
/**
|
|
630
|
+
* Whether each host box's runner is told its label (HOST_BOX): the driver makes boxes and names their provider, and this
|
|
631
|
+
* place can read the label back (its read members need a control that lists). A label it could never read is never
|
|
632
|
+
* handed over.
|
|
633
|
+
*/
|
|
634
|
+
const handingOver = recording && typeof host.box === "function" && typeof host.provider === "string" && host.provider !== "";
|
|
614
635
|
const owner = options.ensure?.create;
|
|
615
636
|
/**
|
|
616
637
|
* Before attempt `gen`'s runner starts: the EMPTY directories it writes into, `signals/` and, with `recordHome`,
|
|
@@ -637,6 +658,88 @@ export function archilPlace(options) {
|
|
|
637
658
|
}
|
|
638
659
|
}
|
|
639
660
|
};
|
|
661
|
+
/**
|
|
662
|
+
* The hook's turn in a start's window: its run members, its bound, then what it returned, checked. Fails closed: a put
|
|
663
|
+
* refused because the run is held fails the start even when the hook caught it, so no box starts without what the
|
|
664
|
+
* hook meant to write.
|
|
665
|
+
*/
|
|
666
|
+
const inWindow = async (hook, attempt, box, orderEnv) => {
|
|
667
|
+
const bound = new AbortController();
|
|
668
|
+
const timer = setTimeout(() => bound.abort(new PlaceError("UNHELD_FAILED", `the start window's hook overran its ${unheldMs} ms bound`)), unheldMs);
|
|
669
|
+
let open = true;
|
|
670
|
+
let closed;
|
|
671
|
+
/** Every put the hook started, settled or not: the driver starts only once all of them have settled. */
|
|
672
|
+
const pending = new Set();
|
|
673
|
+
const runControl = control;
|
|
674
|
+
const run = {
|
|
675
|
+
id: ref.id,
|
|
676
|
+
signal: bound.signal,
|
|
677
|
+
read: (path) => readRunFile(runControl, ref, path, { signal: bound.signal }),
|
|
678
|
+
list: (dir) => listRunFiles(runControl, ref, dir, { signal: bound.signal }),
|
|
679
|
+
put: (path, text) => {
|
|
680
|
+
if (!open)
|
|
681
|
+
return Promise.reject(new RunFileError("WINDOW_CLOSED", "the start window is closed (its hook returned or overran its bound): nothing more is put"));
|
|
682
|
+
const put = putRunFile(runControl, ref, path, text, owner, { signal: bound.signal }).catch((error) => {
|
|
683
|
+
if (error instanceof RunFileError && error.code === "WINDOW_CLOSED")
|
|
684
|
+
closed ??= error;
|
|
685
|
+
throw error;
|
|
686
|
+
});
|
|
687
|
+
const settled = put.then(() => undefined, () => undefined);
|
|
688
|
+
pending.add(settled);
|
|
689
|
+
void settled.then(() => pending.delete(settled));
|
|
690
|
+
return put;
|
|
691
|
+
},
|
|
692
|
+
};
|
|
693
|
+
// The bound, as a rejection to race against.
|
|
694
|
+
const overran = new Promise((_, reject) => bound.signal.addEventListener("abort", () => reject(bound.signal.reason), { once: true }));
|
|
695
|
+
overran.catch(() => undefined);
|
|
696
|
+
let returned;
|
|
697
|
+
let thrown;
|
|
698
|
+
try {
|
|
699
|
+
returned = await Promise.race([Promise.resolve().then(() => hook(run, box ? { attempt, box } : { attempt })), overran]);
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
thrown = { error };
|
|
703
|
+
}
|
|
704
|
+
// No put starts once the hook is done, and every put it started (awaited or not) settles inside the bound: none is
|
|
705
|
+
// written, or refused, after the driver has started.
|
|
706
|
+
open = false;
|
|
707
|
+
if (pending.size > 0) {
|
|
708
|
+
try {
|
|
709
|
+
await Promise.race([Promise.allSettled([...pending]), overran]);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
thrown ??= { error };
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
clearTimeout(timer);
|
|
716
|
+
if (closed)
|
|
717
|
+
throw new PlaceError("WINDOW_CLOSED", "a put in the start window was refused because a delegation holds the run (409 OperationAborted): this start makes no box", { cause: closed });
|
|
718
|
+
if (thrown) {
|
|
719
|
+
const { error } = thrown;
|
|
720
|
+
if (error === bound.signal.reason && error instanceof PlaceError)
|
|
721
|
+
throw error;
|
|
722
|
+
if (error instanceof RunFileError && error.code === "CONTROL_API_FAILED")
|
|
723
|
+
throw new PlaceError("CONTROL_API_FAILED", `the start window's hook failed on the control: ${error.message}`, { cause: error });
|
|
724
|
+
throw new PlaceError("UNHELD_FAILED", `the start window's hook failed: ${String(error?.message ?? error).slice(0, 400)}`, { cause: error });
|
|
725
|
+
}
|
|
726
|
+
if (returned === undefined)
|
|
727
|
+
return {};
|
|
728
|
+
if (returned === null || typeof returned !== "object" || Array.isArray(returned))
|
|
729
|
+
throw new PlaceError("INVALID_ARGUMENT", "the start window's hook returned something other than an environment (names to strings)");
|
|
730
|
+
const taken = new Set(["PATH", "PDA_HOLDER", "HOST_BOX", ...Object.keys(orderEnv)]);
|
|
731
|
+
for (const [name, value] of Object.entries(returned)) {
|
|
732
|
+
if (!ENV_NAME.test(name))
|
|
733
|
+
throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${JSON.stringify(name)}, which is not an environment variable name`);
|
|
734
|
+
if (taken.has(name))
|
|
735
|
+
throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name}, which this start already sets (the driver, HOST_BOX or the order)`);
|
|
736
|
+
if (keyShaped(name))
|
|
737
|
+
throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name}, a secret's name (a KEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL or CREDENTIALS segment): the instance can read its environment, so a secret never goes there`);
|
|
738
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
739
|
+
throw new PlaceError("INVALID_ARGUMENT", `the start window's hook returned ${name} with a value that is not a string without NUL`);
|
|
740
|
+
}
|
|
741
|
+
return returned;
|
|
742
|
+
};
|
|
640
743
|
const fetchedSizes = new Map();
|
|
641
744
|
const carrying = new Map();
|
|
642
745
|
const handleOf = new Map();
|
|
@@ -695,7 +798,14 @@ export function archilPlace(options) {
|
|
|
695
798
|
// Inside ensureRunning, after its revoke and start mark: no delegation holds the run, so S3 takes the writes.
|
|
696
799
|
start: async (r, token, attempt) => {
|
|
697
800
|
await makeDirs(order.gen);
|
|
698
|
-
|
|
801
|
+
// The box this start makes is the next in `started`, so its index in status().boxes is started.length: its runner
|
|
802
|
+
// claims that label first (HOST_BOX), for the supervisor's life (RUN_LABEL).
|
|
803
|
+
const life = order.env.RUN_LABEL;
|
|
804
|
+
const box = handingOver && typeof life === "string" && life !== "" ? { provider: host.provider, key: String(started.length), run: life } : undefined;
|
|
805
|
+
const n = attempt?.attempt ?? order.gen;
|
|
806
|
+
const added = options.unheld ? await inWindow(options.unheld, n, box, order.env) : {};
|
|
807
|
+
const env = box ? { ...order.env, ...added, HOST_BOX: JSON.stringify(box) } : { ...order.env, ...added };
|
|
808
|
+
return host.start(r, token, { attempt: n, env });
|
|
699
809
|
},
|
|
700
810
|
status: (h) => host.status(h),
|
|
701
811
|
stop: (h) => host.stop(h),
|
|
@@ -724,8 +834,15 @@ export function archilPlace(options) {
|
|
|
724
834
|
// An adopted instance mounts with an earlier start's token: this one is unused, removed as above.
|
|
725
835
|
if (result.adopted)
|
|
726
836
|
await drop(m).catch(() => { });
|
|
727
|
-
|
|
728
|
-
|
|
837
|
+
// A box this place already started, adopted again (its attempt started twice), keeps the key it has: a second
|
|
838
|
+
// entry would be a key no runner claims (HOST_BOX), a box counted twice, and a box stopped twice. Only the driver
|
|
839
|
+
// can say two handles are one instance (`instanceOf`); a box it cannot name, and every fresh one, is a new entry.
|
|
840
|
+
const name = result.adopted === true ? host.instanceOf?.(result.handle) : undefined;
|
|
841
|
+
const known = (list) => name !== undefined && list.some((h) => host.instanceOf?.(h) === name);
|
|
842
|
+
if (!known(handles))
|
|
843
|
+
handles.push(result.handle);
|
|
844
|
+
if (!known(started))
|
|
845
|
+
started.push(result.handle);
|
|
729
846
|
const w = watch(result.handle);
|
|
730
847
|
const mark = result.startMark ? await readStartMark(control, ref.id).catch(() => null) : null;
|
|
731
848
|
const runner = { epoch: mark?.generation ?? order.gen, exited: w.exited };
|
|
@@ -770,6 +887,13 @@ export function archilPlace(options) {
|
|
|
770
887
|
return {
|
|
771
888
|
name: "archil",
|
|
772
889
|
paths,
|
|
890
|
+
// The run's own files over the control (GET, HEAD, LIST): never a mount, a write, or a wait on the claim.
|
|
891
|
+
...(recording
|
|
892
|
+
? {
|
|
893
|
+
readRunFile: (path) => readRunFile(control, ref, path),
|
|
894
|
+
listRunFiles: (dir) => listRunFiles(control, ref, dir),
|
|
895
|
+
}
|
|
896
|
+
: {}),
|
|
773
897
|
kills: host.kill && host.kills ? [host.kills] : [],
|
|
774
898
|
token: async (attempt) => (await mint(attempt)).token,
|
|
775
899
|
...(recording
|
|
@@ -823,7 +947,12 @@ export function archilPlace(options) {
|
|
|
823
947
|
throw error;
|
|
824
948
|
}),
|
|
825
949
|
]);
|
|
826
|
-
|
|
950
|
+
// With HOST_BOX in play a box's position is its key, so a box never drops out: no receipt fails the read instead.
|
|
951
|
+
const receipts = host.box ? started.map((h) => host.box(h)) : undefined;
|
|
952
|
+
const gap = receipts?.indexOf(null) ?? -1;
|
|
953
|
+
if (handingOver && gap >= 0)
|
|
954
|
+
throw new PlaceError("STATUS_FAILED", `the driver gave no receipt for host box ${gap}, which this place started: every later box's key would shift`);
|
|
955
|
+
const boxes = receipts?.filter((b) => b !== null);
|
|
827
956
|
return { record, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), ...(boxes ? { boxes } : {}) };
|
|
828
957
|
},
|
|
829
958
|
recover: async (last, kind, order) => {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type RunRef } from "./claim.js";
|
|
2
|
+
import { PdaError } from "./errors.js";
|
|
3
|
+
/** The calls of the disk's control these reads use; `archilControl` has all three. */
|
|
4
|
+
export interface RunFileControl {
|
|
5
|
+
getObject(key: string): Promise<Uint8Array>;
|
|
6
|
+
/** null when there is no such key. */
|
|
7
|
+
headObject(key: string): Promise<unknown | null>;
|
|
8
|
+
listObjects(prefix: string, options?: {
|
|
9
|
+
recursive?: boolean;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
objects: {
|
|
12
|
+
key: string;
|
|
13
|
+
size?: number;
|
|
14
|
+
}[];
|
|
15
|
+
commonPrefixes?: string[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export type RunFileErrorCode = "INVALID_PATH" | "RUN_MISSING" | "NOT_A_FILE" | "CONTROL_API_FAILED" | "EXISTS" | "WINDOW_CLOSED";
|
|
19
|
+
/** A read of a run's files that cannot be answered. Its message names the path, never what a file holds. */
|
|
20
|
+
export declare class RunFileError extends PdaError {
|
|
21
|
+
readonly code: RunFileErrorCode;
|
|
22
|
+
constructor(code: RunFileErrorCode, message: string, options?: {
|
|
23
|
+
cause?: unknown;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** The segments of `path`, or INVALID_PATH naming what is wrong (never the path). `""` is the run's root where allowed. */
|
|
27
|
+
export declare function runFileSegments(path: string, allowRoot: boolean): string[];
|
|
28
|
+
/**
|
|
29
|
+
* The bytes of `path` in the run (`runs/<id>/<path>`), or null when there is no such file yet, so a poller waits. A
|
|
30
|
+
* directory or a symlink is NOT_A_FILE; a run that does not exist is RUN_MISSING. No polling here: the caller bounds its
|
|
31
|
+
* own wait, with `signal` and its own retries.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readRunFile(control: RunFileControl, ref: RunRef, path: string, options?: {
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}): Promise<Uint8Array | null>;
|
|
36
|
+
/**
|
|
37
|
+
* The names directly under `dir` in the run (`""` for the run's root), sorted: files and directories alike, names only,
|
|
38
|
+
* never a claim about what each one is. A directory not made yet lists nothing; a run that does not exist is RUN_MISSING.
|
|
39
|
+
*/
|
|
40
|
+
export declare function listRunFiles(control: RunFileControl, ref: RunRef, dir: string, options?: {
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}): Promise<string[]>;
|
|
43
|
+
/** The calls of the disk's control a put uses: HEAD, and PutObject with an owner. `archilControl` has both. */
|
|
44
|
+
export interface RunFilePutControl {
|
|
45
|
+
headObject(key: string): Promise<unknown | null>;
|
|
46
|
+
putObject(key: string, body: string, options: {
|
|
47
|
+
uid: number;
|
|
48
|
+
gid: number;
|
|
49
|
+
mode: number;
|
|
50
|
+
}): Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create `path` in the run (`runs/<id>/<path>`) holding `text`, owned by `owner` with mode 0644, after making each missing
|
|
54
|
+
* parent directory as a marker `owner` owns, mode 0755 (a directory made by path on a mount refuses later creates).
|
|
55
|
+
* Create-only: a file or a directory already at `path` is EXISTS and nothing is written. The check is a HEAD before the
|
|
56
|
+
* PutObject, not a conditional create (Archil's client has none), so it is not exclusive against another writer at the
|
|
57
|
+
* same moment: claim a shared name where a single writer is certain. Only a run nobody holds takes the write; a held one
|
|
58
|
+
* answers 409 OperationAborted, which is WINDOW_CLOSED. For non-secret run files: a put file is readable in the run.
|
|
59
|
+
*/
|
|
60
|
+
export declare function putRunFile(control: RunFilePutControl, ref: RunRef, path: string, text: string, owner: {
|
|
61
|
+
uid: number;
|
|
62
|
+
gid: number;
|
|
63
|
+
}, options?: {
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
}): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* `readRunFile` over the run directory `root` on this machine: the same path rules and answers. A directory, or a symlink
|
|
68
|
+
* anywhere on the way, is NOT_A_FILE, and a file swapped for a link after the check is refused, not followed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function readLocalRunFile(root: string, id: string, path: string): Promise<Uint8Array | null>;
|
|
71
|
+
/** `listRunFiles` over the run directory `root` on this machine: the same rules; a symlink on the way is NOT_A_FILE. */
|
|
72
|
+
export declare function listLocalRunFiles(root: string, id: string, dir: string): Promise<string[]>;
|