@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/image-preflight.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { imageRefProblem } from "./image-ref.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Which image a job runs in, and whether it is on this host.
|
|
@@ -7,9 +8,9 @@ import { spawn } from "node:child_process";
|
|
|
7
8
|
* `docker run` is handed. Both sides resolve through `resolveJobImage`, so there is one answer by
|
|
8
9
|
* construction rather than by two call sites happening to match.
|
|
9
10
|
*
|
|
10
|
-
* This module imports nothing but `node:child_process`
|
|
11
|
-
* `env-allowlist.mjs` and therefore `@earendil-works/pi-ai`, which is why its tests sit
|
|
12
|
-
* node-version skip guard. This is a money gate: it decides whether a budget slot is spent, so its tests
|
|
11
|
+
* This module imports nothing but `node:child_process` and the import-free `image-ref.mjs` -- deliberately.
|
|
12
|
+
* `run-container.mjs` pulls in `env-allowlist.mjs` and therefore `@earendil-works/pi-ai`, which is why its tests sit
|
|
13
|
+
* behind a node-version skip guard. This is a money gate: it decides whether a budget slot is spent, so its tests
|
|
13
14
|
* must run everywhere, unconditionally.
|
|
14
15
|
*/
|
|
15
16
|
|
|
@@ -20,7 +21,16 @@ import { spawn } from "node:child_process";
|
|
|
20
21
|
* is how that is guaranteed rather than hoped for.
|
|
21
22
|
*/
|
|
22
23
|
export function resolveJobImage(job, defaultImage) {
|
|
23
|
-
|
|
24
|
+
const image = job?.image ?? defaultImage;
|
|
25
|
+
// The one image rule (`image-ref.mjs`, issue #471 gate round 1), where every consumer resolves the image: a job queued
|
|
26
|
+
// with a value it refuses (enqueued before the rule reached the CLI, or by any other producer) is a config error here,
|
|
27
|
+
// which the processor refuses as `config-refused` before any spend, since the preflight resolves first. So no argv is
|
|
28
|
+
// ever handed a flag where the image belongs.
|
|
29
|
+
// A caller with no default and a job with none (`prepare.mjs`'s unwired stamp) resolves to nothing, as before.
|
|
30
|
+
if (image === null || image === undefined) return image;
|
|
31
|
+
const problem = imageRefProblem(image);
|
|
32
|
+
if (problem) throw Object.assign(new Error(`the job image ${problem.reason} (got ${JSON.stringify(image)})`), { piDispatchConfig: true });
|
|
33
|
+
return image;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
@@ -34,6 +44,8 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
34
44
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
35
45
|
* { replicaUnsupported }-- present, but does not declare replica support for a replica job => POLICY
|
|
36
46
|
* { commandUnsupported }-- present, but does not declare command support for a command job => POLICY
|
|
47
|
+
* { excludeToolsUnsupported } -- present, but does not declare exclude-tools support for a job that
|
|
48
|
+
* carries exclusions => POLICY
|
|
37
49
|
*
|
|
38
50
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
39
51
|
* so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
|
|
@@ -46,7 +58,9 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
46
58
|
* `unavailable` and retry; if it comes up between them we report `missing` on an image that is genuinely
|
|
47
59
|
* absent. Nothing is cached, deliberately -- see the note at the call site in start.mjs.
|
|
48
60
|
*/
|
|
49
|
-
export function makeImagePreflight({ image, spawnFn = spawn }) {
|
|
61
|
+
export function makeImagePreflight({ image, spawnFn = spawn, bin = "docker" }) {
|
|
62
|
+
// Issue #354: `bin` is the venue's CLI. Both probes go through it, because a `podman image inspect` answered by one
|
|
63
|
+
// store and an `info` answered by another daemon would tell "absent" from "unavailable" about two different hosts.
|
|
50
64
|
return async function imagePreflight(job) {
|
|
51
65
|
const wanted = resolveJobImage(job, image);
|
|
52
66
|
// --format keeps docker from serialising the whole image manifest to a pipe we ignore.
|
|
@@ -68,7 +82,7 @@ export function makeImagePreflight({ image, spawnFn = spawn }) {
|
|
|
68
82
|
// HARD_RULES.md whose rule 3 hard-codes `pi/issue-<n>` -- and that is the SYSTEM prompt, which the
|
|
69
83
|
// model treats as authoritative. Both replicas would converge on one branch and the feature would
|
|
70
84
|
// become the push race it exists to avoid, with nothing in the run record saying so.
|
|
71
|
-
const probe = await runDocker(spawnFn, ["image", "inspect", `--format={{.Id}}${FIELD_SEP}${PI_VERSION_TEMPLATE}${FIELD_SEP}${FORGES_TEMPLATE}${FIELD_SEP}${CAPABILITIES_TEMPLATE}`, wanted], true);
|
|
85
|
+
const probe = await runDocker(bin, spawnFn, ["image", "inspect", `--format={{.Id}}${FIELD_SEP}${PI_VERSION_TEMPLATE}${FIELD_SEP}${FORGES_TEMPLATE}${FIELD_SEP}${CAPABILITIES_TEMPLATE}`, wanted], true);
|
|
72
86
|
if (probe.code === 0) {
|
|
73
87
|
const [piVersion, forges, capabilities, imageDigest] = parseLabels(probe.stdout);
|
|
74
88
|
const kind = job?.kind;
|
|
@@ -96,9 +110,19 @@ export function makeImagePreflight({ image, spawnFn = spawn }) {
|
|
|
96
110
|
if (job?.command !== undefined && !(capabilities ?? []).includes("commands")) {
|
|
97
111
|
return { commandUnsupported: wanted, declared: capabilities ?? [] };
|
|
98
112
|
}
|
|
99
|
-
|
|
113
|
+
// Same inclusion-list polarity again, and here the stale-image failure is a PERMISSION quietly
|
|
114
|
+
// not enforced (issue #291): a runner that predates run.excludeTools reads no PI_EXCLUDE_TOOLS,
|
|
115
|
+
// so a "read-only" trigger's job would run with every tool the file says to remove and record a
|
|
116
|
+
// clean exit -- the silent fail-open this field exists to close. Unreachable for a job without
|
|
117
|
+
// exclusions, so the existing fleet pays nothing for it.
|
|
118
|
+
if (job?.excludeTools !== undefined && !(capabilities ?? []).includes("excludeTools")) {
|
|
119
|
+
return { excludeToolsUnsupported: wanted, declared: capabilities ?? [] };
|
|
120
|
+
}
|
|
121
|
+
// `capabilities` rides the ok result for issue #341's job-user gate, which needs `anyUid` and runs after
|
|
122
|
+
// this preflight; an image that declares nothing reads as [].
|
|
123
|
+
return { ok: true, image: wanted, piVersion, imageDigest, capabilities: capabilities ?? [] };
|
|
100
124
|
}
|
|
101
|
-
if ((await runDocker(spawnFn, ["info"])).code === 0) return { missing: wanted };
|
|
125
|
+
if ((await runDocker(bin, spawnFn, ["info"])).code === 0) return { missing: wanted };
|
|
102
126
|
return { unavailable: wanted };
|
|
103
127
|
};
|
|
104
128
|
}
|
|
@@ -139,13 +163,24 @@ function parseLabels(stdout) {
|
|
|
139
163
|
// string had four fields and was thrown away until issue #57, which needs it to answer "are these two
|
|
140
164
|
// hosts running the same image?" -- a question `OQ-012` records as unanswerable and which turns out to
|
|
141
165
|
// cost nothing to answer, because the inspect that would have asked it already runs.
|
|
142
|
-
const imageDigest = label(parts[0]);
|
|
166
|
+
const imageDigest = normalizeImageId(label(parts[0]));
|
|
143
167
|
const piVersion = label(parts[1]);
|
|
144
168
|
// A label that is present but parses to nothing usable is treated as ABSENT rather than as an empty
|
|
145
169
|
// list -- on `forges` the latter would refuse every job on an image whose label was merely malformed.
|
|
146
170
|
return [piVersion, list(parts[2]), list(parts[3]), imageDigest];
|
|
147
171
|
}
|
|
148
172
|
|
|
173
|
+
/**
|
|
174
|
+
* One spelling of an image id across runtimes (issue #354): Podman's `{{.Id}}` is the bare 64-hex digest where docker's
|
|
175
|
+
* carries `sha256:` (both measured). The id is published to the fleet registry and compared host against host by
|
|
176
|
+
* `doctor`, so without this a podman host and a docker host running one image would disagree on every read. ONLY the
|
|
177
|
+
* exact bare form gains the prefix; anything else passes through untouched, so a string this rule does not recognise is
|
|
178
|
+
* never made to look like a digest it is not.
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeImageId(id) {
|
|
181
|
+
return typeof id === "string" && /^[0-9a-f]{64}$/.test(id) ? `sha256:${id}` : id;
|
|
182
|
+
}
|
|
183
|
+
|
|
149
184
|
/** One comma-separated label value as a non-empty array, or `null` when it declares nothing usable. */
|
|
150
185
|
function list(raw) {
|
|
151
186
|
const value = label(raw);
|
|
@@ -170,11 +205,11 @@ function label(raw) {
|
|
|
170
205
|
* answer "did the daemon reply", and piping output we would not read is how a probe becomes a place a
|
|
171
206
|
* large payload can arrive.
|
|
172
207
|
*/
|
|
173
|
-
function runDocker(spawnFn, args, capture = false) {
|
|
208
|
+
function runDocker(bin, spawnFn, args, capture = false) {
|
|
174
209
|
return new Promise((resolve) => {
|
|
175
210
|
let child;
|
|
176
211
|
try {
|
|
177
|
-
child = spawnFn(
|
|
212
|
+
child = spawnFn(bin, args, { stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore" });
|
|
178
213
|
} catch {
|
|
179
214
|
resolve({ code: null, stdout: "" });
|
|
180
215
|
return;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE one rule for an image reference this project hands a container runtime as its image positional (issue #471):
|
|
3
|
+
* `run.image` in the triggers file (`triggers.mjs`), `PI_JOB_IMAGE` at the worker's boot (`config.mjs`), and the image
|
|
4
|
+
* doctor inspects and runs (`doctor.mjs`). Before #471 only `run.image` was judged, so a `PI_JOB_IMAGE` of `--help`
|
|
5
|
+
* booted a worker whose every job handed docker a flag where the image belongs.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT the OCI grammar (`triggers.mjs` says why: docker validates its own grammar, and an over-strict regex
|
|
8
|
+
* takes a valid deployment down at boot). Each refusal names a value that corrupts something on OUR side: a blank one
|
|
9
|
+
* throws inside the argv builder after a budget slot is reserved, surrounding whitespace means the file disagrees with
|
|
10
|
+
* what runs, a leading `-` is read as a flag by the runtime's parser, and a control character reaches argv, logs and a
|
|
11
|
+
* terminal as bytes nobody wrote on purpose.
|
|
12
|
+
*
|
|
13
|
+
* Returns null for an acceptable reference, else `{ code, reason }`, `reason` a clause to follow the value's name.
|
|
14
|
+
*/
|
|
15
|
+
export function imageRefProblem(image) {
|
|
16
|
+
if (typeof image !== "string" || image.trim() === "") return { code: "blank", reason: "must be a non-empty string" };
|
|
17
|
+
if (image !== image.trim()) return { code: "padded", reason: "must not have leading or trailing whitespace" };
|
|
18
|
+
if (image.startsWith("-")) return { code: "dash", reason: 'must not start with "-", which the runtime reads as a flag where the image belongs' };
|
|
19
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(image)) return { code: "control", reason: "must not hold a control character" };
|
|
20
|
+
return null;
|
|
21
|
+
}
|