@edgehero/pi-dispatch 1.10.2 → 2.0.0
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 +300 -148
- package/README.md +50 -0
- package/deploy/com.pi-dispatch.worker.plist +9 -3
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +11 -0
- package/deploy/worker-env-wrapper.sh +60 -34
- package/deploy/worker.service +18 -8
- package/package.json +14 -4
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4701 -414
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +455 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +222 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-registry.mjs +32 -5
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +387 -17
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/processor.mjs +505 -26
- package/src/provider-key.mjs +41 -0
- package/src/provider-steering.mjs +144 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +23 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1395 -268
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +176 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
package/src/backend-local.mjs
CHANGED
|
@@ -23,14 +23,47 @@
|
|
|
23
23
|
|
|
24
24
|
import { execFile } from "node:child_process";
|
|
25
25
|
import { promisify } from "node:util";
|
|
26
|
+
import { scrubCredentials } from "./redact.mjs";
|
|
26
27
|
import { BACKENDS, DEFAULT_BACKEND, DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
|
|
28
|
+
import { DEFAULT_EGRESS_PROXY, ENDPOINT_LISTED_STATES, networkEndpoints, removeNetworkOrSay } from "./egress.mjs";
|
|
29
|
+
import { makeDetachGate } from "./netns-keeper.mjs";
|
|
30
|
+
import { isDeterminateFsCode } from "./transient.mjs";
|
|
27
31
|
|
|
28
32
|
const execDocker = promisify(execFile);
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The boot reaper's step bound (issue #452, gate round 2). Its `exec` had none, so a wedged daemon held the worker's boot
|
|
36
|
+
* with nothing said. 30 s, `RUN_TIMEOUTS.cmd`'s bound for one CLI step, and not the sandbox sweep's 10 s: Podman's `rm
|
|
37
|
+
* -f` of a running container waits its stop timeout (10 s by default, measured 10.1 s on 5.8.1) before SIGKILL, and the
|
|
38
|
+
* container half removes RUNNING job containers. A step killed by the bound rejects like a failed one: in the container
|
|
39
|
+
* half that is the conservative `{ reaped: false }`, in the network half an unreadable or failed step.
|
|
40
|
+
*/
|
|
41
|
+
export const REAPER_STEP_TIMEOUT_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reaper's default `exec`: `promisify(execFile)`'s contract (resolves `{ stdout, stderr }`, rejects with the exit
|
|
45
|
+
* code on `.code` and both streams on the error), with a bound. A step the bound kills is SIGKILLed and rejects with
|
|
46
|
+
* `killed: true` and no numeric code, which every caller already reads as no answer.
|
|
47
|
+
*/
|
|
48
|
+
export function reaperExec({ execFileFn = execFile, timeoutMs = REAPER_STEP_TIMEOUT_MS } = {}) {
|
|
49
|
+
// `opts` (issue #452, gate round 4): a step may ask for its own bound, as the detach gate's runtime read does (15 s,
|
|
50
|
+
// 1 MiB, the facts readers' own); every other step keeps the reaper's 30 s.
|
|
51
|
+
return (bin, args, opts = {}) =>
|
|
52
|
+
new Promise((resolve, reject) => {
|
|
53
|
+
execFileFn(bin, [...args], { timeout: opts.timeoutMs ?? timeoutMs, killSignal: "SIGKILL", maxBuffer: opts.maxBuffer ?? 1024 * 1024 }, (err, stdout, stderr) => {
|
|
54
|
+
if (err) reject(Object.assign(err, { stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
|
|
55
|
+
else resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const execReaperBounded = reaperExec();
|
|
60
|
+
|
|
30
61
|
/**
|
|
31
62
|
* `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
|
|
32
63
|
* 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.
|
|
64
|
+
* `docker ps` filter and the network reaper's `docker network ls` filter. They share the NAME rule
|
|
65
|
+
* (`isJobNamespace`) and deliberately not the STATE question -- see `reapNetwork` for why one lists running
|
|
66
|
+
* containers and the other asks about members in any state. The sandbox tooling is the
|
|
34
67
|
* counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
|
|
35
68
|
* namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
|
|
36
69
|
* by job id rather than by name.
|
|
@@ -42,15 +75,33 @@ const execDocker = promisify(execFile);
|
|
|
42
75
|
*/
|
|
43
76
|
export const JOB_NAME_PREFIX = "pi-job-";
|
|
44
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The container states `docker network inspect` lists in `.Containers`, and therefore the ones the daemon
|
|
80
|
+
* itself guards: with one attached, `network rm` fails "has active endpoints" (measured, docker 27.4.0).
|
|
81
|
+
* `paused` is here because it is listed and the `rm` refuses while it is there.
|
|
82
|
+
*
|
|
83
|
+
* DEFINED in `egress.mjs` since issue #452 and re-exported here under its old name: `networkEndpoints` now
|
|
84
|
+
* sorts Podman's members by the same set, so it is one fact with two readers, and `egress.mjs` cannot import
|
|
85
|
+
* from this module without a cycle. Its reasons (an allowlist, and why `restarting` is not in it) are there.
|
|
86
|
+
*/
|
|
87
|
+
export { ENDPOINT_LISTED_STATES };
|
|
88
|
+
|
|
45
89
|
/**
|
|
46
90
|
* `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
|
|
47
91
|
* per-job egress network derived from it, and for the reaper's filter.
|
|
48
92
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
93
|
+
* Sanitised to the runtimes' own name rule, `[a-zA-Z0-9][a-zA-Z0-9_.-]*` (docker's `RestrictedNameChars`, and
|
|
94
|
+
* Podman's, measured), every other character becoming `_`, as `sanitizeJobId` does for file names. It said here that
|
|
95
|
+
* BullMQ ids are already that shape, and a cron job's is not: the job scheduler mints `repeat:<schedulerId>:<millis>`,
|
|
96
|
+
* so `pi-job-repeat:...` was refused at create (exit 125, measured on Podman 5.8.1) and so was its `-net` network, and
|
|
97
|
+
* every cron job ended `container-never-started` before it ever ran (issue #435). The prefix supplies the first
|
|
98
|
+
* character. Deterministic, because every path that must find the container again (the timeout's stop, the cancel,
|
|
99
|
+
* the network, the log sink) asks this function for the name rather than deriving it. Not injective: `repeat:a:1`
|
|
100
|
+
* and a literal `repeat_a_1` share a name. BullMQ ids are unique per queue and no producer here mints an id with `_`
|
|
101
|
+
* where a scheduler id has `:`, so it is a residual rather than a collision anything reaches.
|
|
51
102
|
*/
|
|
52
103
|
export function jobContainerName(jobId) {
|
|
53
|
-
return `${JOB_NAME_PREFIX}${jobId}`;
|
|
104
|
+
return `${JOB_NAME_PREFIX}${String(jobId).replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
54
105
|
}
|
|
55
106
|
|
|
56
107
|
/**
|
|
@@ -158,12 +209,198 @@ export const LOCAL_NEVER_STARTED_EXITS = DOCKER_NEVER_STARTED_EXITS;
|
|
|
158
209
|
* 137. An adapter implements "stop this job" however its runtime spells it and the classification is
|
|
159
210
|
* unchanged.
|
|
160
211
|
*/
|
|
161
|
-
export function makeStopContainer({ exec = execDocker } = {}) {
|
|
212
|
+
export function makeStopContainer({ exec = execDocker, bin = "docker" } = {}) {
|
|
213
|
+
// Issue #354: `bin` is the venue's CLI. A stop sent to a runtime other than the one that runs the job answers "no such
|
|
214
|
+
// container" while the job runs on, which is the unstoppable runaway this function exists to rule out.
|
|
162
215
|
return async function stopContainer(name) {
|
|
163
|
-
return exec(
|
|
216
|
+
return exec(bin, ["stop", "-t", "5", name]);
|
|
164
217
|
};
|
|
165
218
|
}
|
|
166
219
|
|
|
220
|
+
/**
|
|
221
|
+
* What the CLI resolved, as a phrase that is never EMPTY. Four call sites interpolate an endpoint into a
|
|
222
|
+
* sentence of the form "resolves <this>, which is not shown to be on this host", and a context can carry no
|
|
223
|
+
* host at all: `docker context create X --docker host=` is accepted by docker 27.4.0 (exit 0, "Successfully
|
|
224
|
+
* created context"), and `context inspect` then renders the Host as `""`. `classifyDockerEndpoint("")`
|
|
225
|
+
* answers `{ local: false, display: "" }`, which is the right answer to its own question, and every one of
|
|
226
|
+
* those sites then printed "resolves , which is not shown to be on this host" at an operator.
|
|
227
|
+
*
|
|
228
|
+
* A PHRASE rather than a fallback value, so no caller can mistake what it returns for something to hand to
|
|
229
|
+
* docker. WHITESPACE counts as empty: `docker context create` refuses a blank host, but `context inspect`,
|
|
230
|
+
* which is the command doctor actually runs, does NOT re-validate what the context store already holds, so
|
|
231
|
+
* the read path can return one.
|
|
232
|
+
*
|
|
233
|
+
* ANYTHING NOT PLAINLY PRINTABLE IS QUOTED AND ESCAPED, never removed, and the difference is the whole of
|
|
234
|
+
* this function's second job. `displayEndpoint` returns a host with no `@` VERBATIM -- it withholds
|
|
235
|
+
* credentials and was never a sanitiser -- so whatever a context store holds reaches a line an operator
|
|
236
|
+
* reads, and an erase-line plus a carriage return wipes the warning and rewrites it from column 0.
|
|
237
|
+
*
|
|
238
|
+
* A first attempt STRIPPED those bytes, and stripping is the wrong rule in both directions, measured on
|
|
239
|
+
* docker 27.4.0. It FORGES: `docker context create` accepts a C1 byte, so a stored
|
|
240
|
+
* `tcp://127.0.0.1<U+0085>:2375` is correctly classified NOT local (the parser percent-encodes it into the
|
|
241
|
+
* hostname) and then printed as a clean loopback address, giving a sentence that contradicts itself and a
|
|
242
|
+
* value that survives copy, paste and grep as something the operator never configured. And it CORRUPTS: a
|
|
243
|
+
* unix socket really can live at a path containing one (created, resolved and dialled on this host), and
|
|
244
|
+
* stripping renames it to a path that does not exist -- while `job-user.mjs`, `sandbox.mjs` and
|
|
245
|
+
* `runtime-observations.mjs` go on reading the UNSTRIPPED value as a real filesystem path, so doctor would
|
|
246
|
+
* name one file and the system use another.
|
|
247
|
+
*
|
|
248
|
+
* So: printable ASCII (U+0020 to U+007E) passes through untouched, and anything else is rendered as a
|
|
249
|
+
* quoted, fully escaped string, which one `JSON.parse` turns back into what was stored. Nothing is deleted,
|
|
250
|
+
* which is the property that matters, and it disarms the whole class at once rather than one codepoint
|
|
251
|
+
* range of it, so a right-to-left override, a zero-width joiner and a line separator are as visible as an
|
|
252
|
+
* ESC. U+007F is on the escaped side of the boundary with the C1 block, which is why the range ends at 7E.
|
|
253
|
+
*
|
|
254
|
+
* TWO LIMITS, stated because "lossless" on its own would overstate them. The mapping is not INJECTIVE: a
|
|
255
|
+
* stored value whose printable text happens to be a quoted escape sequence renders byte-identically to the
|
|
256
|
+
* escaped form of the value it describes, so an operator cannot tell whose quotes they are. Reachable only
|
|
257
|
+
* through the unvalidated read path, since `context create` refuses a quote-wrapped host. And a value that
|
|
258
|
+
* is only WHITESPACE, control whitespace such as a lone carriage return or tab included, is reported as
|
|
259
|
+
* empty rather than escaped: `trim()` decides that, and it is a deliberate simplification rather than an
|
|
260
|
+
* oversight, because an endpoint of one tab is empty in every way an operator cares about. The earlier
|
|
261
|
+
* comment here also claimed docker's URL parser kept control bytes out, having measured `context create`
|
|
262
|
+
* and `DOCKER_HOST`: both are WRITE paths, the read path does not re-validate, and C1 is accepted on the
|
|
263
|
+
* write path anyway, so the claim was wrong on its own terms as well as measured on the wrong command.
|
|
264
|
+
*
|
|
265
|
+
* RESIDUAL, named rather than closed: a stored endpoint whose value IS the literal text "an empty endpoint"
|
|
266
|
+
* is indistinguishable from the empty case. `docker context create` refuses it (no scheme), and it costs one
|
|
267
|
+
* sentence misread.
|
|
268
|
+
*/
|
|
269
|
+
export function endpointShown(endpoint) {
|
|
270
|
+
const shown = String(endpoint?.endpoint ?? "");
|
|
271
|
+
if (shown.trim() === "") return "an empty endpoint";
|
|
272
|
+
if (shown.length <= ENDPOINT_SHOWN_MAX && /^[\x20-\x7e]+$/.test(shown)) return shown;
|
|
273
|
+
return boundedShown(shown);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The same escaping, always quoted, for a value that is not an endpoint: a context NAME.
|
|
278
|
+
*
|
|
279
|
+
* `JSON.stringify` was what printed those, and it escapes C0 and stops: a C1 CSI byte, a right-to-left
|
|
280
|
+
* override and a zero-width joiner all reached the terminal through it. This is the same class the endpoint
|
|
281
|
+
* itself is held to, so the two halves of `resolves context X to Y` cannot be protected differently.
|
|
282
|
+
*
|
|
283
|
+
* It must agree with `JSON.stringify` on the ordinary cases, because existing pins read that shape:
|
|
284
|
+
* `""` for the empty string, and `null`/`undefined` rendered as themselves.
|
|
285
|
+
*/
|
|
286
|
+
export function quotedShown(value) {
|
|
287
|
+
if (value === null || value === undefined) return String(value);
|
|
288
|
+
const shown = String(value);
|
|
289
|
+
// The CAP IS ON THE RENDERED TEXT, quotes included, which the fast path got wrong: 300 printable
|
|
290
|
+
// characters render as 302, and a value of 300 quote characters as 602. A 300-character context name is
|
|
291
|
+
// creatable (`docker context create` accepts one, measured), so this was reachable.
|
|
292
|
+
const quoted = JSON.stringify(shown);
|
|
293
|
+
if (quoted.length <= ENDPOINT_SHOWN_MAX && /^[\x20-\x7e]*$/.test(shown)) return quoted;
|
|
294
|
+
return boundedShown(shown);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 300 characters of RENDERED text, and the number is a literal rather than a computation.
|
|
299
|
+
*
|
|
300
|
+
* A DNS name is at most 253 bytes plus a scheme and a port, and a unix socket path is capped at 104 bytes on
|
|
301
|
+
* macOS and 108 on Linux, so no PRINTABLE endpoint a daemon can actually have reaches this. A 104-byte
|
|
302
|
+
* socket path made entirely of C1 bytes renders to about 321 characters and IS cut, which is the honest
|
|
303
|
+
* statement of the bound: it cuts escaped text, not real endpoints.
|
|
304
|
+
*/
|
|
305
|
+
export const ENDPOINT_SHOWN_MAX = 300;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Escape, then cut -- and the ORDER is the opposite of `SCRUBBED_MAX`'s, deliberately.
|
|
309
|
+
*
|
|
310
|
+
* Cutting first and escaping after can land the cut inside a `\u00e9` and produce text no `JSON.parse` will
|
|
311
|
+
* read. Escaping first and cutting after can do the same. So this walks CODE POINTS, escapes each one, and
|
|
312
|
+
* stops BEFORE the rendered text would pass the cap -- the quoted part is therefore always parseable and
|
|
313
|
+
* always a PREFIX of the input, which is the property an operator needs when they compare it to what they
|
|
314
|
+
* configured.
|
|
315
|
+
*
|
|
316
|
+
* Per code point, not per UTF-16 unit: iterating units cuts between surrogates and emits a lone one (1,537
|
|
317
|
+
* of them in a 50k-case fuzz of the version this replaced). And each escape is built from `JSON.stringify`
|
|
318
|
+
* of the whole code point rather than `codePointAt(0).toString(16)`, which emits `\u1f600`-shaped text for
|
|
319
|
+
* an astral character -- that parses as `\u1f60` followed by a literal `0`, so the result was not a prefix
|
|
320
|
+
* of anything (16,916 fuzz failures).
|
|
321
|
+
*/
|
|
322
|
+
function boundedShown(value) {
|
|
323
|
+
let out = "";
|
|
324
|
+
let kept = 0;
|
|
325
|
+
let cut = false;
|
|
326
|
+
const points = [...value];
|
|
327
|
+
for (const point of points) {
|
|
328
|
+
const piece = /^[\x20-\x7e]$/.test(point) ? (point === '"' || point === "\\" ? `\\${point}` : point) : escapedPoint(point);
|
|
329
|
+
// +2 for the quotes this will be wrapped in. Stop BEFORE passing the cap, never after.
|
|
330
|
+
if (out.length + piece.length + 2 > ENDPOINT_SHOWN_MAX) {
|
|
331
|
+
cut = true;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
out += piece;
|
|
335
|
+
kept += 1;
|
|
336
|
+
}
|
|
337
|
+
// The count outside the quotes, so the quoted part stays exactly what `JSON.parse` will take.
|
|
338
|
+
return cut ? `"${out}" (first ${kept} of ${points.length} characters)` : `"${out}"`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function escapedPoint(point) {
|
|
342
|
+
// PER UTF-16 UNIT, which is what `\uXXXX` means. Escaping the CODE POINT instead emits `\u1f600` for an
|
|
343
|
+
// astral character -- five hex digits, which `JSON.parse` reads as `\u1f60` followed by a literal `0`, so
|
|
344
|
+
// the result is not the value and not a prefix of it (16,916 failures in a 50,000-case fuzz). Both halves
|
|
345
|
+
// of a surrogate pair are always emitted together, because the caller walks whole code points, so this
|
|
346
|
+
// can never leave a lone surrogate behind.
|
|
347
|
+
// JSON's OWN SHORT ESCAPES for the five it has (`\b \t \n \f \r`), because the claim this renderer makes
|
|
348
|
+
// is that an under-cap value renders byte-identically to what `JSON.stringify` produced before the bound
|
|
349
|
+
// existed -- and `\u0009` for a tab is not that. Everything else is escaped per UTF-16 unit below.
|
|
350
|
+
const SHORT = { "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r" };
|
|
351
|
+
let out = "";
|
|
352
|
+
for (let i = 0; i < point.length; i++) {
|
|
353
|
+
const unit = point.charCodeAt(i);
|
|
354
|
+
out += SHORT[point[i]] ?? (unit >= 0x20 && unit <= 0x7e ? point[i] : `\\u${unit.toString(16).padStart(4, "0")}`);
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Is this name one THIS project claims? ONE answer for both halves of the boot reaper, which is the whole
|
|
361
|
+
* point of it being a function rather than two tests (issue #360, item 7).
|
|
362
|
+
*
|
|
363
|
+
* It is the bare prefix, and it is NOT an accident that it does not require `-net` of a network. The two
|
|
364
|
+
* halves used to disagree: the container half was this `startsWith` and the network half was
|
|
365
|
+
* `^pi-job-.*-net$`, so an operator's `pi-job-runner_default` had its CONTAINER reaped and its NETWORK left
|
|
366
|
+
* standing. Two answers to "what is ours" is the defect; which answer to keep is the decision, and the
|
|
367
|
+
* container half cannot be the one that moves. After a crash nothing distinguishes our `pi-job-<id>` from
|
|
368
|
+
* any other name under the prefix, and a charset rule does not separate them either. `jobContainerName` maps a
|
|
369
|
+
* job id onto `[A-Za-z0-9._-]` (since issue #435, when a cron id's `:` was found to be refused by the runtime), so
|
|
370
|
+
* `runner_default` and `runner-db-1` are both shapes a real job's name can take. There is no stricter rule
|
|
371
|
+
* available that is also TRUE, so the halves agree by widening the network one.
|
|
372
|
+
*
|
|
373
|
+
* WHAT THAT COSTS, stated rather than buried in a test diff: a network called `pi-job-mine-net-backup`, or
|
|
374
|
+
* `pi-job-runner_default`, is now removed by the boot reaper. Their CONTAINERS always were. `SECURITY.md`
|
|
375
|
+
* states "the `pi-job-*` namespace the boot reaper clears" as a deliberate claim, so this makes the code
|
|
376
|
+
* agree with the claim rather than extending it -- but the objects are an operator's, so it is named in the
|
|
377
|
+
* commit, in `SECURITY.md` and in the spec, not only here.
|
|
378
|
+
*
|
|
379
|
+
* The prefix is still only half the rule at the call sites. `docker`'s `--filter name=` is a SUBSTRING match
|
|
380
|
+
* (measured on 27.4.0: `my-pi-job-notes` comes back from `--filter name=pi-job-`), so the filter is the cheap
|
|
381
|
+
* server-side narrowing and this anchored test is the namespace decision. A name that merely CONTAINS the
|
|
382
|
+
* prefix is not ours and never was.
|
|
383
|
+
*
|
|
384
|
+
* Not a constant called `_SHAPE`: a name that says "shape" while holding a prefix test is a name that lies,
|
|
385
|
+
* and the previous one did.
|
|
386
|
+
*
|
|
387
|
+
* The `typeof` guard is UNREACHABLE from both production call sites, and saying so is the point rather than
|
|
388
|
+
* claiming a coverage it does not have: the container listing is `stdout.split("\n").map(trim)`, and
|
|
389
|
+
* `networkEndpoints` already coerces every endpoint with `String(c?.Name ?? "")` before returning, so
|
|
390
|
+
* neither can hand this a non-string. It is here because this is EXPORTED, and a predicate that throws on a
|
|
391
|
+
* value it should simply answer `false` to is a trap for the next caller -- inside `makeReaper` that throw
|
|
392
|
+
* reaches the outer catch and answers `{ reaped: false }`, which is a money decision. Measured: dropping the
|
|
393
|
+
* guard is caught by the unit table below and by no call-site test.
|
|
394
|
+
*
|
|
395
|
+
* `SANDBOX_NETWORK_SHAPE` is the sibling that must NOT be loosened the same way, and the difference is what
|
|
396
|
+
* the name is FOR rather than taste: it carries a capture group and the sandbox sweep parses the session id
|
|
397
|
+
* back out of it to key against the directories it kept. A predicate cannot answer that question, so the
|
|
398
|
+
* sandbox's full shape is load-bearing where this one's was only a filter.
|
|
399
|
+
*/
|
|
400
|
+
export function isJobNamespace(name) {
|
|
401
|
+
return typeof name === "string" && name.startsWith(JOB_NAME_PREFIX);
|
|
402
|
+
}
|
|
403
|
+
|
|
167
404
|
/**
|
|
168
405
|
* Boot-time reaper: clear stray `pi-job-*` containers a previous worker crash left behind.
|
|
169
406
|
*
|
|
@@ -177,34 +414,162 @@ export function makeStopContainer({ exec = execDocker } = {}) {
|
|
|
177
414
|
* for containers that may still be running and let another host start more alongside them. That is a spend
|
|
178
415
|
* overrun rather than a tidy-up, which is why the catch below returns false rather than swallowing.
|
|
179
416
|
*/
|
|
180
|
-
export function makeReaper({ log, exec =
|
|
417
|
+
export function makeReaper({ log, exec = execReaperBounded, bin = "docker" }) {
|
|
418
|
+
// Issue #354: every spawn below names `bin`, the venue's CLI, so a podman venue's reaper enumerates, removes and
|
|
419
|
+
// sweeps in the store its jobs actually ran in. A mixed pass (listing under one binary, removing under another) would
|
|
420
|
+
// report `reaped: true` for a host whose containers it never saw, and the scope-claim sweep spends on that answer.
|
|
421
|
+
// The SAME injected `exec`, as a NON-THROWING `{ code, stdout, stderr }` step. Two things fall out and both
|
|
422
|
+
// are load-bearing. It is the shape `networkEndpoints` and `removeNetworkOrSay` need -- the "not found" rule
|
|
423
|
+
// reads both streams, and with `--format` the daemon puts that wording on stderr with stdout empty (measured
|
|
424
|
+
// on docker 27.4.0). And because it cannot throw, the PER-NETWORK calls cannot reach the outer catch, so the
|
|
425
|
+
// work this slice adds cannot flip the tri-state a scope claim is spent on.
|
|
426
|
+
//
|
|
427
|
+
// The `network ls` itself deliberately stays on the throwing `exec`, so a daemon that dies between the `ps`
|
|
428
|
+
// and the listing still answers `{ reaped: false }`. That is the pre-existing behaviour and it is the
|
|
429
|
+
// conservative direction: this host cannot claim it holds nothing while it could not finish looking.
|
|
430
|
+
// `opts` passed through (issue #452, gate round 4): the detach gate's runtime read asks for its own bound.
|
|
431
|
+
const step = async (args, opts) => {
|
|
432
|
+
try {
|
|
433
|
+
const { stdout, stderr } = await (opts ? exec(bin, args, opts) : exec(bin, args));
|
|
434
|
+
return { code: 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
// `promisify(execFile)` rejects with the exit code on `.code` and what the CLI printed on
|
|
437
|
+
// `.stdout`/`.stderr`. Both streams are MATCHED against and NEITHER is ever logged (issue #339).
|
|
438
|
+
return { code: typeof err?.code === "number" ? err.code : null, stdout: String(err?.stdout ?? ""), stderr: String(err?.stderr ?? "") };
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One leftover network. The old body was a bare `network rm` in a `try {} catch {}` whose comment said an
|
|
445
|
+
* in-use network "belongs to something else" -- and on the path this sweep exists for, it does not: the
|
|
446
|
+
* worker died mid-job, its container is reaped three lines above, and the only thing still holding the
|
|
447
|
+
* network is this worker's OWN long-lived egress proxy, which nothing detached when the process died. So
|
|
448
|
+
* the network survived every later boot, silently, because the failure had nowhere to go (issue #357).
|
|
449
|
+
*
|
|
450
|
+
* DETACH WHAT IS ATTACHED, never a proxy named from configuration. That needs no `env` seam here, and it
|
|
451
|
+
* is right where a configured name would be wrong: a network left before a `PI_EGRESS_PROXY` change holds
|
|
452
|
+
* the OLD proxy, and on a deployment with the policy off it holds nothing at all. Inspecting is also the
|
|
453
|
+
* only way to see the one case that must be left alone.
|
|
454
|
+
*
|
|
455
|
+
* THE ONE THING THIS MUST NOT TOUCH is a network a `pi-job-` container is still on. Detaching there would
|
|
456
|
+
* sever a live job's only route out: it keeps running, spends its slot and dies at its first turn, which is
|
|
457
|
+
* strictly worse than the network it would have cleaned up. It should be unreachable -- the container loop
|
|
458
|
+
* `rm -f`'d every one of them, and a failure there throws to the outer catch -- so the ways in are a
|
|
459
|
+
* container started between the `ps` and this inspect, or the two-workers-per-daemon configuration
|
|
460
|
+
* `DES-CONCURRENCY-3` already calls catastrophic and unsupported. Where it is seen, it is left alone and
|
|
461
|
+
* said.
|
|
462
|
+
*
|
|
463
|
+
* WHAT IT CANNOT SEE IS NOW ASKED FOR SEPARATELY (issue #379, item 1). `.Containers` lists RUNNING
|
|
464
|
+
* endpoints, so a member that is `created` or `exited` was invisible to it AND to the container half's
|
|
465
|
+
* `docker ps` -- and the `rm` succeeded, leaving a container that can never start again (`docker start`
|
|
466
|
+
* answers `network <id> not found`, measured on 27.4.0 for both states). A second ask, `ps -a --filter
|
|
467
|
+
* network=`, closes that: any member outside `ENDPOINT_LISTED_STATES` keeps the network and is named.
|
|
468
|
+
*
|
|
469
|
+
* The two halves still ask DIFFERENT questions, deliberately. This one only declines to remove something,
|
|
470
|
+
* which costs a leftover network and a line. The container half `rm -f`s what it finds, so widening it
|
|
471
|
+
* the same way would destroy an operator's stopped container and a crashed job's forensic one.
|
|
472
|
+
*/
|
|
473
|
+
async function reapNetwork(network, gate) {
|
|
474
|
+
const { ok, names, absent } = await networkEndpoints(step, network, { bin });
|
|
475
|
+
// Gone between the `ls` and now. Nothing was left behind, so there is nothing to say: the ONE silence
|
|
476
|
+
// this sweep allows, and only in the daemon's own words for a network.
|
|
477
|
+
if (absent) return;
|
|
478
|
+
if (!ok) return log("network_not_reaped", { network, reason: "unreadable" });
|
|
479
|
+
if (names.some(isJobNamespace)) return log("network_not_reaped", { network, reason: "job-container-attached" });
|
|
480
|
+
// THE OTHER HALF OF THE SAME QUESTION (issue #379, item 1). `.Containers` above lists RUNNING
|
|
481
|
+
// endpoints, which is what the daemon guards: with one of those attached the `rm` fails by itself.
|
|
482
|
+
// It lists nothing for a member that is `created` or `exited`, and the `rm` then SUCCEEDS -- after
|
|
483
|
+
// which that member can never start again, because it holds a network id the daemon no longer has.
|
|
484
|
+
// Measured on docker 27.4.0 for BOTH states: `docker start` answers `network <id> not found`.
|
|
485
|
+
//
|
|
486
|
+
// So this half asks about a member in ANY state, and the container half deliberately does not. The
|
|
487
|
+
// container half stays `docker ps`, running-only, because widening it to `ps -a` would `rm -f` an
|
|
488
|
+
// operator's stopped container and a crashed job's forensic one, which `design.md` refuses; this half
|
|
489
|
+
// only DECLINES to remove something, which costs a leftover network and a line saying so.
|
|
490
|
+
//
|
|
491
|
+
// On Podman the read above is itself this `ps -a` since issue #452 (4.9 renders no `.Containers`), and its
|
|
492
|
+
// `parked` already holds this answer. Asked again anyway, so both runtimes take ONE path from here on and
|
|
493
|
+
// the classification below stays the one this sweep has always pinned; the cost is one command per
|
|
494
|
+
// leftover network per boot.
|
|
495
|
+
const members = await step(["ps", "-a", "--filter", `network=${network}`, "--format", "{{.Names}}\t{{.State}}"]);
|
|
496
|
+
if (members.code !== 0) return log("network_not_reaped", { network, reason: "containers-unreadable" });
|
|
497
|
+
// AN ALLOWLIST, following `sandbox.mjs`: the states named here are the ones the daemon itself lists in
|
|
498
|
+
// `.Containers`, so the detach-or-leave logic below already handles them. Anything else -- `created`,
|
|
499
|
+
// `exited`, `dead`, a Podman state nothing here has measured -- keeps the network. `paused` is on this
|
|
500
|
+
// side because it IS listed and the `rm` refuses while it is there (measured); `restarting` is not,
|
|
501
|
+
// because whether it appears is a matter of which instant the sweep asks in.
|
|
502
|
+
// A NAME AND A STATE, both of them the daemon's own vocabulary. A line without a tab is not a member --
|
|
503
|
+
// docker writes warnings and deprecation notices to stdout, and reading one as a container name put
|
|
504
|
+
// free text into a log field this file's own closed-token rule forbids, and kept the network forever
|
|
505
|
+
// on the strength of it. A name that is not a docker name is not a member either (docker refuses
|
|
506
|
+
// anything outside `[a-zA-Z0-9][a-zA-Z0-9_.-]*`).
|
|
507
|
+
const parked = members.stdout
|
|
508
|
+
.split("\n")
|
|
509
|
+
.map((l) => l.split("\t"))
|
|
510
|
+
.filter(([name, state]) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(name ?? "").trim()) && state !== undefined && !ENDPOINT_LISTED_STATES.has(String(state).trim()))
|
|
511
|
+
.map(([name]) => name.trim());
|
|
512
|
+
// THE PROXY IS NOT A REASON TO KEEP THE NETWORK, and leaving it out is what keeps this sweep able to do
|
|
513
|
+
// its job. `reapNetwork` exists for exactly one shape: the worker died mid-job, its container is reaped
|
|
514
|
+
// three lines above, and the only thing still holding the network is this worker's OWN long-lived
|
|
515
|
+
// egress proxy. If that proxy is STOPPED at reap time -- an operator who turned the policy off, a host
|
|
516
|
+
// that rebooted -- then treating it as a member to protect leaves every leftover job network standing
|
|
517
|
+
// forever, logged once per boot, with nothing else in the project that would ever remove them.
|
|
518
|
+
//
|
|
519
|
+
// Detaching a stopped container is measured to work (`network disconnect -f`, exit 0 on docker 27.4.0),
|
|
520
|
+
// and it costs that container nothing it can use: the network it is being detached from is a dead job's
|
|
521
|
+
// network, which is not one the proxy needs to start. What the rule protects is a container that would
|
|
522
|
+
// be BROKEN by the removal, which is any other member.
|
|
523
|
+
const attached = parked.filter((name) => name !== DEFAULT_EGRESS_PROXY);
|
|
524
|
+
// BOUNDED, because this goes into a log line. Five hundred stopped members produced a single
|
|
525
|
+
// 18,000-character record, in the same change that bounded the endpoint line for the same reason.
|
|
526
|
+
if (attached.length > 0) return log("network_not_reaped", { network, reason: "container-attached-not-running", containers: attached.slice(0, 5), more: attached.length > 5 ? attached.length - 5 : 0 });
|
|
527
|
+
// The parked proxy is detached too: it is attached to this network and `.Containers` does not list it,
|
|
528
|
+
// so without naming it here the `rm` would fail "has active endpoints" on a member nothing detached.
|
|
529
|
+
const detach = [...names, ...parked.filter((name) => name === DEFAULT_EGRESS_PROXY)];
|
|
530
|
+
// Through the detach gate, as every detach is (issue #452 with #458, gate round 3): `names` are the RUNNING members, so
|
|
531
|
+
// a stopped proxy asks nothing, and a refusal leaves the network whole and said; the next boot with the keeper
|
|
532
|
+
// holding removes it. The gate is this pass's, so its runtime read is made once however many networks there are.
|
|
533
|
+
const outcome = await removeNetworkOrSay(step, { network, detach, running: names, bin, gate });
|
|
534
|
+
if (outcome.blocked) return log("network_not_reaped", { network, reason: outcome.blocked });
|
|
535
|
+
if (outcome.absent) return;
|
|
536
|
+
// `detached` is named rather than counted: one of them may be something this worker never attached.
|
|
537
|
+
if (outcome.removed) return log("reaped_network", { network, detached: outcome.detached });
|
|
538
|
+
log("network_not_reaped", { network, reason: "rm-failed", detached: outcome.detached });
|
|
539
|
+
}
|
|
540
|
+
|
|
181
541
|
return async function reap() {
|
|
182
542
|
try {
|
|
183
|
-
const { stdout } = await exec(
|
|
543
|
+
const { stdout } = await exec(bin, ["ps", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Names}}"]);
|
|
544
|
+
// ANCHORED, because `--filter name=` is a SUBSTRING match: it also returns an operator's own
|
|
545
|
+
// `my-pi-job-notes`, which this sweep would then `rm -f`. Measured on docker 27.4.0 by creating
|
|
546
|
+
// exactly that name and watching it come back in the listing. The filter stays as the cheap
|
|
547
|
+
// server-side narrowing; the namespace decision is made here, on the name, where a test can pin it.
|
|
184
548
|
const names = stdout
|
|
185
549
|
.split("\n")
|
|
186
550
|
.map((n) => n.trim())
|
|
187
|
-
.filter(
|
|
551
|
+
.filter(isJobNamespace);
|
|
188
552
|
for (const name of names) {
|
|
189
|
-
await exec(
|
|
553
|
+
await exec(bin, ["rm", "-f", name]);
|
|
190
554
|
log("reaped_container", { name });
|
|
191
555
|
}
|
|
192
556
|
// REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
|
|
193
557
|
// because a network with a member still attached cannot be removed -- and swept by the SAME
|
|
194
558
|
// `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
|
|
559
|
+
// container reaper keeps their sandbox NETWORK safe too. The filter is the cheap narrowing only:
|
|
560
|
+
// the namespace decision is `isJobNamespace`, asked below and by the container loop above.
|
|
196
561
|
//
|
|
197
562
|
// A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
|
|
198
563
|
// on every ordinary path, so anything still here outlived a process that did not get to run it.
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
for
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
564
|
+
const { stdout: nets } = await exec(bin, ["network", "ls", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Name}}"]);
|
|
565
|
+
// Same substring hazard, and worse here: this sweep DETACHES before it removes, so a foreign
|
|
566
|
+
// network that merely contains `pi-job-` would have its endpoints stripped. THE SAME PREDICATE as
|
|
567
|
+
// the container loop above, which is the fix for #360 item 7: this used to be an anchored
|
|
568
|
+
// `^pi-job-.*-net$`, so the two loops gave different answers to "what is ours" and a network under
|
|
569
|
+
// the prefix without the suffix outlived the container it belonged to. See `isJobNamespace`.
|
|
570
|
+
// ONE gate for the pass (issue #452, gate round 3): a runtime that hangs costs one bound, not one per network.
|
|
571
|
+
const gate = makeDetachGate(step, { bin });
|
|
572
|
+
for (const net of nets.split("\n").map((n) => n.trim()).filter(isJobNamespace)) await reapNetwork(net, gate);
|
|
208
573
|
// Whether the enumeration HAPPENED, which the scope-claim sweep depends on: it may only delete a
|
|
209
574
|
// claim naming this host once this host has actually established that it holds no containers.
|
|
210
575
|
return { reaped: true };
|
|
@@ -214,9 +579,260 @@ export function makeReaper({ log, exec = execDocker }) {
|
|
|
214
579
|
// the daemon. Either way the claim "I hold nothing" is unproven, and sweeping on it would free
|
|
215
580
|
// slots for containers that may STILL BE RUNNING, letting another host start more alongside
|
|
216
581
|
// them: a money overrun rather than a tidy-up. Conservative in the only safe direction.
|
|
217
|
-
|
|
582
|
+
// SCRUBBED, because this is the one line in this file that carries the CLI's own words: `step` above
|
|
583
|
+
// matches both streams and logs neither, but `promisify(execFile)` puts them on the Error's own
|
|
584
|
+
// message, and a docker error repeats an unparseable DOCKER_HOST with its credentials (issue #339).
|
|
585
|
+
log("reaper_skipped", { reason: scrubCredentials(err?.message) });
|
|
218
586
|
return { reaped: false };
|
|
219
587
|
}
|
|
220
588
|
};
|
|
221
589
|
}
|
|
222
590
|
|
|
591
|
+
/**
|
|
592
|
+
* WHERE THE DOCKER CLI WILL SEND A CONTAINER, AND WHETHER THAT IS THIS HOST (issue #278).
|
|
593
|
+
*
|
|
594
|
+
* `credentialTransit` is the property that the provider key and the per-job forge token reach the container
|
|
595
|
+
* without crossing a network this deployment does not own. For `local` they ride the worker's own `docker run`
|
|
596
|
+
* argv as `-e NAME=VALUE`, so the question is which daemon that CLI talks to -- and the CLI decides it from
|
|
597
|
+
* `DOCKER_HOST`, else `DOCKER_CONTEXT`, else the config file's `currentContext`, with its own normalisation
|
|
598
|
+
* (`DOCKER_HOST=bogus` becomes `tcp://bogus:2375`). Checking `DOCKER_HOST` alone misses both other sources, and
|
|
599
|
+
* this very machine resolves through a context. So the CLI is ASKED, never re-implemented: `DES-WORKER-ON-HOST`
|
|
600
|
+
* already rejected reimplementing docker's path translation, and a second copy of its context precedence would
|
|
601
|
+
* be the same failure one layer over. `docker context inspect` answers from local config in milliseconds and
|
|
602
|
+
* never contacts a daemon.
|
|
603
|
+
*
|
|
604
|
+
* The format is NARROW on purpose -- the context's name and the docker endpoint's host, each JSON-quoted -- and
|
|
605
|
+
* never `{{json .}}`, which carries TLS material paths and storage locations nobody asked for. (`job-user.mjs`'s
|
|
606
|
+
* `docker info` read is the one exception, and says why there: it parses the body in memory, keeps a handful of
|
|
607
|
+
* facts and drops the rest, and a narrow template turns a field one runtime lacks into a template error
|
|
608
|
+
* indistinguishable from no daemon.)
|
|
609
|
+
*/
|
|
610
|
+
export const DOCKER_ENDPOINT_ARGS = Object.freeze(["context", "inspect", "--format={{json .Name}}|{{json .Endpoints.docker.Host}}"]);
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* `{ context, host }` from the CLI's output, or `null`. Both runners hand over stdout alone, and docker 27.4 puts
|
|
614
|
+
* nothing on stdout but the answer (its warnings and errors go to stderr, which neither runner reads). The scan still runs from the LAST line
|
|
615
|
+
* and credits only a line that parses whole, so a notice a plugin or a later CLI prints ahead of the answer is
|
|
616
|
+
* never read as it.
|
|
617
|
+
*/
|
|
618
|
+
export function parseDockerEndpoint(output) {
|
|
619
|
+
const lines = String(output ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
620
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
621
|
+
const at = lines[i].indexOf("|");
|
|
622
|
+
if (at <= 0) continue;
|
|
623
|
+
try {
|
|
624
|
+
const context = JSON.parse(lines[i].slice(0, at));
|
|
625
|
+
const host = JSON.parse(lines[i].slice(at + 1));
|
|
626
|
+
if (typeof context === "string" && typeof host === "string") return { context, host };
|
|
627
|
+
} catch {
|
|
628
|
+
// not this line
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 127.0.0.0/8 as a literal dotted quad, and nothing that merely starts with "127." (`127.0.0.1.nip.io` resolves anywhere). */
|
|
635
|
+
function isLoopbackV4(hostname) {
|
|
636
|
+
// No leading zeros. Go's parser (which the docker CLI dials with) refuses `127.0.0.09` as an address, so it is
|
|
637
|
+
// a NAME: looked up by the resolver, and sent through HTTP_PROXY when one is set -- measured, with the job's
|
|
638
|
+
// `-e` values in the proxied request. Only the canonical dotted quad is an address the CLI will not proxy.
|
|
639
|
+
const parts = hostname.split(".");
|
|
640
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^(0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Is this endpoint on this host, judged by its FORM? `{ local, display }`, where `display` is the endpoint with
|
|
645
|
+
* the endpoint reduced or withheld so that no part of a credential in it can be logged or printed. Never an
|
|
646
|
+
* EDIT of the value: see `displayEndpoint`.
|
|
647
|
+
*
|
|
648
|
+
* LOCAL only when it can be shown local, the polarity this codebase uses wherever a thing that cannot be shown
|
|
649
|
+
* to be on gets no credit:
|
|
650
|
+
* - `unix://` -- a socket on this machine's filesystem (Docker Desktop's and colima's VMs included: they are
|
|
651
|
+
* this machine's own runtime, not a network the deployment does not own).
|
|
652
|
+
* - `npipe:` with the `.` host -- `npipe:////./pipe/docker_engine`. A named pipe on another host is SMB, and
|
|
653
|
+
* the CLI accepts one. Parsed by hand: `new URL` puts the `.` in the path with an empty host.
|
|
654
|
+
* - `tcp://` to exactly `localhost`, a canonical literal `127.0.0.0/8` address (no leading zeros) or `[::1]`.
|
|
655
|
+
* NOT local: `ssh://` (including `ssh://localhost` -- `~/.ssh/config` can send that anywhere), `tcp://` to any
|
|
656
|
+
* other name or address, any other scheme, and nothing at all.
|
|
657
|
+
*
|
|
658
|
+
* WHAT THIS CANNOT SEE, and says so: a unix socket or a loopback port can be a tunnel (`ssh -L`, socat) to
|
|
659
|
+
* another machine, and `localhost` is whatever this host's resolver says it is. The form is local and the daemon
|
|
660
|
+
* is not. That residual is named in the declaration's comment rather than claimed away.
|
|
661
|
+
*/
|
|
662
|
+
export function classifyDockerEndpoint(host) {
|
|
663
|
+
if (typeof host !== "string" || host === "") return { local: false, display: "" };
|
|
664
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(host)?.[1]?.toLowerCase();
|
|
665
|
+
const display = displayEndpoint(host, scheme);
|
|
666
|
+
if (scheme === "unix") return { local: true, display };
|
|
667
|
+
if (scheme === "npipe") {
|
|
668
|
+
// `\\.\pipe\name` is this machine's pipe namespace; `\\.\UNC\host\...` is the same `.` prefix
|
|
669
|
+
// reaching another host over SMB, so the segment after `.` must be `pipe` too, and no later segment may
|
|
670
|
+
// be `.` or `..`, which Windows resolves in a `\\.\` path and could climb back out of `pipe` with. Split on
|
|
671
|
+
// both separators, because the CLI passes a backslash through and Windows reads it as one.
|
|
672
|
+
const [server, namespace, ...rest] = host.slice("npipe:".length).replace(/^[\\/]+/, "").split(/[\\/]/);
|
|
673
|
+
return { local: server === "." && String(namespace).toLowerCase() === "pipe" && rest.length > 0 && !rest.some((seg) => seg === "." || seg === ".." || seg === ""), display };
|
|
674
|
+
}
|
|
675
|
+
if (scheme === "tcp") {
|
|
676
|
+
let hostname;
|
|
677
|
+
try {
|
|
678
|
+
hostname = new URL(host).hostname;
|
|
679
|
+
} catch {
|
|
680
|
+
return { local: false, display };
|
|
681
|
+
}
|
|
682
|
+
return { local: hostname === "localhost" || hostname === "[::1]" || isLoopbackV4(hostname), display };
|
|
683
|
+
}
|
|
684
|
+
return { local: false, display };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Everything between `scheme://` and the first `/`, `?` or `#`; `null` when the value does not start `scheme://`. */
|
|
688
|
+
function rawAuthority(host, scheme) {
|
|
689
|
+
if (!scheme || host.slice(0, scheme.length + 3).toLowerCase() !== `${scheme}://`) return null;
|
|
690
|
+
const rest = host.slice(scheme.length + 3);
|
|
691
|
+
const end = rest.search(/[/?#]/);
|
|
692
|
+
return end === -1 ? rest : rest.slice(0, end);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The endpoint as it may be logged and printed. Never an EDIT of it: passed through whole, reduced to a host, or
|
|
697
|
+
* withheld. Editing is what kept failing, because a password may itself hold `@`, `/`, `?` or `#`, so any rule that
|
|
698
|
+
* splits the string and keeps a part can keep part of a password.
|
|
699
|
+
*
|
|
700
|
+
* THE RULE IS ONE FACT ABOUT URLs: userinfo ends at an `@`. So if the whole value holds exactly ONE `@` and that `@`
|
|
701
|
+
* lies inside the raw authority, then under every parse the userinfo is a prefix of what precedes it, and everything
|
|
702
|
+
* after it -- which is all that is shown -- is host and port. A password containing `@`, `/`, `?` or `#` either adds a
|
|
703
|
+
* second `@` or pushes the only one outside the authority, and both are withheld.
|
|
704
|
+
*
|
|
705
|
+
* `new URL` IS THE DEFECT THIS REPLACES (issue #340), not a helper it uses. It computes an authority of its own and
|
|
706
|
+
* takes the LAST `@` in it, so `ssh://bob:@secret/word@remote` parsed with host `secret` and `ssh://bob:4455?qzx@remote`
|
|
707
|
+
* with host `bob:4455`: part of a password, displayed. A 2,000,000-value fuzz over hostile password bodies leaks
|
|
708
|
+
* 639,930 times under that rule and zero under this one.
|
|
709
|
+
*
|
|
710
|
+
* `unix` and `npipe` are PATHS, where `@` is an ordinary filename character, and three call sites read this display
|
|
711
|
+
* form AS that path (`job-user.mjs`, `sandbox.mjs`, `runtime-observations.mjs`). They pass through whole when they have
|
|
712
|
+
* no authority at all, which every real socket path does. One WITH an authority is a userinfo position on a URL no
|
|
713
|
+
* socket needs, and is withheld -- the previous rule cut at the last `@` of a hand-split authority, so
|
|
714
|
+
* `unix://bob:p/w@/x.sock` displayed VERBATIM and `unix://bob@/var/run/docker.sock` displayed an INVENTED path that
|
|
715
|
+
* `job-user.mjs` then stat'ed. If the withheld token's shape ever loses its `scheme://` prefix, `podmanOnThisHost`
|
|
716
|
+
* changes answer with it.
|
|
717
|
+
*
|
|
718
|
+
* `\` is deliberately NOT an authority terminator: Go's `url.Parse`, which the docker CLI uses, ends an authority at
|
|
719
|
+
* the first `/` only, so `unix://\\srv\x@y` is userinfo to it and must be withheld rather than read as an empty
|
|
720
|
+
* authority and passed through. Adding it to the set makes `npipe://\\host\pipe:pw@x` display its
|
|
721
|
+
* password, measured, and there is a test for that rather than only this sentence.
|
|
722
|
+
*
|
|
723
|
+
* Taking the FIRST `@` of the authority rather than the last is equivalent while the one-`@` guard below
|
|
724
|
+
* stands, since there is then only one. It is written as `indexOf` because that is the rule being
|
|
725
|
+
* expressed; remove the guard and the difference between them is the entire defect this replaced.
|
|
726
|
+
*
|
|
727
|
+
* WHAT IS GIVEN UP, stated rather than glossed: a password in `DOCKER_HOST` no longer makes the display say so, since
|
|
728
|
+
* `tcp://bob:pw@127.0.0.1:2375` now shows its host like any other. Accepted because neither docker's tcp transport nor
|
|
729
|
+
* ssh takes a password from a URL, so it is inert junk in an operator's environment rather than a credential this
|
|
730
|
+
* worker puts on a wire. The old comment claimed the CLI refuses to dial every withheld form, and that was false twice:
|
|
731
|
+
* `tcp://bob:hunter2@127.0.0.1:P` dials, and `ssh://bob@[fe80::1%25en0]:22` dials (`URL` rejects IPv6 zone ids; the CLI
|
|
732
|
+
* runs `ssh -- fe80::1%en0`). Both now show their host, which is the fact an operator needs.
|
|
733
|
+
*/
|
|
734
|
+
function displayEndpoint(host, scheme) {
|
|
735
|
+
if (!host.includes("@")) return host;
|
|
736
|
+
const authority = rawAuthority(host, scheme);
|
|
737
|
+
// The scheme is named only when the value really starts `scheme://`: in `bob:pw@host` the "scheme" is a username.
|
|
738
|
+
const withheld = `${authority === null ? "" : `${scheme}://`}(credentials not shown)`;
|
|
739
|
+
if (scheme === "unix" || scheme === "npipe") return authority === "" ? host : withheld;
|
|
740
|
+
if (authority === null || !authority.includes("@")) return withheld;
|
|
741
|
+
if (host.indexOf("@") !== host.lastIndexOf("@")) return withheld;
|
|
742
|
+
return `${scheme}://${authority.slice(authority.indexOf("@") + 1)}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Why a resolve failed, as `{ reason, transient }`. A fixed token, never the CLI's stderr, which is not read at
|
|
747
|
+
* all: a missing context's message carries the operator's home path, and a `DOCKER_HOST` it cannot parse is
|
|
748
|
+
* repeated in it, credentials included.
|
|
749
|
+
*
|
|
750
|
+
* Classified on what Node supplies as values, per `DES-TRANSIENT-VERSUS-DETERMINATE-IS-ONE-RULE`:
|
|
751
|
+
* - the SPAWN errno through `transient.mjs`'s allow-list (no docker binary is determinate; a spawn out of
|
|
752
|
+
* processes or descriptors, or refused with `EACCES`, is not);
|
|
753
|
+
* - the timer's kill is a `timeout`, and a death by any other signal is named by it; both are transient;
|
|
754
|
+
* - a NON-ZERO EXIT is determinate. It is the CLI's own answer, and on docker 27.4 most measured are
|
|
755
|
+
* configuration refusals that answer the same until the operator changes something: a context that does not
|
|
756
|
+
* exist, a `DOCKER_HOST` it cannot parse, a context file it cannot parse. Some are not -- `permission denied`
|
|
757
|
+
* on the context store, a CLI starved of file descriptors -- and telling them apart would need a table of
|
|
758
|
+
* another tool's stderr prose, the shape that entry rejects for `gh auth token` on exactly this argument. So
|
|
759
|
+
* the residual is named instead: under a floor such a passing failure exits 2 at boot, and per job it
|
|
760
|
+
* refuses with the fixed comment as `backend-floor-unobserved` rather than retrying;
|
|
761
|
+
* - output that does not parse on a clean exit is determinate.
|
|
762
|
+
*/
|
|
763
|
+
export function classifyEndpointFailure({ error = null, code = null } = {}) {
|
|
764
|
+
if (error?.timedOut || error?.killed) return { reason: "timeout", transient: true };
|
|
765
|
+
if (typeof error?.signal === "string") return { reason: `signal-${error.signal.toLowerCase()}`, transient: true };
|
|
766
|
+
if (error?.code === "ENOENT") return { reason: "docker-not-found", transient: false };
|
|
767
|
+
if (typeof error?.code === "string") return { reason: `spawn-${error.code.toLowerCase()}`, transient: !isDeterminateFsCode(error.code) };
|
|
768
|
+
if (typeof error?.code === "number" && error.code !== 0) return { reason: `exit-${error.code}`, transient: false };
|
|
769
|
+
if (typeof code === "number" && code !== 0) return { reason: `exit-${code}`, transient: false };
|
|
770
|
+
if (error) return { reason: "spawn-failed", transient: true };
|
|
771
|
+
return { reason: "unparseable", transient: false };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Run the CLI bounded, as `{ code, stdout, error }`. `execFile`'s own `timeout` is NOT the bound: it sends a
|
|
776
|
+
* signal and then still waits for the child's `close`, so a CLI wedged on a dead socket never settles
|
|
777
|
+
* (`retention-sweep.mjs` records the same). A separate timer settles the promise regardless, kills with
|
|
778
|
+
* SIGKILL and destroys the pipes. REF'D, because at boot nothing else may be holding the event loop.
|
|
779
|
+
*/
|
|
780
|
+
export function execDockerBounded(args, { timeoutMs = 5000, execFileFn = execFile, maxBuffer = 64 * 1024, bin = "docker", withStderr = false } = {}) {
|
|
781
|
+
return new Promise((resolve) => {
|
|
782
|
+
let settled = false;
|
|
783
|
+
let child = null;
|
|
784
|
+
let timer = null;
|
|
785
|
+
const finish = (value) => {
|
|
786
|
+
if (settled) return;
|
|
787
|
+
settled = true;
|
|
788
|
+
clearTimeout(timer);
|
|
789
|
+
resolve(value);
|
|
790
|
+
};
|
|
791
|
+
timer = setTimeout(() => {
|
|
792
|
+
try {
|
|
793
|
+
child?.kill?.("SIGKILL");
|
|
794
|
+
child?.stdout?.destroy?.();
|
|
795
|
+
child?.stderr?.destroy?.();
|
|
796
|
+
} catch {
|
|
797
|
+
// already gone
|
|
798
|
+
}
|
|
799
|
+
finish({ code: null, stdout: "", error: { timedOut: true } });
|
|
800
|
+
}, timeoutMs);
|
|
801
|
+
try {
|
|
802
|
+
// No `env`: the endpoint that matters is the one the job's own `docker run` will use, and that spawn
|
|
803
|
+
// inherits this process's environment. Passing an env here would ask about a different CLI.
|
|
804
|
+
// `stderr` only for a caller that ASKS (`withStderr`, issue #452, gate round 3): `execFile` passes it to the callback
|
|
805
|
+
// and never puts it on the error, so the sandbox sweep's runner, which reads `error.stderr`, saw an empty string
|
|
806
|
+
// and the podman-docker `.Containers` fallback never fired there (measured). Not by default: stderr can repeat an
|
|
807
|
+
// unparseable DOCKER_HOST with its credentials (issue #339), and no other caller reads it.
|
|
808
|
+
child = execFileFn(bin, [...args], { killSignal: "SIGKILL", maxBuffer }, (err, stdout, stderr) => {
|
|
809
|
+
finish({ code: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: String(stdout ?? ""), ...(withStderr ? { stderr: String(stderr ?? "") } : {}), error: err ?? null });
|
|
810
|
+
});
|
|
811
|
+
} catch (err) {
|
|
812
|
+
finish({ code: null, stdout: "", error: err });
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The resolver: `async () => ({ local, context, endpoint, reason, transient })`. `local` is `true`, `false`, or
|
|
819
|
+
* `null` when the CLI did not answer; `endpoint` is the display form; `reason` and `transient` are set only when
|
|
820
|
+
* `local` is `null`. `run(args)` is the seam, returning `{ code, stdout, error }`.
|
|
821
|
+
*/
|
|
822
|
+
export function makeDockerEndpointResolver({ run = (args) => execDockerBounded(args) } = {}) {
|
|
823
|
+
return async function resolveDockerEndpoint() {
|
|
824
|
+
let result;
|
|
825
|
+
try {
|
|
826
|
+
result = await run(DOCKER_ENDPOINT_ARGS);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
result = { code: null, stdout: "", error: err };
|
|
829
|
+
}
|
|
830
|
+
const parsed = result?.error || result?.code !== 0 ? null : parseDockerEndpoint(result.stdout);
|
|
831
|
+
if (!parsed) {
|
|
832
|
+
const { reason, transient } = classifyEndpointFailure({ error: result?.error ?? null, code: result?.code ?? null });
|
|
833
|
+
return { local: null, context: null, endpoint: null, reason, transient };
|
|
834
|
+
}
|
|
835
|
+
const { local, display } = classifyDockerEndpoint(parsed.host);
|
|
836
|
+
return { local, context: parsed.context, endpoint: display, reason: null, transient: false };
|
|
837
|
+
};
|
|
838
|
+
}
|