@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-facts.mjs -- what a container runtime's own `info` says, parsed (issue #452, gate round 3).
|
|
3
|
+
*
|
|
4
|
+
* A LEAF, importing nothing of this project's, and that is its reason to exist: the parsers the local venue decides its
|
|
5
|
+
* job user with (`parseDaemonFacts`) and the podman venue decides its observations with (`parsePodmanInfo`) are also
|
|
6
|
+
* what the network detach gate (`netns-keeper.mjs`) reads the runtime with, and that gate is used by `egress.mjs`, which
|
|
7
|
+
* `job-user.mjs` and `backend-podman.mjs` sit above. Moved here unchanged and re-exported from both, so every existing
|
|
8
|
+
* importer and test is untouched and there is still ONE copy of each rule.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The one daemon read. `{{json .}}` rather than a narrow template, deliberately and against `DOCKER_ENDPOINT_ARGS`'
|
|
13
|
+
* rule, for a measured reason: a template field one runtime lacks is a TEMPLATE ERROR on the client, exactly what "no
|
|
14
|
+
* daemon" and Podman's docker emulation also produce, while a missing key in a parsed body is just `null`. The body
|
|
15
|
+
* carries proxy settings and storage paths, so it is parsed here, reduced to the facts below, and never logged or
|
|
16
|
+
* returned.
|
|
17
|
+
*/
|
|
18
|
+
export const DAEMON_FACTS_ARGS = Object.freeze(["info", "--format={{json .}}"]);
|
|
19
|
+
export const DAEMON_FACTS_MAX_BUFFER = 256 * 1024;
|
|
20
|
+
// Longer than the endpoint read's 5 s: `docker info` counts containers and images, and on a busy host it is the slow
|
|
21
|
+
// one. A per-job `unknown` retries the job, so a bound the host routinely misses would retry every job forever. Boot
|
|
22
|
+
// is bounded separately (`settleWithin`), and a read still in flight there is joined by the first job.
|
|
23
|
+
export const DAEMON_FACTS_TIMEOUT_MS = 15_000;
|
|
24
|
+
|
|
25
|
+
// Podman's compat API has hard-coded this since v2 (pkg/api/handlers/compat/info.go); Docker CE packages say
|
|
26
|
+
// "Community Engine" and Docker Desktop omits the key (measured). Used only to withhold credit, never to grant it.
|
|
27
|
+
export const PODMAN_PRODUCT_LICENSE = "Apache-2.0";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a `docker info --format={{json .}}` answer says: `{ facts }`, `{ unreachable: true }`, or `null` when no line
|
|
31
|
+
* parses to either shape.
|
|
32
|
+
*
|
|
33
|
+
* NO DAEMON IS NOT A SHAPE. When the CLI's `/info` request fails, the docker CLI still exits 0 under `--format` and
|
|
34
|
+
* prints a Docker-shaped body with every server field empty and the error in `ServerErrors` (measured on 27.5.1
|
|
35
|
+
* against a missing socket). From 28.1 a CONNECTION failure exits non-zero instead, which the reader already treats
|
|
36
|
+
* as transient, but any other `/info` failure (an authorization plugin, an API version mismatch) still exits 0 with
|
|
37
|
+
* `ServerErrors` on every version (source). Read as facts, that body has no rootless marker and decides `worker`, so it is
|
|
38
|
+
* recognised FIRST: a non-empty `ServerErrors` is `unreachable` (the error text is never read), and a Docker-shaped
|
|
39
|
+
* body with no `ServerVersion` is no shape at all. A daemon that answers `/info` always sets `ServerVersion`
|
|
40
|
+
* (Docker and Podman's compat handler, source).
|
|
41
|
+
*
|
|
42
|
+
* TWO SHAPES, both measured. The real docker CLI against any daemon (Docker, or Podman's compat socket) prints the
|
|
43
|
+
* Docker shape. Podman's docker emulation (podman-docker) prints Podman's own (`host.security.rootless`,
|
|
44
|
+
* `host.serviceIsRemote`, `host.remoteSocket.path`), and that is the only facts source on that route, because it
|
|
45
|
+
* resolves no docker context.
|
|
46
|
+
*
|
|
47
|
+
* `bounds` is `null` whenever the daemon is Podman: its compat `PidsLimit` is hard-coded true and `MemoryLimit`
|
|
48
|
+
* follows the root cgroup's controllers, not whether a container's bounds apply (source; measured on a rootless host
|
|
49
|
+
* reporting both true while applying neither). CPU is never read: rootful Podman reports `CpuCfsQuota: false` while
|
|
50
|
+
* applying `--cpus` (measured).
|
|
51
|
+
*/
|
|
52
|
+
export function parseDaemonFacts(output) {
|
|
53
|
+
const lines = String(output ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
54
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
55
|
+
let body;
|
|
56
|
+
try {
|
|
57
|
+
body = JSON.parse(lines[i]);
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) continue;
|
|
62
|
+
if (Array.isArray(body.ServerErrors) && body.ServerErrors.length > 0) return { unreachable: true };
|
|
63
|
+
if (body.host && typeof body.host === "object") {
|
|
64
|
+
const rootless = body.host.security?.rootless;
|
|
65
|
+
const selinux = body.host.security?.selinuxEnabled;
|
|
66
|
+
const path = body.host.remoteSocket?.path;
|
|
67
|
+
return { facts: {
|
|
68
|
+
shape: "podman",
|
|
69
|
+
podman: true,
|
|
70
|
+
os: typeof body.host.os === "string" ? body.host.os : null,
|
|
71
|
+
rootless: typeof rootless === "boolean" ? rootless : null,
|
|
72
|
+
// Issue #355. Podman's own shape says it outright, so a missing or odd value is no fact rather than `false`.
|
|
73
|
+
selinux: typeof selinux === "boolean" ? selinux : null,
|
|
74
|
+
userns: false,
|
|
75
|
+
bounds: null,
|
|
76
|
+
serviceIsRemote: typeof body.host.serviceIsRemote === "boolean" ? body.host.serviceIsRemote : null,
|
|
77
|
+
// Only a unix path is kept, because only a unix path is ever statted. Podman fills this from the SERVICE's own
|
|
78
|
+
// listener (source): `unix://...` or `tcp://...` for a running service, the bare default path in-process.
|
|
79
|
+
remoteSocketPath: isUnixSocketPath(path) ? path : null,
|
|
80
|
+
serverVersion: displayVersion(body.version?.Version),
|
|
81
|
+
} };
|
|
82
|
+
}
|
|
83
|
+
if (typeof body.ServerVersion === "string" && body.ServerVersion !== "" && (typeof body.OperatingSystem === "string" || Array.isArray(body.SecurityOptions))) {
|
|
84
|
+
const options = Array.isArray(body.SecurityOptions) ? body.SecurityOptions.filter((o) => typeof o === "string") : [];
|
|
85
|
+
const named = (name) => options.some((o) => o.split(",").includes(`name=${name}`));
|
|
86
|
+
const podman = body.ProductLicense === PODMAN_PRODUCT_LICENSE;
|
|
87
|
+
return { facts: {
|
|
88
|
+
shape: "docker",
|
|
89
|
+
podman,
|
|
90
|
+
os: typeof body.OperatingSystem === "string" ? body.OperatingSystem : null,
|
|
91
|
+
rootless: named("rootless"),
|
|
92
|
+
// Issue #355, measured through rootful Podman 5.8.1's compat API on an enforcing Fedora 44 host:
|
|
93
|
+
// `["name=seccomp,profile=default","name=selinux"]`. Docker Engine with `selinux-enabled` says the same word,
|
|
94
|
+
// and that route is out of scope, so this fact alone decides nothing (`relabelsPrivateMounts`).
|
|
95
|
+
selinux: named("selinux"),
|
|
96
|
+
userns: named("userns"),
|
|
97
|
+
bounds: podman ? null : { pids: body.PidsLimit === true, memory: body.MemoryLimit === true },
|
|
98
|
+
serviceIsRemote: null,
|
|
99
|
+
remoteSocketPath: null,
|
|
100
|
+
serverVersion: displayVersion(body.ServerVersion),
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A version string fit for doctor's display line (issue #345), or `null`. Display only, never a decision: validated to a
|
|
109
|
+
* short run of version characters so a daemon's answer cannot put anything else on an operator's terminal. Exported for
|
|
110
|
+
* the podman venue's `podman info` read (issue #354), so the two runtimes' versions pass one filter.
|
|
111
|
+
*/
|
|
112
|
+
export function displayVersion(value) {
|
|
113
|
+
return typeof value === "string" && /^[0-9A-Za-z.+~_-]{1,40}$/.test(value) ? value : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isUnixSocketPath(path) {
|
|
117
|
+
return typeof path === "string" && (path.startsWith("/") || path.startsWith("unix:///"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
/** The one facts read. JSON, not a template: a template field one Podman lacks is an error, a missing key is `null`. */
|
|
122
|
+
export const PODMAN_INFO_ARGS = Object.freeze(["info", "--format", "json"]);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What `podman info --format json` says, reduced to the facts this venue reads, or `null` when the output is not a JSON
|
|
126
|
+
* object with a `host` object. Every field is `null` when absent or of the wrong type, never a guess: a missing
|
|
127
|
+
* `rootless` must not read as rootless, nor a missing `serviceIsRemote` as local. Nothing else of the body (proxy
|
|
128
|
+
* settings, store paths) survives this function, so nothing else can be logged.
|
|
129
|
+
*/
|
|
130
|
+
export function parsePodmanInfo(stdout) {
|
|
131
|
+
let body;
|
|
132
|
+
try {
|
|
133
|
+
body = JSON.parse(String(stdout ?? "").trim());
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
138
|
+
const host = body.host;
|
|
139
|
+
if (!host || typeof host !== "object" || Array.isArray(host)) return null;
|
|
140
|
+
const bool = (value) => (typeof value === "boolean" ? value : null);
|
|
141
|
+
const controllers = Array.isArray(host.cgroupControllers) ? host.cgroupControllers.filter((c) => typeof c === "string" && /^[a-z][a-z0-9_]{0,31}$/.test(c)) : null;
|
|
142
|
+
return {
|
|
143
|
+
rootless: bool(host.security?.rootless),
|
|
144
|
+
serviceIsRemote: bool(host.serviceIsRemote),
|
|
145
|
+
selinux: bool(host.security?.selinuxEnabled),
|
|
146
|
+
// "v2" or "v1"; anything else is no fact rather than a string an operator's terminal is handed.
|
|
147
|
+
cgroupVersion: typeof host.cgroupVersion === "string" && /^v[0-9]{1,2}$/.test(host.cgroupVersion) ? host.cgroupVersion : null,
|
|
148
|
+
// Issue #453: the cgroup manager Podman actually uses for this call. `cgroupfs` when it was configured so, and when a
|
|
149
|
+
// configured `systemd` could not reach the account's user manager over D-Bus (Podman then warns and falls back).
|
|
150
|
+
// A short lower-case word, else no fact.
|
|
151
|
+
cgroupManager: typeof host.cgroupManager === "string" && /^[a-z][a-z0-9_-]{0,31}$/.test(host.cgroupManager) ? host.cgroupManager : null,
|
|
152
|
+
// The controllers of the CALLING process's own cgroup, as `podman info` reports them. Kept for what it is and for
|
|
153
|
+
// nothing else: issue #453 measured it listing all five where no bound was applied, so no observation reads it.
|
|
154
|
+
controllers,
|
|
155
|
+
version: displayVersion(body.version?.Version),
|
|
156
|
+
// Issue #429: the container STORE this account's Podman uses (`store.graphRoot`), which moves with HOME,
|
|
157
|
+
// XDG_DATA_HOME or a storage.conf. Rootless `podman ps -a` over another store answers exit 0 with an EMPTY list
|
|
158
|
+
// (measured, Podman 5.8.1), so a sandbox opened with another store is invisible to the retention sweep; the
|
|
159
|
+
// sandbox and the sweep compare it with the one a run recorded. An absolute path with no control character, else
|
|
160
|
+
// no fact.
|
|
161
|
+
graphRoot: typeof body.store?.graphRoot === "string" && body.store.graphRoot.length <= 4096 && /^\/[^\u0000-\u001f\u007f]*$/.test(body.store.graphRoot) ? body.store.graphRoot : null,
|
|
162
|
+
// Issue #450: where Podman keeps its runtime state (`store.runRoot`, `/run/user/<uid>/containers` by default,
|
|
163
|
+
// measured on 5.8.1 and 4.9.3), under which Podman 5 records this account's rootless network helper. Parsed as
|
|
164
|
+
// graphRoot is: an absolute path with no control character, else no fact (and the live network check refuses).
|
|
165
|
+
runRoot: typeof body.store?.runRoot === "string" && body.store.runRoot.length <= 4096 && /^\/[^\u0000-\u001f\u007f]*$/.test(body.store.runRoot) ? body.store.runRoot : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH VENUE A DEPLOYMENT RUNS, decided one way for every command that asks (issue #453): `up` since #430, and `init`
|
|
3
|
+
* (its next steps) and `doctor` (what it judges) since #453, which read only their shell before and so answered for a
|
|
4
|
+
* different venue than the service they described. The deployment's `.env` is read with `readStackKeys`, exactly as
|
|
5
|
+
* `service install` reads it, and a key this shell sets differently stops the command, since the service runs the file.
|
|
6
|
+
*/
|
|
7
|
+
import { parseBackendList } from "./backends.mjs";
|
|
8
|
+
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
9
|
+
import { envValueShown } from "./env-file.mjs";
|
|
10
|
+
import { STACK_KEYS, VALKEY_SHARED_KEY, readStackKeys, readValkeyKeys } from "./podman-stack.mjs";
|
|
11
|
+
|
|
12
|
+
/** How each command says what a shell/file disagreement would make it do, the clause before "while the service ...". */
|
|
13
|
+
const COMMAND_SAYS = Object.freeze({
|
|
14
|
+
up: "up would drive the shell's venue",
|
|
15
|
+
init: "init's next steps would be for the shell's venue",
|
|
16
|
+
doctor: "doctor would judge the shell's venue",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The venue keys this pass decides with (D2): this shell's value where it sets one, else the deployment `.env`'s, read
|
|
21
|
+
* with `readStackKeys` exactly as `service install` reads them. Returns `{ env, fromFile, disagreements }` or
|
|
22
|
+
* `{ error }`. `env` is the whole environment with the file's keys filled in; `fromFile` is only what the file
|
|
23
|
+
* supplied, for doctor's layering; `disagreements` names each key both set differently.
|
|
24
|
+
*/
|
|
25
|
+
export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up", loader: loaderGiven = null, keys: keysWanted = STACK_KEYS }) {
|
|
26
|
+
let keys = {};
|
|
27
|
+
const notes = [];
|
|
28
|
+
// The loader of the file on THIS platform, as service.mjs reads it (round 2, E6): systemd's EnvironmentFile= on
|
|
29
|
+
// Linux, the sh wrapper on macOS, the cmd wrapper on Windows. Off Linux the venue refuses the host anyway, so a
|
|
30
|
+
// line that platform's loader reads differently is noted and never stops `up`.
|
|
31
|
+
const linux = platform === "linux";
|
|
32
|
+
// A caller with a mapping of its own passes it (doctor, so every `.env` read it makes uses one loader).
|
|
33
|
+
const loader = loaderGiven ?? (linux ? "systemd" : platform === "darwin" ? "shell" : "cmd");
|
|
34
|
+
if (fs.existsSync(envPath)) {
|
|
35
|
+
let text = null;
|
|
36
|
+
try {
|
|
37
|
+
// Bytes, not text: `readStackKeys` checks what systemd refuses to load before it decodes (issue #447).
|
|
38
|
+
text = fs.readFileSync(envPath);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// Said rather than silent (round 2 nit): the venue then comes from this shell alone.
|
|
41
|
+
notes.push(`${envPath} could not be read (${err?.message}), so PI_BACKENDS, PI_EGRESS and PI_EGRESS_PROXY come from this shell alone`);
|
|
42
|
+
}
|
|
43
|
+
if (text !== null) {
|
|
44
|
+
const read = readStackKeys(text, { loader, path: envPath });
|
|
45
|
+
if (read.error && linux) return { error: read.error };
|
|
46
|
+
if (read.error) notes.push(`${read.error}; off Linux the podman venue refuses this host anyway, so this pass reads the venue from this shell`);
|
|
47
|
+
else keys = read.keys;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const merged = { ...env };
|
|
51
|
+
const fromFile = {};
|
|
52
|
+
const conflicts = [];
|
|
53
|
+
// Only the keys the caller allows are taken from the file (doctor's `SERVICE_ENV_KEYS`); all of them by default.
|
|
54
|
+
for (const key of STACK_KEYS.filter((k) => keysWanted.includes(k))) {
|
|
55
|
+
if (!Object.hasOwn(keys, key)) continue;
|
|
56
|
+
if (typeof env[key] === "string") {
|
|
57
|
+
// Compared as the worker READS them (round 3, D4), not as strings: ` podman` and `podman,podman` are the list
|
|
58
|
+
// `podman`, and refusing them sent an operator to reconcile two values that already agree.
|
|
59
|
+
if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push(`${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
merged[key] = keys[key];
|
|
63
|
+
fromFile[key] = keys[key];
|
|
64
|
+
}
|
|
65
|
+
// A KNOWN disagreement is a refusal (round 2, E5), not a warning: this pass would stand up one venue's stack and the
|
|
66
|
+
// service installed next would run the other. The file is what the service runs, so it is the one to change unless
|
|
67
|
+
// the shell's value was a one-off.
|
|
68
|
+
if (conflicts.length > 0) {
|
|
69
|
+
return { error: `${conflicts.join("; ")}. ${COMMAND_SAYS[command] ?? COMMAND_SAYS.up} while the service runs the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
|
|
70
|
+
}
|
|
71
|
+
return { env: merged, fromFile, notes };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* VALKEY_URL and PI_VALKEY_SHARED as `up` must judge them (issue #464): the deployment `.env`'s, read with the loader the
|
|
76
|
+
* service uses (`readValkeyKeys`), because the Valkey `up` adopts or refuses is the one the SERVICE's worker will use.
|
|
77
|
+
* For VALKEY_URL this shell's value is taken where the file sets none (what the wizard relies on before init has
|
|
78
|
+
* written `.env`), and both set differently is refused, as a venue key is (`deploymentVenueEnv`), since the service
|
|
79
|
+
* runs the file's. PI_VALKEY_SHARED comes from the file only, a shell value named as ignored (gate round 2); a
|
|
80
|
+
* `.env` line the loaders read differently is refused, naming what in it is the problem, on Linux (off it the podman
|
|
81
|
+
* venue refuses the host anyway, so it is a note). Returns `{ env: { VALKEY_URL?, PI_VALKEY_SHARED? }, fromFile, notes }`
|
|
82
|
+
* or `{ error }`.
|
|
83
|
+
*/
|
|
84
|
+
export function deploymentValkeyEnv({ env, fs, envPath, platform, command = "up" }) {
|
|
85
|
+
const linux = platform === "linux";
|
|
86
|
+
const loader = linux ? "systemd" : platform === "darwin" ? "shell" : "cmd";
|
|
87
|
+
const notes = [];
|
|
88
|
+
let keys = {};
|
|
89
|
+
if (fs.existsSync(envPath)) {
|
|
90
|
+
let text = null;
|
|
91
|
+
try {
|
|
92
|
+
// Bytes, not text (gate round 3): `readValkeyKeys` checks what systemd refuses to load before it decodes.
|
|
93
|
+
text = fs.readFileSync(envPath);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
notes.push(`${envPath} could not be read (${err?.message}), so VALKEY_URL comes from this shell alone and PI_VALKEY_SHARED is unset`);
|
|
96
|
+
}
|
|
97
|
+
if (text !== null) {
|
|
98
|
+
const read = readValkeyKeys(text, { loader, path: envPath });
|
|
99
|
+
if (read.error && linux) return { error: `${read.error}. ${command} judges the Valkey the service will use, so it stops here` };
|
|
100
|
+
if (read.error) notes.push(read.error);
|
|
101
|
+
else keys = read.keys;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const merged = {};
|
|
105
|
+
const fromFile = {};
|
|
106
|
+
const conflicts = [];
|
|
107
|
+
{
|
|
108
|
+
const shell = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : undefined;
|
|
109
|
+
if (!Object.hasOwn(keys, "VALKEY_URL")) {
|
|
110
|
+
if (shell !== undefined) merged.VALKEY_URL = shell;
|
|
111
|
+
} else {
|
|
112
|
+
if (shell !== undefined && shell !== keys.VALKEY_URL) conflicts.push(`VALKEY_URL is ${quotedShown(shell)} in this shell and ${quotedShown(keys.VALKEY_URL)} in ${envPath}`);
|
|
113
|
+
merged.VALKEY_URL = keys.VALKEY_URL;
|
|
114
|
+
fromFile.VALKEY_URL = keys.VALKEY_URL;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// PI_VALKEY_SHARED from the file ONLY (gate round 2): it is the one statement that another uid's Valkey is this
|
|
118
|
+
// deployment's queue on purpose, and the worker, `service install` and doctor read it from `.env` alone, so a shell
|
|
119
|
+
// export that `up` honoured was an opt-in the service never made (measured: `up` exit 0 and a ✓ over a refused
|
|
120
|
+
// install). A shell value is ignored, and said.
|
|
121
|
+
if (Object.hasOwn(keys, VALKEY_SHARED_KEY)) {
|
|
122
|
+
merged[VALKEY_SHARED_KEY] = keys[VALKEY_SHARED_KEY];
|
|
123
|
+
fromFile[VALKEY_SHARED_KEY] = keys[VALKEY_SHARED_KEY];
|
|
124
|
+
}
|
|
125
|
+
if (typeof env[VALKEY_SHARED_KEY] === "string") notes.push(sharedShellIgnored(env[VALKEY_SHARED_KEY], envPath));
|
|
126
|
+
if (conflicts.length > 0) {
|
|
127
|
+
return { error: `${conflicts.join("; ")}. ${command} would judge the shell's Valkey while the service uses the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
|
|
128
|
+
}
|
|
129
|
+
return { env: merged, fromFile, notes };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The sentence for a PI_VALKEY_SHARED this shell sets, which no command honours (issue #464, gate round 2). */
|
|
133
|
+
export function sharedShellIgnored(value, envPath) {
|
|
134
|
+
return `${VALKEY_SHARED_KEY} is ${quotedShown(value)} in this shell: ignored, since only ${envPath} may say a Valkey is shared on purpose (the service's worker reads it there alone)`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What a venue key MEANS to the worker, for comparing two spellings of it: the parsed list for PI_BACKENDS, on or off
|
|
139
|
+
* for PI_EGRESS, the resolved name for PI_EGRESS_PROXY exactly as egressProxyName resolves it (empty is the default
|
|
140
|
+
* name; NOT trimmed, because nothing that reads the name trims it, so " x" and "x" are different proxies to the worker
|
|
141
|
+
* and to podman). A value the worker would refuse keeps its raw spelling, so two different unreadable values still
|
|
142
|
+
* disagree and the doctor below names them.
|
|
143
|
+
*/
|
|
144
|
+
function venueKeyMeaning(key, value) {
|
|
145
|
+
try {
|
|
146
|
+
if (key === "PI_BACKENDS") return `list:${parseBackendList(value).join(",")}`;
|
|
147
|
+
if (key === "PI_EGRESS") return `egress:${egressArmed({ PI_EGRESS: value }) ? "on" : "off"}`;
|
|
148
|
+
return `proxy:${egressProxyName({ PI_EGRESS_PROXY: value })}`;
|
|
149
|
+
} catch {
|
|
150
|
+
return `raw:${value}`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A value shown in quotes, so a leading space or an empty value is visible; control characters escaped as ever. */
|
|
155
|
+
function quotedShown(value) {
|
|
156
|
+
const shown = envValueShown(value);
|
|
157
|
+
return shown.startsWith('"') ? shown : `"${shown}"`;
|
|
158
|
+
}
|
package/src/docker-run.mjs
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// `buildDockerRunArgs`, which never moved; the entry that names `containerSpec` is design.md's 2026-08-31
|
|
21
21
|
// row, and this move is recorded in its own row rather than by leaving that pointer to rot.
|
|
22
22
|
export { containerSpec, CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE } from "./container-spec.mjs";
|
|
23
|
-
import {
|
|
23
|
+
import { isAbsolute, relative } from "node:path";
|
|
24
|
+
import { assertCidFile, assertJobUser, containerSpec } from "./container-spec.mjs";
|
|
24
25
|
|
|
25
26
|
/** The fixed isolation flags. Not configurable -- these ARE the boundary. */
|
|
26
27
|
export const ISOLATION_FLAGS = [
|
|
@@ -43,8 +44,8 @@ export const ISOLATION_FLAGS = [
|
|
|
43
44
|
];
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
* HOW Docker spells it
|
|
47
|
-
*
|
|
47
|
+
* HOW Docker spells it, and the local backend's translation step. Since issue #354 `podmanArgsFromSpec` below is the
|
|
48
|
+
* second, and it shares this function's body rather than copying it.
|
|
48
49
|
*/
|
|
49
50
|
/**
|
|
50
51
|
* Flags a `dockerExtra` may not carry, because docker resolves a repeated option LAST-WINS and this array
|
|
@@ -56,9 +57,9 @@ export const ISOLATION_FLAGS = [
|
|
|
56
57
|
* The list covers every member of `ISOLATION_FLAGS`, the container name, and the near-synonyms that reach
|
|
57
58
|
* the same effect without repeating a listed flag (`--volumes-from` for a mount, `--memory-swap` for the
|
|
58
59
|
* memory bound). It is a DENY-list and therefore only as good as its coverage: docker's surface is large
|
|
59
|
-
* and a release can add another way in. So it NARROWS the gap rather than closing it
|
|
60
|
-
* table's `isolation` and `ephemeral` words rest on
|
|
61
|
-
*
|
|
60
|
+
* and a release can add another way in. So it NARROWS the gap rather than closing it; since issue #341 the
|
|
61
|
+
* allow-list below closes it, and the backend table's `isolation` and `ephemeral` words rest on both plus the
|
|
62
|
+
* callers passing fixed literals.
|
|
62
63
|
*
|
|
63
64
|
* Without this the two standing assertions that "every member of ISOLATION_FLAGS reaches the argv" would
|
|
64
65
|
* still pass on an argv with no boundary left, because membership is not effectiveness. Nothing passes any
|
|
@@ -66,13 +67,22 @@ export const ISOLATION_FLAGS = [
|
|
|
66
67
|
* which stay allowed -- so this closes a hole rather than changing a behaviour, and it makes "the builder
|
|
67
68
|
* CANNOT DECLINE the boundary" true of the whole argv instead of of one boolean field.
|
|
68
69
|
*
|
|
69
|
-
* `--user`
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
70
|
+
* `--user` and `-u` ARE here (issue #341), because the builder owns the job user now: `containerSpec`'s `user` field,
|
|
71
|
+
* validated non-root and emitted before `dockerExtra`. A second `--user` in `dockerExtra` would win last and could
|
|
72
|
+
* name uid 0, which is `nonRoot` gone. An earlier version of this comment called `--user` "a documented, tested
|
|
73
|
+
* feature" and left it allowed; nothing documented it and nothing passed it, which is how every job on a native
|
|
74
|
+
* Linux daemon whose worker uid is not 1001 came to run as a uid that cannot read its own inputs.
|
|
75
|
+
*
|
|
76
|
+
* ONE SHORT FLAG PER TOKEN, and that is a separate rule below the list: docker parses `-u0` as `-u 0`, `-iu0` as
|
|
77
|
+
* `-i -u 0`, `-v/:/h` as a mount and `-m1g` as a memory bound, and a list compared on the text before `=` sees none
|
|
78
|
+
* of those. A single-dash token longer than two characters is refused outright; every caller passes separate tokens.
|
|
79
|
+
*
|
|
80
|
+
* AND AN ALLOW-LIST CLOSES WHAT THE DENY-LIST ONLY NARROWED (issue #341, found by an adversarial pass): a bare
|
|
81
|
+
* positional token or `--` becomes the IMAGE, with every `-e` and `-v` after it passed to that image as arguments;
|
|
82
|
+
* `--annotation run.oci.keep_original_groups=1` keeps the worker's groups on Podman; `--uidmap`/`--gidmap` remap
|
|
83
|
+
* the user on podman-docker; `--use-api-socket` mounts the daemon socket on a recent CLI. None was listed, and the
|
|
84
|
+
* next release adds another. So after the refusals above, a token must be one of what the callers actually pass,
|
|
85
|
+
* `DOCKER_EXTRA_ALLOWED`, or it is refused. The deny-list stays for its named reasons and its tests.
|
|
76
86
|
*/
|
|
77
87
|
export const DOCKER_EXTRA_FORBIDDEN = [
|
|
78
88
|
// Each of the seven logical flags in ISOLATION_FLAGS, and the argv member beside them that the worker's
|
|
@@ -85,6 +95,9 @@ export const DOCKER_EXTRA_FORBIDDEN = [
|
|
|
85
95
|
// last and leaves a container no sweep finds and no timeout can stop. `ephemeral` and `abortable` both
|
|
86
96
|
// rest on it.
|
|
87
97
|
"--name",
|
|
98
|
+
// Issue #345: the run's cidfile is how a container that outlived its `docker run` is found; a second `--cidfile`
|
|
99
|
+
// would win last and write the ID where the worker does not look.
|
|
100
|
+
"--cidfile",
|
|
88
101
|
"--privileged",
|
|
89
102
|
"--cap-add",
|
|
90
103
|
"--security-opt",
|
|
@@ -118,9 +131,74 @@ export const DOCKER_EXTRA_FORBIDDEN = [
|
|
|
118
131
|
"--uts",
|
|
119
132
|
"--userns",
|
|
120
133
|
"--cgroupns",
|
|
134
|
+
// The job user is a spec field (issue #341). Here so a repeat cannot override it with uid 0.
|
|
135
|
+
"--user",
|
|
136
|
+
"-u",
|
|
121
137
|
];
|
|
122
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Everything a `dockerExtra` may say. Bare flags stand alone; a valued flag takes the NEXT token, and that token must
|
|
141
|
+
* match its pattern: an entrypoint is a command name, never a flag, and a published port is bound to loopback
|
|
142
|
+
* (`parsePublish` never builds anything else). The sandbox passes `-i -t --entrypoint bash -p 127.0.0.1:<h>:<c>`;
|
|
143
|
+
* the live probes pass `-d --entrypoint sleep` and `-d`.
|
|
144
|
+
*/
|
|
145
|
+
export const DOCKER_EXTRA_ALLOWED = Object.freeze({
|
|
146
|
+
bare: Object.freeze(["-i", "-t", "-d"]),
|
|
147
|
+
valued: Object.freeze({ "--entrypoint": /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/, "-p": /^127\.0\.0\.1:\d{1,5}:\d{1,5}$/ }),
|
|
148
|
+
});
|
|
149
|
+
|
|
123
150
|
export function dockerArgsFromSpec(spec) {
|
|
151
|
+
// Issue #354. REFUSED, never dropped: the docker CLI rejects `--userns=keep-id` client-side (exit 125, measured in
|
|
152
|
+
// issue #345), and silently omitting it would run the job under the daemon's own mapping, where the "<uid>:<gid>"
|
|
153
|
+
// the caller chose for bind-mount ownership names a different host identity. `undefined` is a hand-built spec that
|
|
154
|
+
// predates the field, which asked for nothing.
|
|
155
|
+
if (spec?.userns !== null && spec?.userns !== undefined) {
|
|
156
|
+
throw new Error(`docker run: refusing a spec with userns ${JSON.stringify(spec.userns)}: the docker CLI has no --userns=keep-id, and only a podman venue builds one`);
|
|
157
|
+
}
|
|
158
|
+
return argsFromSpec(spec, { userns: null });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* HOW rootless Podman spells the same box (issue #354): `dockerArgsFromSpec`'s argv with `--userns=keep-id` immediately
|
|
163
|
+
* after `--user=`, from ONE private builder, so the boundary flags, the `dockerExtra` allow-list and the mount rendering
|
|
164
|
+
* cannot drift between the two runtimes. Podman accepts every other token here as docker does (`--pull=never`,
|
|
165
|
+
* `--cidfile`, `-v host:ctr:ro,Z`: measured on 5.8.1).
|
|
166
|
+
*
|
|
167
|
+
* `keep-id` is REQUIRED, and so is a user. Without keep-id a rootless container's uid N is the host's subordinate uid, so
|
|
168
|
+
* a job run as the worker's uid cannot read `/job`. With keep-id and NO `--user`, the container runs as the IMAGE's user
|
|
169
|
+
* with the worker's gid only as a supplementary group, and `/job` is unreadable again (both measured on Fedora 44,
|
|
170
|
+
* Podman 5.8.1). Either argv would start, spend its slot and fail inside the container, so both are refused here.
|
|
171
|
+
*/
|
|
172
|
+
export function podmanArgsFromSpec(spec) {
|
|
173
|
+
if (spec?.userns !== "keep-id") {
|
|
174
|
+
throw new Error(`podman run: refusing a spec whose userns is not "keep-id": ${JSON.stringify(spec?.userns ?? null)} (a rootless job without it cannot read its own mounts)`);
|
|
175
|
+
}
|
|
176
|
+
if (spec.user === null || spec.user === undefined) {
|
|
177
|
+
throw new Error("podman run: refusing a keep-id spec with no job user: the image's own user would run with /job unreadable");
|
|
178
|
+
}
|
|
179
|
+
return argsFromSpec(spec, { userns: "keep-id" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The namespaces and inheritances a rootless Podman argv pins, because the account's own containers.conf can set every
|
|
184
|
+
* container's defaults for them: `pidns` (PID), `ipcns` (IPC), `utsns` (UTS), `cgroupns` (cgroup), `env_host` and `http_proxy`. (dockerd's
|
|
185
|
+
* daemon.json can default the cgroup and IPC modes too, and the docker argv pins neither: a gap older than this venue.) Measured on Podman 5.8.1 with a user containers.conf of `pidns = "host"` and `env_host = true`: an
|
|
186
|
+
* unpinned job ran outside its own PID namespace and received the worker's environment (the provider key with it);
|
|
187
|
+
* with these flags it got its own namespaces and nothing. `http_proxy` is on by default, which copies the worker's proxy
|
|
188
|
+
* variables into every job. A job's network is pinned the same way where it has none of its own (`--network=private`,
|
|
189
|
+
* Podman's word for the rootless default), or `netns = "host"` would put it on the host's. What cannot be pinned is
|
|
190
|
+
* that network's OPTIONS: containers.conf `pasta_options` come before the command line's in pasta's argv, and a conf
|
|
191
|
+
* `-T <port>` survives even `--network=pasta:--map-host-loopback,none` (measured), so no flag here could close it. It is
|
|
192
|
+
* REFUSED instead, not left open: the venue refuses to run while the account's containers.conf sets `pasta_options`, `network_cmd_options`, `annotations`, `env`, `helper_binaries_dir`, `network_cmd_path`, `default_sysctls`, `default_ulimits`, `seccomp_profile`, `init_path`, `dns_servers`, `dns_options`, `dns_searches`, `base_hosts_file`, `oom_score_adj`, `privileged`, `label`, `cgroup_conf`, `host_containers_internal_ip`, `runtimes`, `conmon_path`, `cgroups` or `umask` at all, the list
|
|
193
|
+
* `PODMAN_WIDENING_KEYS` in backends.mjs (`podmanConfWidening` in backend-podman.mjs, issues #428 and #450).
|
|
194
|
+
*/
|
|
195
|
+
export const PODMAN_PINNED_FLAGS = Object.freeze(["--pid=private", "--ipc=private", "--uts=private", "--cgroupns=private", "--env-host=false", "--http-proxy=false"]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The shared body of both builders. `userns` is the caller's, never the spec's: each public builder has already decided
|
|
199
|
+
* what its runtime may say, and reading the spec here would let a docker argv carry a flag its CLI refuses.
|
|
200
|
+
*/
|
|
201
|
+
function argsFromSpec(spec, { userns }) {
|
|
124
202
|
// The builder CANNOT DECLINE the boundary. `containerSpec` cannot produce anything but `true`, so this
|
|
125
203
|
// only ever fires on a hand-built spec -- and a hand-built spec that forgot the field is exactly the
|
|
126
204
|
// case that must fail loudly rather than quietly emit a container with no isolation flags at all.
|
|
@@ -129,7 +207,9 @@ export function dockerArgsFromSpec(spec) {
|
|
|
129
207
|
// Same refusal, one level down. `dockerExtra` is raw Docker flags by design, and it lands after the
|
|
130
208
|
// boundary where a repeat supersedes it -- so the escape hatch is bounded by what it may not say.
|
|
131
209
|
// Split on `=` so `--network=foo` is caught alongside `--network foo`.
|
|
132
|
-
|
|
210
|
+
const extra = spec.dockerExtra ?? [];
|
|
211
|
+
for (let i = 0; i < extra.length; i++) {
|
|
212
|
+
const flag = extra[i];
|
|
133
213
|
// REFUSED, not skipped. Skipping a non-string still PUSHED it into the argv below, so
|
|
134
214
|
// `new String("--privileged")` and `{ toString: () => "--privileged" }` walked past the check and
|
|
135
215
|
// then reached docker as the flag they stringify to.
|
|
@@ -139,7 +219,21 @@ export function dockerArgsFromSpec(spec) {
|
|
|
139
219
|
if (DOCKER_EXTRA_FORBIDDEN.includes(flag.split("=", 1)[0])) {
|
|
140
220
|
throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag}`);
|
|
141
221
|
}
|
|
222
|
+
// Fused short flags (see the list's header). After the list check, so a listed flag keeps its own refusal.
|
|
223
|
+
if (/^-[^-]/.test(flag) && flag.length > 2) {
|
|
224
|
+
throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag} (one short flag per token)`);
|
|
225
|
+
}
|
|
226
|
+
if (DOCKER_EXTRA_ALLOWED.bare.includes(flag)) continue;
|
|
227
|
+
const valuePattern = Object.hasOwn(DOCKER_EXTRA_ALLOWED.valued, flag) ? DOCKER_EXTRA_ALLOWED.valued[flag] : null;
|
|
228
|
+
if (valuePattern && typeof extra[i + 1] === "string" && valuePattern.test(extra[i + 1])) {
|
|
229
|
+
i++;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`docker run: refusing a dockerExtra token outside what the builder's callers pass (-i, -t, -d, --entrypoint <command>, -p 127.0.0.1:<host>:<container>): ${JSON.stringify(flag)}`);
|
|
142
233
|
}
|
|
234
|
+
// Re-checked here for a hand-built spec, the same reason `isolated` is.
|
|
235
|
+
assertJobUser(spec.user);
|
|
236
|
+
assertCidFile(spec.cidFile);
|
|
143
237
|
|
|
144
238
|
// `--network` sits HERE, beside --memory and --cpus, and deliberately NOT inside ISOLATION_FLAGS.
|
|
145
239
|
// That array is the LITERAL, value-free, unconditional set, and two separate places assert every member
|
|
@@ -153,6 +247,14 @@ export function dockerArgsFromSpec(spec) {
|
|
|
153
247
|
// before this feature existed. Same shape as the sessionDir/outboxDir/globalPiDir mounts below.
|
|
154
248
|
const args = ["run", `--name=${spec.name}`, ...ISOLATION_FLAGS, `--memory=${spec.memory}`, `--cpus=${spec.cpus}`];
|
|
155
249
|
if (spec.network) args.push(`--network=${spec.network}`);
|
|
250
|
+
else if (userns) args.push("--network=private");
|
|
251
|
+
// null => ABSENT, so a job the image's own USER runs has an argv byte-identical to one built before issue #341.
|
|
252
|
+
if (spec.user) args.push(`--user=${spec.user}`);
|
|
253
|
+
// Issue #354: IMMEDIATELY after `--user=`, which it qualifies, and before `dockerExtra`, where `--userns` is refused.
|
|
254
|
+
if (userns) args.push(`--userns=${userns}`, ...PODMAN_PINNED_FLAGS);
|
|
255
|
+
// null => ABSENT, so every argv but a job's is byte-identical to one built before issue #345. BEFORE `dockerExtra`, and
|
|
256
|
+
// `--cidfile` is refused there, so no later token can move where the ID lands and turn the detached check off.
|
|
257
|
+
if (spec.cidFile) args.push(`--cidfile=${spec.cidFile}`);
|
|
156
258
|
args.push(...(spec.dockerExtra ?? []));
|
|
157
259
|
|
|
158
260
|
// Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
|
|
@@ -166,7 +268,16 @@ export function dockerArgsFromSpec(spec) {
|
|
|
166
268
|
// `-v` and its value stay TWO argv elements rather than one `--volume=` token. Not cosmetic: the mount
|
|
167
269
|
// assertions across this suite extract mounts by adjacency (`args[i - 1] === "-v"`), so collapsing the
|
|
168
270
|
// pair would make those filters return nothing and turn several exact-array checks vacuously green.
|
|
169
|
-
|
|
271
|
+
//
|
|
272
|
+
// The option list after the container path is `ro`, `Z`, or `ro,Z` (issue #355): one list, comma-joined, which is
|
|
273
|
+
// how both the docker CLI and Podman read it. `Z` only for a mount the spec marks `relabel: "private"`, so a spec
|
|
274
|
+
// without that field renders exactly the strings it always did. Anything else in that field is refused rather than
|
|
275
|
+
// dropped, because a mount that silently lost its label fails in the container with nothing pointing back here.
|
|
276
|
+
for (const m of spec.mounts ?? []) {
|
|
277
|
+
if (m.relabel !== undefined && m.relabel !== "private") throw new Error(`docker run: refusing a mount relabel other than "private": ${JSON.stringify(m.relabel)}`);
|
|
278
|
+
const options = [...(m.readOnly ? ["ro"] : []), ...(m.relabel === "private" ? ["Z"] : [])];
|
|
279
|
+
args.push("-v", `${m.host}:${m.container}${options.length > 0 ? `:${options.join(",")}` : ""}`);
|
|
280
|
+
}
|
|
170
281
|
|
|
171
282
|
args.push(spec.image);
|
|
172
283
|
return args;
|
|
@@ -182,3 +293,23 @@ export function dockerArgsFromSpec(spec) {
|
|
|
182
293
|
export function buildDockerRunArgs(opts) {
|
|
183
294
|
return dockerArgsFromSpec(containerSpec(opts));
|
|
184
295
|
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The `podman run` argv (excluding the leading "podman"), issue #354. `userns` is not the caller's to choose: a podman
|
|
299
|
+
* job runs keep-id or not at all (see `podmanArgsFromSpec`), so an opts bag carrying another value is overridden rather
|
|
300
|
+
* than read. A null `user` is refused by the builder, before anything spawns.
|
|
301
|
+
*/
|
|
302
|
+
export function buildPodmanRunArgs(opts) {
|
|
303
|
+
return podmanArgsFromSpec(containerSpec({ ...opts, userns: "keep-id" }));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Whether `inner` is strictly inside `outer` (issue #355). The one containment rule that decides a workspace is the
|
|
308
|
+
* worker's own and may carry a private SELinux label: the job path (run-container) and a reopened sandbox both ask it, here beside the builder that renders the label,
|
|
309
|
+
* and it fails CLOSED, on an empty or non-string path, on `outer` itself and on anything that climbs out of it.
|
|
310
|
+
*/
|
|
311
|
+
export function insideDir(outer, inner) {
|
|
312
|
+
if (typeof outer !== "string" || typeof inner !== "string" || outer === "" || inner === "") return false;
|
|
313
|
+
const rel = relative(outer, inner);
|
|
314
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
315
|
+
}
|