@edgehero/pi-dispatch 1.9.0 → 1.10.1
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/.env.example +9 -0
- package/package.json +7 -2
- package/src/backend-conformance.mjs +262 -0
- package/src/backend-local.mjs +222 -0
- package/src/backend-registry.mjs +157 -0
- package/src/backends.mjs +595 -0
- package/src/config.mjs +63 -1
- package/src/container-spec.mjs +226 -0
- package/src/docker-run.mjs +100 -106
- package/src/doctor.mjs +118 -0
- package/src/index.mjs +136 -12
- package/src/outbox.mjs +8 -0
- package/src/packages.mjs +7 -4
- package/src/processor.mjs +48 -11
- package/src/queue.mjs +14 -2
- package/src/sandbox-cli.mjs +36 -0
- package/src/schedules.mjs +1 -1
- package/src/start.mjs +118 -80
- package/src/triggers.mjs +120 -5
package/.env.example
CHANGED
|
@@ -130,6 +130,15 @@ PI_JOB_IMAGE=pi-job:latest # the DEFAULT job image. Any trigger may nam
|
|
|
130
130
|
|
|
131
131
|
PI_SCHEDULER_STALL_MAX=2 # tear down a scheduler after N consecutive stalls (money backstop)
|
|
132
132
|
|
|
133
|
+
# --- Container backend: where a job's container is built, and what that place guarantees (docs/backends.md) ---
|
|
134
|
+
# PI_BACKENDS= # which backends this deployment blesses, comma separated (default: local, the docker daemon on this host).
|
|
135
|
+
# # A trigger's run.backend selects among these; one that names none runs on the first, so the list must include local. An unknown name refuses to boot.
|
|
136
|
+
# PI_BACKEND_FLOOR= # the minimum every backend above must declare, as property=word pairs: e.g. egress=enforced,nonRoot=asserted
|
|
137
|
+
# # Words are enforced (this worker builds it and a test reads it back), asserted (something outside the worker provides it, unverifiable from here) or absent.
|
|
138
|
+
# # A floor naming a switched-off control refuses to boot: asking for egress=enforced while PI_EGRESS=0 is a bound you would believe in and not have.
|
|
139
|
+
# # `pi-dispatch doctor` prints enforced quietly, asserted as a warning naming who asserts it, and absent as a failure.
|
|
140
|
+
# # Env-only, deliberately: a bound that can be widened from the surface it bounds is not a bound.
|
|
141
|
+
|
|
133
142
|
# --- Egress policy: what a job container may reach on the network (docs/egress.md) ---
|
|
134
143
|
# ON by default. Every job runs on its own --internal Docker network with no route anywhere except an
|
|
135
144
|
# allowlist proxy, and a job whose policy cannot serve it is refused BEFORE it spends a budget slot.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,12 @@
|
|
|
52
52
|
"./connection": "./src/connection.mjs",
|
|
53
53
|
"./job-id": "./src/job-id.mjs",
|
|
54
54
|
"./forges": "./src/forges.mjs",
|
|
55
|
-
"./
|
|
55
|
+
"./backend-local": "./src/backend-local.mjs",
|
|
56
|
+
"./backends": "./src/backends.mjs",
|
|
57
|
+
"./backend-registry": "./src/backend-registry.mjs",
|
|
58
|
+
"./container-spec": "./src/container-spec.mjs",
|
|
59
|
+
"./backend-conformance": "./src/backend-conformance.mjs",
|
|
60
|
+
"./triggers": "./src/triggers.mjs",
|
|
56
61
|
"./triggers-file": "./src/triggers-file.mjs",
|
|
57
62
|
"./packages": "./src/packages.mjs",
|
|
58
63
|
"./pause-windows": "./src/pause-windows.mjs",
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONFORMANCE SUITE a backend runs against itself (issue #227).
|
|
3
|
+
*
|
|
4
|
+
* Every earlier slice of this issue ends with the same sentence: the table's words are a CONTRACT and not a
|
|
5
|
+
* FINDING, because nothing verifies a declaration against behaviour. `backends.mjs` says so in its own
|
|
6
|
+
* header, `backend-local.mjs` says its completeness check "proves ARITY AND NOTHING ELSE", and
|
|
7
|
+
* `container-spec.mjs` says the transfer abstraction is an adapter-facing contract awaiting a consumer.
|
|
8
|
+
* This module is that consumer. It is what turns `enforced` from a word into something that can be wrong.
|
|
9
|
+
*
|
|
10
|
+
* IT SHIPS IN `src/`, NOT IN `test/`, and that is the whole point. A suite that lived beside this repo's own
|
|
11
|
+
* tests could only ever check this repo's own backend. An adapter is written elsewhere, by someone who will
|
|
12
|
+
* never run `npm test` here, and the question they need answered is "does MY backend hold the contract" --
|
|
13
|
+
* so the checks have to be importable. `docs/backends.md` is the page that tells them to import it.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT CAN AND CANNOT DO, stated at its true size. It verifies the SHAPE of a bundle, the INTERNAL
|
|
16
|
+
* CONSISTENCY of its declaration, and the behaviour of whatever the caller's PROBES produce. It cannot
|
|
17
|
+
* start a real container, reach a real daemon, or prove a kernel enforced anything.
|
|
18
|
+
*
|
|
19
|
+
* THE PROBES ARE THE ADAPTER'S OWN CODE, and that is a real limit rather than a detail. How you make a
|
|
20
|
+
* container exit 2, or make an enumeration fail, is the runtime's business and cannot be written
|
|
21
|
+
* generically -- so `checkExitCodes` and `checkAbortFlag` verify what your probe REPORTS, and a probe that
|
|
22
|
+
* fabricates its answer instead of routing through your `runContainer` will pass while proving nothing.
|
|
23
|
+
* The suite cannot detect that, which is why it is written here rather than left for a reader to discover.
|
|
24
|
+
* `docs/backends.md` says the same thing where an adapter author will actually read it.
|
|
25
|
+
*
|
|
26
|
+
* `OQ-012`'s line applies to the harness itself: a check that proves intent is not a check that proves
|
|
27
|
+
* conformance, and the difference has to be stated rather than blurred.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { BACKENDS, PROPERTY_NAMES, isDeclaration } from "./backends.mjs";
|
|
31
|
+
import { containerSpec, copyDowngrades } from "./container-spec.mjs";
|
|
32
|
+
|
|
33
|
+
/** A finding. `ok: false` is a contract violation; `unverifiable` is a property this harness cannot reach. */
|
|
34
|
+
const pass = (check, detail) => ({ check, ok: true, detail });
|
|
35
|
+
const fail = (check, detail) => ({ check, ok: false, detail });
|
|
36
|
+
const abstain = (check, detail) => ({ check, ok: true, unverifiable: true, detail });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* EXIT-CODE FIDELITY. The runner's integer must reach the processor unmodified.
|
|
40
|
+
*
|
|
41
|
+
* `INT-RUNNER-EXIT-CODE-PROTOCOL` rests on this and so does every retry decision: 0 is completed, 1 is
|
|
42
|
+
* infra (retry), 2 is policy (no retry). A backend that clamped, remapped or swallowed a code would turn a
|
|
43
|
+
* policy refusal into a paid retry loop, or a real failure into a clean success. The zero case matters most
|
|
44
|
+
* -- a backend that returns a falsy code as `null` reads as "unknown exit" downstream.
|
|
45
|
+
*/
|
|
46
|
+
async function checkExitCodes(backend, { probe }) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const code of [0, 1, 2, 3, 137, 255]) {
|
|
49
|
+
const got = await probe(backend, { exitCode: code });
|
|
50
|
+
if (got?.code !== code) {
|
|
51
|
+
out.push(fail("exitCodes", `a runner exit of ${code} arrived as ${JSON.stringify(got?.code)}; every retry decision reads this integer`));
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
out.push(pass("exitCodes", "0, 1, 2, 3, 137 and 255 all arrive unmodified"));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE ABORT FLAG must be distinguishable from the exit code.
|
|
61
|
+
*
|
|
62
|
+
* `INT-RUNNER-EXIT-CODE-PROTOCOL`: a worker SIGKILL and a kernel OOM BOTH surface as 137, so the code alone
|
|
63
|
+
* cannot say which happened. The processor classifies an abort as POLICY (no retry) and an OOM as INFRA
|
|
64
|
+
* (retry), so a backend that reports only the integer makes a hung job retry forever and an OOM look like a
|
|
65
|
+
* deliberate stop. This is the single most transferable clause in the contract and the easiest to drop.
|
|
66
|
+
*/
|
|
67
|
+
async function checkAbortFlag(backend, { probe }) {
|
|
68
|
+
const stopped = await probe(backend, { exitCode: 137, aborted: true });
|
|
69
|
+
const oomed = await probe(backend, { exitCode: 137, aborted: false });
|
|
70
|
+
if (stopped?.aborted !== true) return [fail("abortable", "a container stopped by the worker did not report aborted: true; 137 alone cannot be told from a kernel OOM")];
|
|
71
|
+
if (oomed?.aborted === true) return [fail("abortable", "a container that exited 137 on its own reported aborted: true; an OOM would be classified as a deliberate stop and never retried")];
|
|
72
|
+
return [pass("abortable", "the abort flag is reported independently of the exit code")];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* THE REAPER'S TRI-STATE. `{reaped: true}` means "this host has ESTABLISHED it holds no job containers".
|
|
77
|
+
*
|
|
78
|
+
* `makeScopeClaimSweeper` gates a money decision on it: it may only delete a scope claim naming this host
|
|
79
|
+
* once the host has proven it holds nothing. A backend whose reap returns true after a FAILED enumeration
|
|
80
|
+
* frees slots for containers that may still be running and lets another host start more alongside them --
|
|
81
|
+
* a spend overrun rather than a tidy-up. Returning `[]` on a failed listing is the specific shape that gets
|
|
82
|
+
* this wrong, because an empty list and an unanswerable question look identical.
|
|
83
|
+
*/
|
|
84
|
+
async function checkReaperTriState(backend, { withBrokenEnumeration }) {
|
|
85
|
+
if (typeof backend?.reap !== "function") return []; // the shape check already said so; do not say it twice
|
|
86
|
+
if (typeof withBrokenEnumeration !== "function") {
|
|
87
|
+
return [abstain("reap", "no `withBrokenEnumeration` probe supplied, so a failed enumeration could not be simulated")];
|
|
88
|
+
}
|
|
89
|
+
const broken = await withBrokenEnumeration(backend);
|
|
90
|
+
if (broken?.reaped !== false) {
|
|
91
|
+
return [fail("reap", `a reap whose enumeration failed returned ${JSON.stringify(broken)}; it must be {reaped:false}, or a scope sweep frees slots for containers that may still be running`)];
|
|
92
|
+
}
|
|
93
|
+
const clean = await backend.reap();
|
|
94
|
+
if (clean?.reaped !== true) return [fail("reap", `a successful reap returned ${JSON.stringify(clean)}; it must be {reaped:true} or no host can ever sweep its own stale claims`)];
|
|
95
|
+
return [pass("reap", "the tri-state distinguishes a failed enumeration from an empty one")];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* THE BUNDLE'S SHAPE, and the name it answers to.
|
|
100
|
+
*
|
|
101
|
+
* Cheap, and it catches the mistake an adapter author actually makes: a bundle whose `name` does not match
|
|
102
|
+
* the table entry it declares against. The registry keys on `name` and the table keys on the same string,
|
|
103
|
+
* so a mismatch silently gives the adapter a different backend's declaration.
|
|
104
|
+
*/
|
|
105
|
+
function checkShape(backend) {
|
|
106
|
+
const out = [];
|
|
107
|
+
// `containerName` is here because the REGISTRY calls it -- `registry.containerName(job)` builds the
|
|
108
|
+
// name the abort then stops. A bundle without it built at boot and threw at the first pickup, which is
|
|
109
|
+
// exactly the property the registry claims to have.
|
|
110
|
+
for (const fn of ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap", "containerName"]) {
|
|
111
|
+
if (typeof backend?.[fn] !== "function") out.push(fail("shape", `the bundle has no ${fn}()`));
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(backend?.neverStartedExits)) {
|
|
114
|
+
out.push(fail("shape", "neverStartedExits must be an array -- [] if this runtime normalises to container-never-started itself"));
|
|
115
|
+
} else {
|
|
116
|
+
// The set must not CLAIM a code the protocol already means. 137 is the OOM/SIGKILL code, so claiming
|
|
117
|
+
// it turns every kernel OOM into a refunded "never started" -- the container ran, spent its slot, and
|
|
118
|
+
// the refund hands the slot back. 0/1/2 are the runner's own completed/infra/policy codes, so claiming
|
|
119
|
+
// one of those converts a real outcome into an infra retry that can never resolve. Both are money.
|
|
120
|
+
const reserved = backend.neverStartedExits.filter((c) => [0, 1, 2, 137].includes(c));
|
|
121
|
+
if (reserved.length > 0) {
|
|
122
|
+
out.push(fail("shape", `neverStartedExits claims ${reserved.join(", ")}, which INT-RUNNER-EXIT-CODE-PROTOCOL already means (0 completed, 1 infra, 2 policy, 137 OOM/SIGKILL); claiming one refunds a slot the container actually spent`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const entry = BACKENDS[backend?.name];
|
|
126
|
+
if (!entry) out.push(fail("shape", `the bundle's name ${JSON.stringify(backend?.name)} has no entry in the backend table, so nothing declares what it guarantees`));
|
|
127
|
+
else {
|
|
128
|
+
// BY VALUE, not by reference. An adapter written outside this repo cannot hold the table's own frozen
|
|
129
|
+
// object, and refusing a faithful copy made `checkDeclaration` below unreachable -- every bundle that
|
|
130
|
+
// passed shape held the table's own words, so the declaration gate could only ever restate a shape
|
|
131
|
+
// failure. What matters is that the words AGREE, since disagreement is what lets an adapter say one
|
|
132
|
+
// thing while doctor prints another.
|
|
133
|
+
const drift = PROPERTY_NAMES.filter((p) => backend.declares?.[p] !== entry.declares[p]);
|
|
134
|
+
if (drift.length > 0) out.push(fail("shape", `the bundle declares ${drift.join(", ")} differently from the table, so doctor would print one answer and the adapter believe another`));
|
|
135
|
+
}
|
|
136
|
+
return out.length > 0 ? out : [pass("shape", "six functions, a declared exit set, and a name the table knows")];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* THE DECLARATION ITSELF, against the closed list and the three words.
|
|
141
|
+
*
|
|
142
|
+
* A backend that omits a property is not admitted for it (`backends.mjs`'s rule), and a word outside the
|
|
143
|
+
* vocabulary ranks with `absent`, so a typo would silently downgrade rather than fail.
|
|
144
|
+
*/
|
|
145
|
+
function checkDeclaration(backend) {
|
|
146
|
+
const declares = backend?.declares ?? {};
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const property of PROPERTY_NAMES) {
|
|
149
|
+
if (!isDeclaration(declares[property])) out.push(fail("declaration", `${property} is declared ${JSON.stringify(declares[property])}, which is not one of enforced/asserted/absent`));
|
|
150
|
+
}
|
|
151
|
+
for (const property of Object.keys(declares)) {
|
|
152
|
+
if (!PROPERTY_NAMES.includes(property)) out.push(fail("declaration", `${property} is not a property of the closed list, so nothing reads it`));
|
|
153
|
+
}
|
|
154
|
+
// An `asserted` word with no source is not actionable: it says the worker is not providing the property
|
|
155
|
+
// without saying who is, so an operator cannot go and check.
|
|
156
|
+
for (const property of PROPERTY_NAMES) {
|
|
157
|
+
if (declares[property] === "asserted" && !BACKENDS[backend?.name]?.asserts?.[property]) {
|
|
158
|
+
out.push(fail("declaration", `${property} is asserted but nothing names WHO asserts it; "not us" without "them" leaves an operator nothing to check`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out.length > 0 ? out : [pass("declaration", "every property carries one of the three words, and every asserted one names its source")];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* THE TRANSFER CONTRACT, and the check that gives it teeth.
|
|
166
|
+
*
|
|
167
|
+
* This is the consumer `container-spec.mjs` was written for. A backend that COPIES rather than bind-mounts
|
|
168
|
+
* loses the kernel's enforcement of `/job`'s read-only mount -- `DES-JOB-FILES-VIA-VOLUME-SUBPATH` says
|
|
169
|
+
* `docker cp` "cannot give /job a kernel-enforced read-only mount, which INT-CONTAINER-JOB-INPUTS depends
|
|
170
|
+
* on" -- so a backend declaring `binds: false` and `readOnlyJobInputs: enforced` is making exactly the
|
|
171
|
+
* claim `CONST-EGRESS-POLICY-IN-THE-ARGV` calls worse than no claim at all.
|
|
172
|
+
*/
|
|
173
|
+
function checkTransfers(backend) {
|
|
174
|
+
const declares = backend?.declares ?? {};
|
|
175
|
+
// `binds` is the adapter's own statement about how it moves files. Absent means "bind-mounts", which is
|
|
176
|
+
// what the local backend does and what every reader assumed before this field existed.
|
|
177
|
+
// FAIL-CLOSED on an unstated `binds`. An earlier draft treated "not false" as "binds", so a copying
|
|
178
|
+
// adapter that never set the field got a positive assertion about a property nothing had examined --
|
|
179
|
+
// the believed-in control, produced by the harness written to prevent it. A backend that does not say
|
|
180
|
+
// how it moves files has not earned the word, and this abstains rather than failing because not saying
|
|
181
|
+
// is a documentation gap rather than a false claim.
|
|
182
|
+
if (backend?.binds === undefined) {
|
|
183
|
+
return [abstain("readOnlyJobInputs", "this backend does not declare `binds`, so whether /job's read-only is the kernel's or a convention could not be determined; set binds: true (bind-mounts) or false (copies)")];
|
|
184
|
+
}
|
|
185
|
+
if (backend.binds === true) {
|
|
186
|
+
return [pass("readOnlyJobInputs", "this backend bind-mounts, so /job's read-only is the kernel's")];
|
|
187
|
+
}
|
|
188
|
+
const spec = containerSpec({ image: "i", name: "n", jobDir: "/j", workspace: "/w" });
|
|
189
|
+
const lost = copyDowngrades(spec).map((d) => d.container);
|
|
190
|
+
if (lost.length > 0 && declares.readOnlyJobInputs === "enforced") {
|
|
191
|
+
return [fail("readOnlyJobInputs", `this backend copies, so ${lost.join(", ")} is read-only by convention rather than by the kernel -- declaring it enforced is a control an operator would believe in and not have`)];
|
|
192
|
+
}
|
|
193
|
+
return [pass("readOnlyJobInputs", `this backend copies and declares ${JSON.stringify(declares.readOnlyJobInputs)}, which matches what a copy can hold`)];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Run every check against one backend bundle.
|
|
198
|
+
*
|
|
199
|
+
* `probe` is the one thing an adapter must supply: `(backend, { exitCode, aborted }) => result`, arranging
|
|
200
|
+
* for the backend's own `runContainer` to produce a container that exits that way. It cannot be written
|
|
201
|
+
* generically -- how you make a container exit 2 is the runtime's business -- and it is the reason this is a
|
|
202
|
+
* function taking probes rather than a fixed suite.
|
|
203
|
+
*
|
|
204
|
+
* Returns `{ ok, findings }`. `ok` is false if ANY check failed; a check that abstained does not fail the
|
|
205
|
+
* run but is reported, because a property nobody could verify is not a property that was verified.
|
|
206
|
+
*/
|
|
207
|
+
export async function runBackendConformance(backend, probes = {}) {
|
|
208
|
+
try {
|
|
209
|
+
return await conformance(backend, probes);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// A backend whose own function THREW is a conformance failure, not a harness crash. An adapter
|
|
212
|
+
// author running this for the first time is the person most likely to hit it, and they need the
|
|
213
|
+
// message rather than a stack trace from someone else's code.
|
|
214
|
+
return { ok: false, findings: [fail("harness", `a backend function threw while being driven: ${error?.message ?? error}`)] };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function conformance(backend, probes) {
|
|
219
|
+
const findings = [
|
|
220
|
+
...checkShape(backend),
|
|
221
|
+
...checkDeclaration(backend),
|
|
222
|
+
...checkTransfers(backend),
|
|
223
|
+
];
|
|
224
|
+
// A bundle that is missing a function cannot be DRIVEN, and a harness that threw here would be useless
|
|
225
|
+
// in exactly the case it exists for: an adapter author's first run, against a bundle they have not
|
|
226
|
+
// finished. The shape findings above already name what is missing.
|
|
227
|
+
const drivable = ["runContainer", "stopContainer", "reap"].every((fn) => typeof backend?.[fn] === "function");
|
|
228
|
+
if (typeof probes.probe === "function" && drivable) {
|
|
229
|
+
findings.push(...(await checkExitCodes(backend, probes)));
|
|
230
|
+
findings.push(...(await checkAbortFlag(backend, probes)));
|
|
231
|
+
} else if (!drivable) {
|
|
232
|
+
findings.push(abstain("exitCodes", "the bundle is incomplete, so nothing could be driven to an exit"));
|
|
233
|
+
findings.push(abstain("abortable", "the bundle is incomplete, so an abort could not be told from an OOM"));
|
|
234
|
+
} else {
|
|
235
|
+
findings.push(abstain("exitCodes", "no `probe` supplied, so no container was driven to an exit"));
|
|
236
|
+
findings.push(abstain("abortable", "no `probe` supplied, so an abort could not be told from an OOM"));
|
|
237
|
+
}
|
|
238
|
+
findings.push(...(await checkReaperTriState(backend, probes)));
|
|
239
|
+
return { ok: findings.every((f) => f.ok), findings };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The properties this harness DOES NOT verify, and why -- printed alongside the findings so a green run is
|
|
244
|
+
* never mistaken for a conformant backend.
|
|
245
|
+
*
|
|
246
|
+
* Every one of these needs a live container on the target runtime, which is exactly what this repo's own
|
|
247
|
+
* offline CI cannot have. Naming them is the difference between a suite that is honest about its reach and
|
|
248
|
+
* one that lets a green tick stand for something it never checked. `verify-image.sh` is the shape the
|
|
249
|
+
* missing half would take, and it "runs ON THE HOST THAT HOLDS THE IMAGE, which is the only place it can".
|
|
250
|
+
*/
|
|
251
|
+
export const UNVERIFIED_BY_THIS_HARNESS = Object.freeze({
|
|
252
|
+
isolation: "needs a live container: read the capability set and no-new-privileges back from inside it",
|
|
253
|
+
ephemeral: "needs two runs of the same job id and a check that no container survived the first",
|
|
254
|
+
mountSet: "needs a live container: enumerate its mounts and assert nothing beyond the declared set",
|
|
255
|
+
egress: "needs a live container and a blocked destination",
|
|
256
|
+
jobToJobIsolation: "needs two live containers and an attempted connection between them",
|
|
257
|
+
imagePinning: "needs a run against an image absent from the target runtime",
|
|
258
|
+
nonRoot: "needs `id -u` inside a live container (`verify-image.sh` is the shape)",
|
|
259
|
+
secretsCustody: "needs a canary secret and an audit of every log, record and forge comment the run produced",
|
|
260
|
+
credentialTransit: "needs to observe what actually crossed the network to the runtime",
|
|
261
|
+
localFolders: "needs a bind-mounted host folder and a write read back outside the container",
|
|
262
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE `local` BACKEND: the Docker daemon on this worker's own host (issue #227).
|
|
3
|
+
*
|
|
4
|
+
* `backends.mjs` says WHAT each backend guarantees; this module is the first thing that has to be true.
|
|
5
|
+
* It bundles the functions that actually build and run a container into one value, so that "a backend" is
|
|
6
|
+
* a thing the worker holds rather than a shape spread across four `deps` keys nobody names together.
|
|
7
|
+
*
|
|
8
|
+
* WHY THE TABLE DOES NOT HOLD A `make()`. The obvious design is one entry per backend carrying its own
|
|
9
|
+
* factory, and it cannot work here: `backends.mjs` imports NOTHING on purpose, because `doctor`, the config
|
|
10
|
+
* loader and the receiver all have to read a declaration without pulling the Docker implementation into
|
|
11
|
+
* their graph. A `make()` in the table is an import edge from the leaf to every adapter, which is the leaf
|
|
12
|
+
* property gone. So the table declares and this module implements, and the two are joined by NAME -- the
|
|
13
|
+
* same split `forges.mjs` uses against the forge hosts.
|
|
14
|
+
*
|
|
15
|
+
* FIVE FUNCTIONS, and the last two arrived late on purpose. `stopContainer` was a one-line literal inside
|
|
16
|
+
* `index.mjs`'s `createWorker` and unreachable from `startWorker`; `reap` lived in `start.mjs` and returns a
|
|
17
|
+
* TRI-STATE that `makeScopeClaimSweeper` gates a money decision on. Neither could move without its reasoning
|
|
18
|
+
* moving too, so an earlier slice declared `abortable` in the table, named both as deferred, and REFUSED a
|
|
19
|
+
* bundle that tried to supply them -- because an adapter author who passes `stopContainer` and has it
|
|
20
|
+
* silently dropped believes a runaway job can be stopped through their backend when the abort path still
|
|
21
|
+
* calls docker directly. That refusal is now gone because the seam is real.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
import { BACKENDS, DEFAULT_BACKEND, DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
|
|
27
|
+
|
|
28
|
+
const execDocker = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
|
|
32
|
+
* readability. TWO sweeps match it as a SUBSTRING, both at boot in `start.mjs`: the container reaper's
|
|
33
|
+
* `docker ps` filter and the network reaper's `docker network ls` filter. The sandbox tooling is the
|
|
34
|
+
* counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
|
|
35
|
+
* namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
|
|
36
|
+
* by job id rather than by name.
|
|
37
|
+
*
|
|
38
|
+
* Exported and imported rather than re-typed at each site for the reason `CONTAINER_GLOBAL_PI_DIR` is: the
|
|
39
|
+
* namespace and the filters that sweep it are ONE fact, and two literals in two modules is how a rename
|
|
40
|
+
* lands in the producer and not in the reaper, leaving every crashed worker's containers behind forever with
|
|
41
|
+
* both test suites green.
|
|
42
|
+
*/
|
|
43
|
+
export const JOB_NAME_PREFIX = "pi-job-";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
|
|
47
|
+
* per-job egress network derived from it, and for the reaper's filter.
|
|
48
|
+
*
|
|
49
|
+
* Not sanitised here: BullMQ ids are already `[A-Za-z0-9._-]`, and the one place a job id comes from
|
|
50
|
+
* anywhere else (a sandbox) goes through `sanitizeJobId` under its own prefix.
|
|
51
|
+
*/
|
|
52
|
+
export function jobContainerName(jobId) {
|
|
53
|
+
return `${JOB_NAME_PREFIX}${jobId}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Bundle the local backend's already-built functions into one checked value.
|
|
58
|
+
*
|
|
59
|
+
* Takes them BUILT rather than building them from config, because each is constructed in `start.mjs` from a
|
|
60
|
+
* different slice of the deployment and behind its own injectable factory that the wiring tests drive. This
|
|
61
|
+
* function's job is not to own that construction; it is to be the one place that says these functions
|
|
62
|
+
* together are a backend, and to REFUSE a bundle that is missing one.
|
|
63
|
+
*
|
|
64
|
+
* The refusal is worth having and worth not overselling. It catches a bundle assembled with a key MISSING or
|
|
65
|
+
* not callable, which is a wiring mistake that would otherwise surface as an unhelpful `undefined is not a
|
|
66
|
+
* function` deep inside a paid job. It proves ARITY AND NOTHING ELSE: `makeEgressPreflight({ armed: false })`
|
|
67
|
+
* returns a function that answers `{ ok: true }` and spawns nothing, so a bundle can pass this check with a
|
|
68
|
+
* gate that does no gating. `backend-conformance.mjs` does not close that either: it never invokes
|
|
69
|
+
* `imagePreflight` or `egressPreflight`, so a stubbed gate remains exactly what neither check can see. What
|
|
70
|
+
* the harness does verify is listed in its own header, and what it cannot is listed beside it.
|
|
71
|
+
*/
|
|
72
|
+
/** The functions a bundle carries today. Deferred members are refused BY NAME below, never ignored. */
|
|
73
|
+
export const BACKEND_FUNCTIONS = ["runContainer", "imagePreflight", "egressPreflight", "stopContainer", "reap"];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Members `makeLocalBackend` SETS rather than takes. An adapter written elsewhere supplies them itself; the
|
|
77
|
+
* local bundle knows its own, so passing them here is refused as an unknown member like any other.
|
|
78
|
+
*/
|
|
79
|
+
export const BACKEND_PROVIDED = ["name", "declares", "namePrefix", "containerName", "neverStartedExits", "binds"];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Non-function members every bundle must also carry. Separate from the list above because the completeness
|
|
83
|
+
* check tests callability, and these are values -- but they are just as required: `neverStartedExits` gates
|
|
84
|
+
* a budget REFUND, so a bundle that omitted it would keep the slot and let BullMQ retry, burning a second
|
|
85
|
+
* one per never-started job. That is the exact bug the explicit `case 125/126/127` was added to fix,
|
|
86
|
+
* reachable again by an adapter simply not setting a property.
|
|
87
|
+
*/
|
|
88
|
+
export const BACKEND_VALUES = ["neverStartedExits"];
|
|
89
|
+
|
|
90
|
+
export function makeLocalBackend(parts = {}) {
|
|
91
|
+
const { runContainer, imagePreflight, egressPreflight, stopContainer, reap } = parts ?? {};
|
|
92
|
+
const missing = Object.entries({ runContainer, imagePreflight, egressPreflight, stopContainer, reap })
|
|
93
|
+
.filter(([, fn]) => typeof fn !== "function")
|
|
94
|
+
.map(([k]) => k);
|
|
95
|
+
if (missing.length > 0) {
|
|
96
|
+
throw new Error(`backend "${DEFAULT_BACKEND}": cannot build a backend missing ${missing.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// An unknown key is REFUSED rather than dropped, and the two this slice defers are named as deferred.
|
|
100
|
+
// An adapter author who supplies `stopContainer` has read the issue and reasonably expects it to be
|
|
101
|
+
// wired; silently ignoring it would leave them believing a runaway job can be stopped through their
|
|
102
|
+
// backend while the abort path still goes straight to the local docker CLI. That is the believed-in
|
|
103
|
+
// control again, arriving through a dropped argument.
|
|
104
|
+
for (const key of Object.keys(parts ?? {})) {
|
|
105
|
+
if (BACKEND_FUNCTIONS.includes(key) || BACKEND_VALUES.includes(key)) continue;
|
|
106
|
+
throw new Error(`backend "${DEFAULT_BACKEND}": unknown bundle member ${JSON.stringify(key)} (this factory takes ${BACKEND_FUNCTIONS.join(", ")} and sets ${BACKEND_PROVIDED.join(", ")} itself)`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name: DEFAULT_BACKEND,
|
|
111
|
+
// The declaration is READ from the table, never re-typed here. An adapter that stated its own
|
|
112
|
+
// guarantees inline could drift from what `doctor` prints and what the boot refusal checks, and an
|
|
113
|
+
// operator would then be told one thing by the thing that decides and another by the thing that ran.
|
|
114
|
+
// This is the SAME object the table holds, and that is safe only because the table is deeply FROZEN:
|
|
115
|
+
// an unfrozen alias would let any holder of a bundle rewrite what `doctor`, the boot refusal and the
|
|
116
|
+
// receiver are all told about this backend, process-wide and invisibly, while the source still read
|
|
117
|
+
// `enforced`. A defensive copy would hide such a mutation rather than prevent it.
|
|
118
|
+
declares: BACKENDS[DEFAULT_BACKEND].declares,
|
|
119
|
+
namePrefix: JOB_NAME_PREFIX,
|
|
120
|
+
containerName: jobContainerName,
|
|
121
|
+
runContainer,
|
|
122
|
+
imagePreflight,
|
|
123
|
+
egressPreflight,
|
|
124
|
+
stopContainer,
|
|
125
|
+
reap,
|
|
126
|
+
// The integers this runtime uses for "the runner never ran". The processor asks the BACKEND rather
|
|
127
|
+
// than assuming docker's triple, because those numbers collide with the runner's own exit channel.
|
|
128
|
+
neverStartedExits: LOCAL_NEVER_STARTED_EXITS,
|
|
129
|
+
// This runtime BIND-MOUNTS, so `/job`'s read-only is the kernel's. The conformance harness abstains
|
|
130
|
+
// rather than passing when a bundle does not say, because a copy downgrades that to a convention.
|
|
131
|
+
binds: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The exit codes that mean THE RUNNER NEVER RAN, as this runtime spells them.
|
|
137
|
+
*
|
|
138
|
+
* Docker's own convention, and defined in `backends.mjs` rather than here so the processor can default to
|
|
139
|
+
* it without importing this module. Re-exported under the local backend's name because that is what the
|
|
140
|
+
* bundle carries and what an adapter author reads.
|
|
141
|
+
*/
|
|
142
|
+
export const LOCAL_NEVER_STARTED_EXITS = DOCKER_NEVER_STARTED_EXITS;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `docker stop` on the job's container name, fired by the abort (the 30-minute timeout or a shutdown).
|
|
146
|
+
*
|
|
147
|
+
* MOVED HERE from `index.mjs`'s `createWorker`, where it was a one-line literal inside the processor's
|
|
148
|
+
* construction and could not be reached from `startWorker` at all. That is why `abortable` was declared in
|
|
149
|
+
* the table two slices before this function existed: a second backend could have passed every other check
|
|
150
|
+
* with no way to stop a runaway container, and nothing in the table would have moved.
|
|
151
|
+
*
|
|
152
|
+
* `-t 5` is SIGTERM then SIGKILL after five seconds. The runner exits, `docker run` returns, and
|
|
153
|
+
* `runContainer`'s promise resolves -- so the abort's effect reaches the processor through the container's
|
|
154
|
+
* own exit rather than through this call's return value, which is why nothing awaits it.
|
|
155
|
+
*
|
|
156
|
+
* `INT-RUNNER-EXIT-CODE-PROTOCOL` is what makes this transferable: the discriminator is the abort FLAG the
|
|
157
|
+
* processor already holds, not the exit code, because a worker SIGKILL and a kernel OOM both surface as
|
|
158
|
+
* 137. An adapter implements "stop this job" however its runtime spells it and the classification is
|
|
159
|
+
* unchanged.
|
|
160
|
+
*/
|
|
161
|
+
export function makeStopContainer({ exec = execDocker } = {}) {
|
|
162
|
+
return async function stopContainer(name) {
|
|
163
|
+
return exec("docker", ["stop", "-t", "5", name]);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Boot-time reaper: clear stray `pi-job-*` containers a previous worker crash left behind.
|
|
169
|
+
*
|
|
170
|
+
* MOVED HERE from `start.mjs` (issue #227). It belongs to the backend because the containers it sweeps are
|
|
171
|
+
* that backend's, and a second backend's crashed containers are unreachable by this one's `docker ps`.
|
|
172
|
+
*
|
|
173
|
+
* THE TRI-STATE IS THE POINT and moved with it: `{ reaped: true }` means this host has ESTABLISHED that it
|
|
174
|
+
* holds no job containers, `{ reaped: false }` means it could not establish that. `makeScopeClaimSweeper`
|
|
175
|
+
* gates a money decision on the difference -- it may only delete a scope claim naming this host once the
|
|
176
|
+
* host has proven it holds nothing -- so returning `[]` or `true` on a failed enumeration would free slots
|
|
177
|
+
* for containers that may still be running and let another host start more alongside them. That is a spend
|
|
178
|
+
* overrun rather than a tidy-up, which is why the catch below returns false rather than swallowing.
|
|
179
|
+
*/
|
|
180
|
+
export function makeReaper({ log, exec = execDocker }) {
|
|
181
|
+
return async function reap() {
|
|
182
|
+
try {
|
|
183
|
+
const { stdout } = await exec("docker", ["ps", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Names}}"]);
|
|
184
|
+
const names = stdout
|
|
185
|
+
.split("\n")
|
|
186
|
+
.map((n) => n.trim())
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
await exec("docker", ["rm", "-f", name]);
|
|
190
|
+
log("reaped_container", { name });
|
|
191
|
+
}
|
|
192
|
+
// REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
|
|
193
|
+
// because a network with a member still attached cannot be removed -- and swept by the SAME
|
|
194
|
+
// `pi-job-` filter, so the namespace rule that keeps an operator's live sandbox safe from the
|
|
195
|
+
// container reaper keeps their sandbox NETWORK safe too, with no second rule to remember.
|
|
196
|
+
//
|
|
197
|
+
// A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
|
|
198
|
+
// on every ordinary path, so anything still here outlived a process that did not get to run it.
|
|
199
|
+
// A network still in use by something else fails to remove and is skipped, which is correct: this
|
|
200
|
+
// is a best-effort sweep and never a reason not to boot.
|
|
201
|
+
const { stdout: nets } = await exec("docker", ["network", "ls", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Name}}"]);
|
|
202
|
+
for (const net of nets.split("\n").map((n) => n.trim()).filter(Boolean)) {
|
|
203
|
+
try {
|
|
204
|
+
await exec("docker", ["network", "rm", net]);
|
|
205
|
+
log("reaped_network", { network: net });
|
|
206
|
+
} catch {} // still in use, or already gone -- either way not this boot's problem
|
|
207
|
+
}
|
|
208
|
+
// Whether the enumeration HAPPENED, which the scope-claim sweep depends on: it may only delete a
|
|
209
|
+
// claim naming this host once this host has actually established that it holds no containers.
|
|
210
|
+
return { reaped: true };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// The `docker ps` is inside this try, so this path CANNOT establish that this host holds no
|
|
213
|
+
// containers -- whether it failed before listing anything or after reaping some and then losing
|
|
214
|
+
// the daemon. Either way the claim "I hold nothing" is unproven, and sweeping on it would free
|
|
215
|
+
// slots for containers that may STILL BE RUNNING, letting another host start more alongside
|
|
216
|
+
// them: a money overrun rather than a tidy-up. Conservative in the only safe direction.
|
|
217
|
+
log("reaper_skipped", { reason: err?.message });
|
|
218
|
+
return { reaped: false };
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|