@edgehero/pi-dispatch 1.10.3 → 2.1.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 +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- 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 +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- 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 +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -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-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- 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/packages.mjs +2 -2
- 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/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -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 +24 -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 +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -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,1152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime observations (issue #345): what THIS HOST's daemon is observed to do about a container's bounds and its
|
|
3
|
+
* mounts, turned into the `daemonAppliesBounds` and `runtimeAddsNoMounts` answers `backends.mjs`'s `effectiveWord`
|
|
4
|
+
* reads. No container runs and no second daemon call is made: the bounds come from the one `docker info` read the job
|
|
5
|
+
* user is decided from (`job-user.mjs`), and the mounts from files on this host.
|
|
6
|
+
*
|
|
7
|
+
* THREE ANSWERS, NEVER TWO. `true` earns the declared word; `false` degrades it to `asserted`, and a floor asking for
|
|
8
|
+
* `enforced` refuses; `null` is NOT ANSWERED (the daemon did not answer, or answered in a shape nothing reads), which a
|
|
9
|
+
* floor turns into a retry rather than a refusal, the endpoint read's transient rule. No credit is ever given for a
|
|
10
|
+
* missing or unreadable fact: the polarity every observation in `backends.mjs` keeps.
|
|
11
|
+
*
|
|
12
|
+
* The evidence strings are fixed text plus a file path or a daemon field NAME, never a value the daemon or a file
|
|
13
|
+
* carries, so they can be logged and put in a refusal.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import { execDockerBounded } from "./backend-local.mjs";
|
|
18
|
+
import { DAEMON_APPLIES_BOUNDS, DOCKER_ENDPOINT_LOCAL, PODMAN_CONF_WIDENS_JOB, PODMAN_ROOTFUL_WIDENING_KEYS, RUNTIME_ADDS_NO_MOUNTS } from "./backends.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The override for Podman's default mount list. When it exists, Podman reads it instead of
|
|
22
|
+
* `/usr/share/containers/mounts.conf`, and an EMPTY one mounts nothing (measured on rootful Podman 5.8.2: `/run/secrets`
|
|
23
|
+
* disappears from `/proc/self/mountinfo`).
|
|
24
|
+
*/
|
|
25
|
+
export const PODMAN_MOUNTS_CONF = "/etc/containers/mounts.conf";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The system containers.conf chain a rootful Podman reads (containers/common v0.67.0 `systemConfigs`): the vendor file,
|
|
29
|
+
* the `/etc` file and every `*.conf` in `/etc/containers/containers.conf.d`. Measured for issue #448 (gate round 1 of PR
|
|
30
|
+
* #473) with a drop-in in each candidate place, on rootful Podman 5.8.1 and 4.9.3, through the API service and the root
|
|
31
|
+
* podman CLI alike: `/usr/share/containers/containers.conf.d` and both `containers.rootful.conf.d` directories were NOT
|
|
32
|
+
* read, so they are not on the chain. Root's own conf, `--module` files and `CONTAINERS_CONF` are added by
|
|
33
|
+
* `rootfulConfChain`. Not `default_mounts_file`: that is not a containers.conf key (`toml:"-"`), only a hidden flag.
|
|
34
|
+
*/
|
|
35
|
+
export const PODMAN_CONTAINERS_CONF_FILES = Object.freeze(["/usr/share/containers/containers.conf", "/etc/containers/containers.conf"]);
|
|
36
|
+
export const PODMAN_CONTAINERS_CONF_DIRS = Object.freeze(["/etc/containers/containers.conf.d"]);
|
|
37
|
+
|
|
38
|
+
/** FIPS mode adds the host's crypto policy mounts outside `mounts.conf` (container-libs pkg/subscriptions). */
|
|
39
|
+
export const FIPS_ENABLED_PATH = "/proc/sys/crypto/fips_enabled";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A key that adds to every container what no argv names: `volumes`, `mounts`, `devices` (host device nodes) and
|
|
43
|
+
* `hooks_dir` (OCI hooks, which can mount). Matched in ANY LETTER CASE (Podman's TOML decoding matches keys
|
|
44
|
+
* case-insensitively: `Volumes` and `CONTAINERS.VOLUMES` both mount, measured on Podman 5.8.2), bare or quoted at the start
|
|
45
|
+
* of a line, dotted (`containers.volumes = [...]`), or inside an inline table (`containers = { volumes = [...] }`), on any
|
|
46
|
+
* line that is not a whole-line comment: a `#` inside a string earlier on the line is not a comment (measured, an
|
|
47
|
+
* `env = ["X=#"]` before the key still mounts). Wider than Podman's own reading on purpose: a string value or a trailing
|
|
48
|
+
* comment that merely contains `volumes =` also matches, which withholds credit rather than giving it.
|
|
49
|
+
*/
|
|
50
|
+
export const MOUNT_KEY = /^(?!\s*#).*?(?:^|[\s.{,"'])["']?(?:volumes|mounts|devices|hooks_dir)["']?\s*=/im;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A quoted TOML key holding a backslash escape (`"volum\u0065s" = ...`), which TOML reads as the unescaped name and
|
|
54
|
+
* `MOUNT_KEY` cannot see through, on any line that is not a whole-line comment. Refused outright rather than decoded: no
|
|
55
|
+
* containers.conf needs one.
|
|
56
|
+
*/
|
|
57
|
+
export const ESCAPED_KEY = /^(?!\s*#).*?["'][^"'\n]*\\[^"'\n]*["']\s*=/m;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A file this check cannot read the way Podman's TOML decoder does, on any line, comment or not (issue #428): a non-ASCII
|
|
61
|
+
* character or a multi-line string opener. Each was measured bypassing the key patterns while Podman honoured the key:
|
|
62
|
+
* Go's case folding matches `"pa\u017fta_options"` (a LONG S) to `pasta_options` where a JS `/i` does not; a `"""` or
|
|
63
|
+
* `'''` string whose content has a line starting `#` reads here as a comment and there as a string, so a key after it
|
|
64
|
+
* on the same TOML line is hidden; and U+2028 or U+2029 inside a string is a line break to a JS regex and not to TOML.
|
|
65
|
+
* Refused outright rather than decoded, as `ESCAPED_KEY` is: a stock containers.conf is plain ASCII with no multi-line
|
|
66
|
+
* string (Fedora 44's, and containers/common v0.57.4's, which Ubuntu 24.04 packages), so the rule costs nothing real.
|
|
67
|
+
*/
|
|
68
|
+
export const UNREAD_SPELLING = /[^\x00-\x7f]|"""|'''/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The first line (1-based, split on `\n` only, as TOML counts lines) holding an `UNREAD_SPELLING`, as `{ line, kind }`
|
|
72
|
+
* with `kind` `"non-ascii"` or `"multi-line"` (a line holding both is named for its non-ASCII character), or `null`.
|
|
73
|
+
* So a refusal can say WHERE, and which of the two, rather than send an operator through the whole file.
|
|
74
|
+
*/
|
|
75
|
+
export function unreadSpelling(text) {
|
|
76
|
+
const lines = String(text).split("\n");
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
if (/[^\x00-\x7f]/.test(lines[i])) return { line: i + 1, kind: "non-ascii" };
|
|
79
|
+
if (/"""|'''/.test(lines[i])) return { line: i + 1, kind: "multi-line" };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The read errors that say nothing about the file, only about this moment (issue #428): out of descriptors or memory,
|
|
86
|
+
* an I/O error, a busy or stale mount, an interrupted or timed-out call. A conf file or drop-in directory that fails
|
|
87
|
+
* with one of these is NOT ANSWERED (`null`, so a floor retries and the podman venue's refusal retries) rather than
|
|
88
|
+
* withheld (`false`, refused): refusing it turned a full descriptor table into a dropped job. Every other code
|
|
89
|
+
* (`EACCES`, `EPERM`, `ENOTDIR`, `ELOOP`, `EISDIR`, an unknown one) stays determinate, the precedent, because a retry
|
|
90
|
+
* reads the same permissions; listed this way round so an errno nobody thought of fails closed, not into a retry loop.
|
|
91
|
+
*/
|
|
92
|
+
export const TRANSIENT_READ_ERRORS = new Set(["EMFILE", "ENFILE", "EIO", "EAGAIN", "EWOULDBLOCK", "EBUSY", "ENOMEM", "EINTR", "ETIMEDOUT", "ESTALE"]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A host file or directory an observation could not read, as a finding: `null` for a transient code, carrying
|
|
96
|
+
* `reason: "file-unread"` so the retry names a file rather than a daemon, else `false`. The ONE rule for every file an
|
|
97
|
+
* observation reads (round 2 of the #428 review: the conf chain had it and its sibling reads, mounts.conf, the hooks
|
|
98
|
+
* directories and the FIPS file, still turned EMFILE into a floor refusal that dropped the job).
|
|
99
|
+
*/
|
|
100
|
+
export function unreadFileFinding(path, code) {
|
|
101
|
+
return TRANSIENT_READ_ERRORS.has(code) ? { value: null, evidence: `${path} could not be read (${code})`, reason: "file-unread" } : { value: false, evidence: `${path} could not be read (${code})` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** OCI hook directories Podman runs every `*.json` hook from (container-libs pkg/config); a hook can mount into a container. */
|
|
105
|
+
export const PODMAN_HOOKS_DIRS = Object.freeze(["/usr/share/containers/oci/hooks.d", "/etc/containers/oci/hooks.d"]);
|
|
106
|
+
|
|
107
|
+
/** What a containers.conf matching `MOUNT_KEY` is said to do, the evidence `observeRuntimeMounts` has always given. */
|
|
108
|
+
export const MOUNT_KEY_SAYS = "sets a volumes, mounts, devices or hooks_dir key, which Podman applies to every container";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One host file's text, as `{ text }`, or `{ missing, error }` when it could not be read. Exported (issue #354) so the
|
|
112
|
+
* rootless podman venue's observations read files by the one rule the rootful ones do.
|
|
113
|
+
*/
|
|
114
|
+
export function readHostFile(fs, path) {
|
|
115
|
+
try {
|
|
116
|
+
return { text: fs.readFileSync(path, "utf8") };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return { missing: error?.code === "ENOENT", error: error?.code ?? "error" };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* FIPS mode as a finding: `{ value: false, evidence }` when it is on or its file cannot be read, `null` when it is off or
|
|
124
|
+
* the kernel has no such file. FIPS mode mounts the host's crypto policy into every Podman container, rootful or not.
|
|
125
|
+
*/
|
|
126
|
+
export function fipsFinding(fs) {
|
|
127
|
+
const fips = readHostFile(fs, FIPS_ENABLED_PATH);
|
|
128
|
+
if (!fips.missing && fips.text === undefined) return unreadFileFinding(FIPS_ENABLED_PATH, fips.error);
|
|
129
|
+
if (String(fips.text ?? "").trim() === "1") return { value: false, evidence: "FIPS mode is on, and Podman then mounts the host's crypto policy into every container" };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether `path` is a directory (following a symlink, as Podman's own walk does), so a directory named `x.conf` in a
|
|
135
|
+
* drop-in directory is skipped the way Podman skips it (gate round 2 of PR #473: it was refused with EISDIR). A stat that
|
|
136
|
+
* fails, or a fake without `isDirectory`, reads as a file, whose own read then says what it is.
|
|
137
|
+
*/
|
|
138
|
+
export function isDirectoryAt(fs, path) {
|
|
139
|
+
try {
|
|
140
|
+
return fs.statSync(path)?.isDirectory?.() === true;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The containers.conf files Podman would read from `files` and every `*.conf` in `dirs` (sorted per directory, missing
|
|
148
|
+
* directories skipped), as `{ files }`, or `{ finding }` when a directory exists and cannot be listed: a drop-in nobody
|
|
149
|
+
* could see must withhold credit, not read as none.
|
|
150
|
+
*/
|
|
151
|
+
export function confFilesIn(fs, { files = [], dirs = [] }) {
|
|
152
|
+
const out = [...files];
|
|
153
|
+
for (const dir of dirs) {
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = fs.readdirSync(dir);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code === "ENOENT") continue;
|
|
159
|
+
return { finding: unreadFileFinding(dir, error?.code ?? "error") };
|
|
160
|
+
}
|
|
161
|
+
for (const entry of [...entries].sort()) if (String(entry).endsWith(".conf") && !isDirectoryAt(fs, `${dir}/${entry}`)) out.push(`${dir}/${entry}`);
|
|
162
|
+
}
|
|
163
|
+
return { files: out };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The first of `files` that sets a key `key` matches (`says` completes the sentence naming it), or holds an escaped key,
|
|
168
|
+
* a non-ASCII character or a multi-line string this check cannot decode (`UNREAD_SPELLING`), or exists and cannot be
|
|
169
|
+
* read, as `{ value: false, evidence }` (`value: null` for a transient read error); `null` when none does. A
|
|
170
|
+
* missing file is simply not read, which is how Podman treats it too. `says` may be a function of the match (issue #428),
|
|
171
|
+
* for a key set whose members each do something different, so the sentence names the one that was found.
|
|
172
|
+
*/
|
|
173
|
+
export function confKeyFinding(fs, files, { key, says }) {
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
const got = readHostFile(fs, file);
|
|
176
|
+
if (got.missing) continue;
|
|
177
|
+
if (got.text === undefined) return unreadFileFinding(file, got.error);
|
|
178
|
+
const match = key.exec(got.text);
|
|
179
|
+
if (match) return { value: false, evidence: `${file} ${typeof says === "function" ? says(match) : says}` };
|
|
180
|
+
if (ESCAPED_KEY.test(got.text)) return { value: false, evidence: `${file} has an escaped key, which this check does not decode`, spelling: "escaped" };
|
|
181
|
+
const unread = unreadSpelling(got.text);
|
|
182
|
+
if (unread) return { value: false, evidence: `${file} line ${unread.line} has ${unread.kind === "non-ascii" ? "a non-ASCII character" : "a multi-line string (\"\"\" or ''')"}, which this check does not decode`, spelling: unread.kind };
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The pattern that finds any of `keys` in a containers.conf, as `MOUNT_KEY` finds its own: any letter case, bare or
|
|
189
|
+
* quoted, dotted or inside an inline table, never on a whole-line comment and never inside a longer key name. The one
|
|
190
|
+
* capture group is the key, so a refusal names the key it found.
|
|
191
|
+
*/
|
|
192
|
+
export function widenKeyPattern(keys) {
|
|
193
|
+
return new RegExp(`^(?!\\s*#).*?(?:^|[\\s.{,"'])["']?(${keys.join("|")})["']?\\s*=`, "im");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* CHAIN-AGNOSTIC (issue #448): the first of `files` that sets one of `keys`, as `{ value: false, key, evidence }` with
|
|
198
|
+
* `says[key]` completing the sentence that names the file, or one this check cannot decode (an escaped key, a non-ASCII
|
|
199
|
+
* character, a multi-line string) as `{ value: false, key: null, evidence, spelling }`, else `null`. A missing file is
|
|
200
|
+
* not read, as Podman does not read it. A file that exists and cannot be read is `{ value, key: null, evidence }` by
|
|
201
|
+
* `unreadFileFinding`'s rule (`null` for a transient code), UNLESS the caller passes an `unread` array: then a
|
|
202
|
+
* determinate code is pushed there as `{ path, code }` and the scan goes on, which is how a caller whose chain includes
|
|
203
|
+
* files it is not expected to read (root's own, for a worker that is not root) names them rather than refusing on them.
|
|
204
|
+
* The rootless podman venue's check (`podmanConfWidening`) calls it with no `unread`, so its answers are what they were.
|
|
205
|
+
* `pattern` replaces the one built from `keys` (the mounts observation passes `MOUNT_KEY`, whose `says` is one string),
|
|
206
|
+
* and `strip` blanks what the caller accepts before the key is looked for (`stripStockBlocks`).
|
|
207
|
+
*/
|
|
208
|
+
export function confWidening(fs, files, { keys, pattern = widenKeyPattern(keys), says, unread = null, nameable = () => true, strip = null }) {
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
const got = readHostFile(fs, file);
|
|
211
|
+
if (got.missing) continue;
|
|
212
|
+
if (got.text === undefined) {
|
|
213
|
+
const finding = unreadFileFinding(file, got.error);
|
|
214
|
+
// Collected only where the caller says a path may go unread (gate round 1 of PR #473: root's own config home, never
|
|
215
|
+
// a `0600` drop-in in /etc, which the service applied while this named it and moved on).
|
|
216
|
+
if (Array.isArray(unread) && finding.value === false && nameable(file)) {
|
|
217
|
+
unread.push({ path: file, code: got.error });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
return { ...finding, key: null };
|
|
221
|
+
}
|
|
222
|
+
// `strip` (issue #448) blanks a block the caller accepts as it ships, line for line, before the key is looked for.
|
|
223
|
+
const text = typeof strip === "function" ? strip(got.text) : got.text;
|
|
224
|
+
const match = pattern.exec(text);
|
|
225
|
+
if (match) {
|
|
226
|
+
const key = match[1]?.toLowerCase() ?? null;
|
|
227
|
+
return { value: false, key, evidence: `${file} ${typeof says === "string" ? says : says[key]}` };
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(keys) && keys.includes("runtimes") && runtimesTableSet(text)) return { value: false, key: "runtimes", evidence: `${file} ${says.runtimes}` };
|
|
230
|
+
if (ESCAPED_KEY.test(got.text)) return { value: false, key: null, evidence: `${file} has an escaped key, which this check does not decode`, spelling: "escaped" };
|
|
231
|
+
const spelled = unreadSpelling(got.text);
|
|
232
|
+
if (spelled) return { value: false, key: null, evidence: `${file} line ${spelled.line} has ${spelled.kind === "non-ascii" ? "a non-ASCII character" : "a multi-line string (\"\"\" or ''')"}, which this check does not decode`, spelling: spelled.kind };
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether a containers.conf sets a runtime in the `[engine.runtimes]` table (issue #448, gate round 1: a wrapper named
|
|
239
|
+
* there ran for every job, rootful and rootless). A TABLE, so the key pattern cannot see it: a key assigned while that
|
|
240
|
+
* table is open, a `[engine.runtimes.<x>]` subtable, or a dotted `runtimes.<x>` or `engine.runtimes.<x>` key. The header
|
|
241
|
+
* alone is not a setting: the stock files of Fedora 44 and Ubuntu 24.04 carry `[engine.runtimes]` with every entry under
|
|
242
|
+
* it commented out, and pass. Any letter case, quotes and spaces inside the header ignored.
|
|
243
|
+
*/
|
|
244
|
+
export function runtimesTableSet(text) {
|
|
245
|
+
let table = "";
|
|
246
|
+
for (const raw of String(text).split("\n")) {
|
|
247
|
+
const line = raw.trim();
|
|
248
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
249
|
+
const header = /^\[\s*([^\[\]]+?)\s*\](?:\s*#.*)?$/.exec(line);
|
|
250
|
+
if (header) {
|
|
251
|
+
table = header[1].replace(/["'\s]/g, "").toLowerCase();
|
|
252
|
+
if (table.startsWith("engine.runtimes.")) return true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (/^\[\[/.test(line)) {
|
|
256
|
+
table = "";
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const key = /^["']?([A-Za-z0-9_.-]+?)["']?\s*(?:\.\s*["']?[A-Za-z0-9_-]+["']?\s*)*=/.exec(line);
|
|
260
|
+
if (!key) continue;
|
|
261
|
+
const dotted = line.slice(0, line.indexOf("=")).replace(/["'\s]/g, "").toLowerCase();
|
|
262
|
+
if (table === "engine.runtimes") return true;
|
|
263
|
+
if (table === "engine" && dotted.startsWith("runtimes.")) return true;
|
|
264
|
+
if (table === "" && dotted.startsWith("engine.runtimes.")) return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** An installed OCI hook (any `*.json` in `PODMAN_HOOKS_DIRS`) as `{ value: false, evidence }`, else `null`. */
|
|
270
|
+
export function hooksFinding(fs, dirs = PODMAN_HOOKS_DIRS) {
|
|
271
|
+
for (const dir of dirs) {
|
|
272
|
+
let entries;
|
|
273
|
+
try {
|
|
274
|
+
entries = fs.readdirSync(dir);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error?.code === "ENOENT") continue;
|
|
277
|
+
return unreadFileFinding(dir, error?.code ?? "error");
|
|
278
|
+
}
|
|
279
|
+
if ([...entries].some((entry) => String(entry).endsWith(".json"))) return { value: false, evidence: `${dir} holds an OCI hook, which can mount into every container` };
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A daemon read that gave no facts, as an observation: `null` (not answered, so a floor retries) for a transient
|
|
286
|
+
* failure, and `false` (answered, so a floor refuses) for a determinate one: a clean `docker info` exit that parses to no
|
|
287
|
+
* known shape (`unparseable`, which the job-user decision likewise treats as determinate), or no docker CLI at all.
|
|
288
|
+
* `undefined` when there are facts to read.
|
|
289
|
+
*/
|
|
290
|
+
function notAnswered(daemon) {
|
|
291
|
+
if (daemon?.answered && daemon.facts) return undefined;
|
|
292
|
+
if (daemon && daemon.answered === false && daemon.transient === false) return { value: false, evidence: `the daemon answered in a shape nothing here reads (${daemon.reason ?? "unparseable"})` };
|
|
293
|
+
// No docker CLI at all is not a daemon still starting: the endpoint read treats it as determinate, and so does this.
|
|
294
|
+
if (daemon?.reason === "docker-not-found") return { value: false, evidence: "no docker CLI was found on PATH" };
|
|
295
|
+
return { value: null, evidence: `the daemon's info was not read (${daemon?.reason ?? "not asked"})` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `daemonAppliesBounds` from a daemon read (`{ answered, facts }` or `{ answered: false, reason }`), as
|
|
300
|
+
* `{ value, evidence }`.
|
|
301
|
+
*/
|
|
302
|
+
export function observeBounds(daemon) {
|
|
303
|
+
const unread = notAnswered(daemon);
|
|
304
|
+
if (unread) return unread;
|
|
305
|
+
const { facts } = daemon;
|
|
306
|
+
if (facts.rootless === true) return { value: false, evidence: "the daemon is rootless, where the bounds it reports need cgroup delegation it does not report" };
|
|
307
|
+
if (facts.podman === true || !facts.bounds) return { value: false, evidence: "the daemon is Podman, whose Docker API reports PidsLimit and MemoryLimit whether or not a container's bounds apply" };
|
|
308
|
+
const missing = [facts.bounds.pids !== true ? "PidsLimit" : null, facts.bounds.memory !== true ? "MemoryLimit" : null].filter(Boolean);
|
|
309
|
+
if (missing.length > 0) return { value: false, evidence: `the daemon reports ${missing.join(" and ")} false` };
|
|
310
|
+
return { value: true, evidence: "the daemon reports PidsLimit and MemoryLimit" };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* `runtimeAddsNoMounts` from a daemon read and this host's files, as `{ value, evidence }`. `fs` is
|
|
315
|
+
* `{ statSync, readFileSync, readdirSync }`; `sameHost` says whether a Podman service's files are this host's (a unix
|
|
316
|
+
* socket on this host, not a remote service).
|
|
317
|
+
*/
|
|
318
|
+
export function observeRuntimeMounts(daemon, { fs, sameHost, unit, env = {}, now = Date.now(), memory }) {
|
|
319
|
+
const notRead = notAnswered(daemon);
|
|
320
|
+
if (notRead) return notRead;
|
|
321
|
+
if (daemon.facts.podman !== true) return { value: true, evidence: "the daemon is not Podman, and Docker adds no mounts from a mounts.conf" };
|
|
322
|
+
// Rootless Podman reads the user's own ~/.config/containers/mounts.conf before these, which a host check does not read.
|
|
323
|
+
if (daemon.facts.rootless === true) return { value: false, evidence: "the daemon is rootless Podman, which reads the user's own mounts.conf first" };
|
|
324
|
+
if (sameHost !== true) return { value: false, evidence: "the daemon is Podman on another machine, whose mounts.conf this host cannot read" };
|
|
325
|
+
// Issue #448: the SAME chain the rootful widening check reads (`rootfulConfChain`: the system files and drop-ins, root's
|
|
326
|
+
// own conf, `--module` files, the unit's and the manager's CONTAINERS_CONF and CONTAINERS_CONF_OVERRIDE), read FIRST
|
|
327
|
+
// and remembered before any answer returns (gate round 2 of PR #473: with no mounts.conf, stock Ubuntu, the memory
|
|
328
|
+
// was never written, so a file deleted under the running service went unseen). `unit` is `systemctl show
|
|
329
|
+
// podman.service` as the caller read it; a caller that read none (`undefined`) judges the files alone.
|
|
330
|
+
const chain = rootfulConfChain({ fs, unit: unit ?? { read: false, reason: "not-asked" }, env, facts: daemon.facts });
|
|
331
|
+
if (unit !== undefined) rememberChain({ fs, unit, chain, memory });
|
|
332
|
+
const fips = fipsFinding(fs);
|
|
333
|
+
if (fips) return fips;
|
|
334
|
+
let size;
|
|
335
|
+
try {
|
|
336
|
+
size = fs.statSync(PODMAN_MOUNTS_CONF).size;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error?.code !== "ENOENT") return unreadFileFinding(PODMAN_MOUNTS_CONF, error?.code ?? "error");
|
|
339
|
+
return { value: false, evidence: `${PODMAN_MOUNTS_CONF} does not exist, so Podman mounts the default list (/run/secrets on Fedora and RHEL)` };
|
|
340
|
+
}
|
|
341
|
+
if (size !== 0) return { value: false, evidence: `${PODMAN_MOUNTS_CONF} is not empty, so Podman mounts what it lists` };
|
|
342
|
+
// The chain under its rule: a part under root's own config home this account cannot read is named in the evidence; any
|
|
343
|
+
// other part that cannot be read, or a module that names no file, withholds the credit (gate round 1: a `0600` drop-in
|
|
344
|
+
// setting `volumes` earned the credit).
|
|
345
|
+
if (chain.transient) return unreadFileFinding(chain.transient.path, chain.transient.code);
|
|
346
|
+
if (chain.unreadable) return unreadFileFinding(chain.unreadable.path, chain.unreadable.code);
|
|
347
|
+
if (chain.unjudgeable) return { value: false, evidence: moduleEvidence(chain.unjudgeable.module) };
|
|
348
|
+
const unread = [...chain.unread];
|
|
349
|
+
const conf = confWidening(fs, chain.files, { pattern: MOUNT_KEY, says: MOUNT_KEY_SAYS, unread, nameable: chain.nameable });
|
|
350
|
+
if (conf) return conf.value === null ? { value: null, evidence: conf.evidence, reason: "file-unread" } : { value: false, evidence: conf.evidence };
|
|
351
|
+
const hooks = hooksFinding(fs);
|
|
352
|
+
if (hooks) return hooks;
|
|
353
|
+
// The running service keeps the containers.conf it started with (measured for #448 with `volumes`: a key removed while
|
|
354
|
+
// it ran was still mounted into the next job, and one added was not, until a restart). mounts.conf is read per
|
|
355
|
+
// container (measured: a line added and removed while it ran reached and left the next job), so it is not watched.
|
|
356
|
+
if (unit !== undefined) {
|
|
357
|
+
const stale = serviceChangedSince({ fs, unit, chain, unread, now, memory });
|
|
358
|
+
if (stale.transient) return unreadFileFinding(stale.transient.path, stale.transient.code);
|
|
359
|
+
if (stale.unreadable) return unreadFileFinding(stale.unreadable.path, stale.unreadable.code);
|
|
360
|
+
const evidence = staleEvidence(stale, "what it mounts may not be what the files say until it restarts");
|
|
361
|
+
if (evidence) return { value: false, evidence };
|
|
362
|
+
}
|
|
363
|
+
const residual = rootfulUnreadList(dedupeUnread(unread));
|
|
364
|
+
return { value: true, evidence: `${PODMAN_MOUNTS_CONF} is empty, no containers.conf sets volumes, mounts, devices or hooks, and no OCI hook is installed${residual ? `; not read, so not judged: ${residual}` : ""}` };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether a Podman service's files are this host's: the docker CLI observed on a local unix socket (the real docker CLI
|
|
369
|
+
* pointed at Podman), or Podman's own shape naming a unix socket (podman-docker, which reports `serviceIsRemote: true`
|
|
370
|
+
* for the local rootful service too, measured, so only the socket's form is read; `parseDaemonFacts` keeps only a unix
|
|
371
|
+
* path). A socket that is really a tunnel to another machine, or `podman machine`'s forwarded socket, reads as this
|
|
372
|
+
* host: the files read are then this host's, where no override exists, so the answer is `false`, never a false credit
|
|
373
|
+
* from files the service does not read, except where an operator created the override on a host that does not run it.
|
|
374
|
+
*/
|
|
375
|
+
export function podmanOnThisHost({ endpoint, facts }) {
|
|
376
|
+
if (endpoint?.local === true && typeof endpoint.endpoint === "string" && endpoint.endpoint.startsWith("unix://")) return true;
|
|
377
|
+
if (facts?.shape === "podman" && typeof facts.remoteSocketPath === "string") return true;
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Every observation `backends.mjs` names, for one endpoint read and one daemon read, as `{ observations, evidence,
|
|
383
|
+
* reasons }`: the map `effectiveWord` and `observationRefusals` take, what each answer rests on, and for an observation
|
|
384
|
+
* that was not answered, the reason token of the read that did not answer. The endpoint's own answer is `null` only for
|
|
385
|
+
* a TRANSIENT failure to ask, as the endpoint read's boot and per-job rule already treats it.
|
|
386
|
+
*/
|
|
387
|
+
export function observeHost({ endpoint, daemon, fs, unit, env = {}, now, memory }) {
|
|
388
|
+
const bounds = observeBounds(daemon);
|
|
389
|
+
const mounts = observeRuntimeMounts(daemon, { fs, sameHost: podmanOnThisHost({ endpoint, facts: daemon?.facts }), unit, env, ...(now !== undefined ? { now } : {}), memory });
|
|
390
|
+
const endpointAnswer = endpoint?.local === true ? true : endpoint?.local === null && endpoint?.transient ? null : false;
|
|
391
|
+
return {
|
|
392
|
+
observations: { [DOCKER_ENDPOINT_LOCAL]: endpointAnswer, [DAEMON_APPLIES_BOUNDS]: bounds.value, [RUNTIME_ADDS_NO_MOUNTS]: mounts.value },
|
|
393
|
+
evidence: { [DAEMON_APPLIES_BOUNDS]: bounds.evidence, [RUNTIME_ADDS_NO_MOUNTS]: mounts.evidence },
|
|
394
|
+
reasons: {
|
|
395
|
+
...(endpointAnswer === null ? { [DOCKER_ENDPOINT_LOCAL]: endpoint?.reason ?? "unknown" } : {}),
|
|
396
|
+
...(bounds.value === null ? { [DAEMON_APPLIES_BOUNDS]: daemon?.reason ?? "not-read" } : {}),
|
|
397
|
+
...(mounts.value === null ? { [RUNTIME_ADDS_NO_MOUNTS]: mounts.reason ?? daemon?.reason ?? "not-read" } : {}),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The two runtime answers as one string, so a caller logs them only when they change. */
|
|
403
|
+
export function runtimeObservationKey(observed) {
|
|
404
|
+
return `${observed.observations[DAEMON_APPLIES_BOUNDS]}|${observed.observations[RUNTIME_ADDS_NO_MOUNTS]}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- issue #448: rootful Podman's containers.conf keys that reach a `local` job ------------------------------------
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The systemd unit that runs rootful Podman's Docker API service behind `/run/podman/podman.sock` (`podman.socket`
|
|
411
|
+
* starts it on the first request, and it exits after a few idle seconds). Read with `systemctl show`, which any account
|
|
412
|
+
* may run: `LoadState` and `ActiveState`, when its process started (`--timestamp=us+utc`, so the answer parses the same
|
|
413
|
+
* in every locale), the `Environment=` and `EnvironmentFile=` it starts Podman with, its `ExecStart` (for `--module`), and
|
|
414
|
+
* the unit files it was built from. Two more reads beside it (gate round 1 of PR #473): the manager's own environment
|
|
415
|
+
* (`systemctl show-environment`, which `DefaultEnvironment=` and `set-environment` fill, and which the service inherits,
|
|
416
|
+
* measured: a `CONTAINERS_CONF_OVERRIDE` there reached jobs) and `podman.socket`'s `Listen=`, so the service is trusted
|
|
417
|
+
* only for the socket it actually serves.
|
|
418
|
+
*/
|
|
419
|
+
export const PODMAN_SERVICE_UNIT = "podman.service";
|
|
420
|
+
export const PODMAN_SOCKET_UNIT = "podman.socket";
|
|
421
|
+
export const PODMAN_SERVICE_SHOW_ARGS = Object.freeze(["show", PODMAN_SERVICE_UNIT, "--timestamp=us+utc", "-p", "LoadState", "-p", "ActiveState", "-p", "ExecMainStartTimestamp", "-p", "Environment", "-p", "EnvironmentFiles", "-p", "FragmentPath", "-p", "DropInPaths", "-p", "ExecStart"]);
|
|
422
|
+
export const PODMAN_MANAGER_ENV_ARGS = Object.freeze(["show-environment"]);
|
|
423
|
+
export const PODMAN_SOCKET_SHOW_ARGS = Object.freeze(["show", PODMAN_SOCKET_UNIT, "-p", "Listen"]);
|
|
424
|
+
export const PODMAN_SERVICE_TIMEOUT_MS = 5000;
|
|
425
|
+
|
|
426
|
+
/** The unit states in which the service's process may be alive, holding the configuration it started with. */
|
|
427
|
+
const SERVICE_RUNNING_STATES = new Set(["active", "reloading", "deactivating", "activating", "refreshing"]);
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The variables of the service's environment that move which containers.conf it reads (issue #448, measured on rootful
|
|
431
|
+
* Podman 5.8.1: `CONTAINERS_CONF` and `CONTAINERS_CONF_OVERRIDE` on podman.service were honoured, and so was root's own
|
|
432
|
+
* `~/.config/containers/containers.conf` with no `HOME` in the unit). `HOME` and `XDG_CONFIG_HOME` move that last one.
|
|
433
|
+
*/
|
|
434
|
+
export const PODMAN_SERVICE_CONF_VARS = Object.freeze(["CONTAINERS_CONF", "CONTAINERS_CONF_OVERRIDE", "HOME", "XDG_CONFIG_HOME"]);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Where rootful Podman resolves a relative `--module <name>` (containers/common `ModuleDirectories`: `/etc` first, then
|
|
438
|
+
* `/usr/share`; the user's own directory only when rootless). A module is one more containers.conf, read after the
|
|
439
|
+
* system chain and before `CONTAINERS_CONF_OVERRIDE` (measured: `--module` in the unit's `LOGGING=` reached jobs).
|
|
440
|
+
*/
|
|
441
|
+
export const PODMAN_MODULE_DIRS = Object.freeze(["/etc/containers/containers.conf.modules", "/usr/share/containers/containers.conf.modules"]);
|
|
442
|
+
|
|
443
|
+
/** `systemctl show`'s space-separated value as words: a `"..."` word may hold spaces and backslash escapes. */
|
|
444
|
+
export function splitUnitWords(value) {
|
|
445
|
+
const words = [];
|
|
446
|
+
const text = String(value ?? "");
|
|
447
|
+
let i = 0;
|
|
448
|
+
while (i < text.length) {
|
|
449
|
+
while (text[i] === " ") i++;
|
|
450
|
+
if (i >= text.length) break;
|
|
451
|
+
let word = "";
|
|
452
|
+
let quoted = false;
|
|
453
|
+
for (; i < text.length; i++) {
|
|
454
|
+
const c = text[i];
|
|
455
|
+
if (c === "\\" && i + 1 < text.length) {
|
|
456
|
+
word += text[++i];
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (c === '"') {
|
|
460
|
+
quoted = !quoted;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (c === " " && !quoted) break;
|
|
464
|
+
word += c;
|
|
465
|
+
}
|
|
466
|
+
words.push(word);
|
|
467
|
+
}
|
|
468
|
+
return words;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Every `--module` a list of words passes (`--module x` and `--module=x`). Read from `ExecStart`'s argv AND from every
|
|
473
|
+
* value of the environment, split on whitespace, since an argv `$LOGGING` expands a variable's words into it (measured:
|
|
474
|
+
* `LOGGING=--log-level=info --module=/x.conf`); a variable no argv names only makes the list longer, never shorter.
|
|
475
|
+
*/
|
|
476
|
+
export function moduleArgsIn(words) {
|
|
477
|
+
const out = [];
|
|
478
|
+
const list = [...words];
|
|
479
|
+
for (let i = 0; i < list.length; i++) {
|
|
480
|
+
const w = String(list[i]);
|
|
481
|
+
if (w === "--module" && i + 1 < list.length) out.push(String(list[++i]));
|
|
482
|
+
else if (w.startsWith("--module=")) out.push(w.slice("--module=".length));
|
|
483
|
+
}
|
|
484
|
+
return out.filter((m) => m !== "");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A `--timestamp=us+utc` value (`Mon 2026-09-28 06:18:14.656913 UTC`) as epoch milliseconds, or `null`. */
|
|
488
|
+
export function parseUnitTimestamp(value) {
|
|
489
|
+
const m = /(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d{1,6}))? UTC$/.exec(String(value ?? "").trim());
|
|
490
|
+
if (!m) return null;
|
|
491
|
+
const [, y, mo, d, h, mi, s, frac = "0"] = m;
|
|
492
|
+
return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) + Number(frac.padEnd(6, "0")) / 1000;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** `NAME=VALUE` assignments as `{ vars, modules }`: the `PODMAN_SERVICE_CONF_VARS` kept, every value's `--module` read. */
|
|
496
|
+
function assignmentsOf(assignments) {
|
|
497
|
+
const vars = {};
|
|
498
|
+
const values = [];
|
|
499
|
+
for (const word of assignments) {
|
|
500
|
+
const eq = word.indexOf("=");
|
|
501
|
+
if (eq <= 0) continue;
|
|
502
|
+
const name = word.slice(0, eq);
|
|
503
|
+
const value = word.slice(eq + 1);
|
|
504
|
+
values.push(...value.split(/\s+/));
|
|
505
|
+
if (PODMAN_SERVICE_CONF_VARS.includes(name)) (vars[name] ??= []).push(value);
|
|
506
|
+
}
|
|
507
|
+
return { vars, modules: moduleArgsIn(values) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* `systemctl show`'s answer for `PODMAN_SERVICE_SHOW_ARGS`, as `{ loaded, running, startedAtMs, environment,
|
|
512
|
+
* environmentFiles, unitPaths, modules }`, or `null` when no line names a load state. `environment` holds only the
|
|
513
|
+
* `PODMAN_SERVICE_CONF_VARS` (every value of each, in order), never the rest, so nothing else the unit carries is ever
|
|
514
|
+
* kept or logged; `modules` is every `--module` its `ExecStart` or its environment passes.
|
|
515
|
+
*/
|
|
516
|
+
export function parsePodmanServiceShow(stdout) {
|
|
517
|
+
const props = {};
|
|
518
|
+
for (const line of String(stdout ?? "").split("\n")) {
|
|
519
|
+
const at = line.indexOf("=");
|
|
520
|
+
if (at <= 0) continue;
|
|
521
|
+
const name = line.slice(0, at);
|
|
522
|
+
(props[name] ??= []).push(line.slice(at + 1));
|
|
523
|
+
}
|
|
524
|
+
if (!props.LoadState) return null;
|
|
525
|
+
const { vars: environment, modules: envModules } = assignmentsOf((props.Environment ?? []).flatMap((v) => splitUnitWords(v)));
|
|
526
|
+
// `ExecStart={ path=... ; argv[]=/usr/bin/podman $LOGGING system service ; ignore_errors=no ; ... }`, one per line.
|
|
527
|
+
const argv = (props.ExecStart ?? []).flatMap((v) => {
|
|
528
|
+
const m = /argv\[\]=(.*?)(?: ;|$)/.exec(v);
|
|
529
|
+
return m ? m[1].split(/\s+/) : [];
|
|
530
|
+
});
|
|
531
|
+
// One `EnvironmentFiles=` line per file, `<path> (ignore_errors=yes|no)`; a path may be a wildcard pattern.
|
|
532
|
+
const environmentFiles = (props.EnvironmentFiles ?? []).map((v) => v.replace(/\s+\(ignore_errors=\w+\)\s*$/, "").trim()).filter((p) => p.startsWith("/"));
|
|
533
|
+
const unitPaths = [...(props.FragmentPath ?? []), ...(props.DropInPaths ?? []).flatMap((v) => splitUnitWords(v))].filter((p) => p.startsWith("/"));
|
|
534
|
+
return {
|
|
535
|
+
loaded: props.LoadState[0] === "loaded",
|
|
536
|
+
running: SERVICE_RUNNING_STATES.has(props.ActiveState?.[0] ?? ""),
|
|
537
|
+
startedAtMs: parseUnitTimestamp(props.ExecMainStartTimestamp?.[0]),
|
|
538
|
+
environment,
|
|
539
|
+
environmentFiles,
|
|
540
|
+
unitPaths,
|
|
541
|
+
modules: [...moduleArgsIn(argv), ...envModules],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* `systemctl show-environment`'s answer as `{ environment, modules }`: one `NAME=VALUE` per line, where a value systemd
|
|
547
|
+
* had to quote is printed `$'...'` (its C escapes undone here only for `\\` and `\'`, which is all a path needs).
|
|
548
|
+
*/
|
|
549
|
+
export function parseShowEnvironment(stdout) {
|
|
550
|
+
const words = String(stdout ?? "")
|
|
551
|
+
.split("\n")
|
|
552
|
+
.filter((l) => l.includes("="))
|
|
553
|
+
.map((l) => {
|
|
554
|
+
const eq = l.indexOf("=");
|
|
555
|
+
let value = l.slice(eq + 1);
|
|
556
|
+
if (value.startsWith("$'") && value.endsWith("'")) value = value.slice(2, -1).replace(/\\(['\\])/g, "$1");
|
|
557
|
+
return `${l.slice(0, eq)}=${value}`;
|
|
558
|
+
});
|
|
559
|
+
const { vars, modules } = assignmentsOf(words);
|
|
560
|
+
return { environment: vars, modules };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** `systemctl show -p Listen podman.socket`'s answer as the unix socket paths it listens on (`<path> (Stream)` each). */
|
|
564
|
+
export function parseSocketListen(stdout) {
|
|
565
|
+
return String(stdout ?? "")
|
|
566
|
+
.split("\n")
|
|
567
|
+
.filter((l) => l.startsWith("Listen="))
|
|
568
|
+
.map((l) => l.slice("Listen=".length).replace(/\s+\(\w+\)\s*$/, "").trim())
|
|
569
|
+
.filter((p) => p.startsWith("/"));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** A reader's failure as a reason token: `systemctl-not-found`, `timeout`, `exit-<n>` or `spawn-failed`. */
|
|
573
|
+
function runFailure(result) {
|
|
574
|
+
const error = result?.error ?? null;
|
|
575
|
+
if (error?.code === "ENOENT") return "systemctl-not-found";
|
|
576
|
+
if (error || result?.code !== 0) return error?.timedOut || error?.killed ? "timeout" : typeof result?.code === "number" ? `exit-${result.code}` : "spawn-failed";
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The reader: `async () => ({ read: true, loaded, running, startedAtMs, environment, environmentFiles, unitPaths,
|
|
582
|
+
* modules, manager, listen })` or `{ read: false, reason }`. `manager` is `{ read: true, environment, modules }` or
|
|
583
|
+
* `{ read: false, reason }`, and `listen` the socket's paths or `{ reason }`: each of the two extra reads fails on its own,
|
|
584
|
+
* as a named residual, never the whole answer. `run(args)` is the seam, returning `{ code, stdout, error }` as the docker
|
|
585
|
+
* readers' does. No `sudo`: every read is one any account may make.
|
|
586
|
+
*/
|
|
587
|
+
export function makePodmanServiceReader({ run = (args) => execDockerBounded(args, { bin: "systemctl", timeoutMs: PODMAN_SERVICE_TIMEOUT_MS }) } = {}) {
|
|
588
|
+
const ask = async (args) => {
|
|
589
|
+
try {
|
|
590
|
+
return await run(args);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
return { code: null, stdout: "", error: err };
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
return async function readPodmanService() {
|
|
596
|
+
const result = await ask(PODMAN_SERVICE_SHOW_ARGS);
|
|
597
|
+
const failed = runFailure(result);
|
|
598
|
+
if (failed) return { read: false, reason: failed };
|
|
599
|
+
const parsed = parsePodmanServiceShow(result.stdout);
|
|
600
|
+
if (!parsed) return { read: false, reason: "unparseable" };
|
|
601
|
+
const env = await ask(PODMAN_MANAGER_ENV_ARGS);
|
|
602
|
+
const envFailed = runFailure(env);
|
|
603
|
+
const sock = await ask(PODMAN_SOCKET_SHOW_ARGS);
|
|
604
|
+
const sockFailed = runFailure(sock);
|
|
605
|
+
return {
|
|
606
|
+
read: true,
|
|
607
|
+
...parsed,
|
|
608
|
+
manager: envFailed ? { read: false, reason: envFailed } : { read: true, ...parseShowEnvironment(env.stdout) },
|
|
609
|
+
listen: sockFailed ? { reason: sockFailed } : parseSocketListen(sock.stdout),
|
|
610
|
+
};
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The values an environment file sets, by systemd's `KEY=VALUE` lines, quotes stripped, as `assignmentsOf` reads them. */
|
|
615
|
+
function envFileAssignments(text) {
|
|
616
|
+
const words = [];
|
|
617
|
+
for (const raw of String(text).split("\n")) {
|
|
618
|
+
const line = raw.trim().replace(/^export\s+/, "");
|
|
619
|
+
if (line === "" || line.startsWith("#") || line.startsWith(";")) continue;
|
|
620
|
+
const eq = line.indexOf("=");
|
|
621
|
+
if (eq <= 0) continue;
|
|
622
|
+
let value = line.slice(eq + 1).trim();
|
|
623
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) value = value.slice(1, -1);
|
|
624
|
+
words.push(`${line.slice(0, eq).trim()}=${value}`);
|
|
625
|
+
}
|
|
626
|
+
return assignmentsOf(words);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Root's home from `/etc/passwd` (uid 0), or `null`: where Podman looks for root's own containers.conf with no HOME set. */
|
|
630
|
+
function rootHomeFrom(fs) {
|
|
631
|
+
const got = readHostFile(fs, "/etc/passwd");
|
|
632
|
+
for (const line of String(got.text ?? "").split("\n")) {
|
|
633
|
+
const f = line.split(":");
|
|
634
|
+
if (f.length >= 7 && f[2] === "0" && f[5].startsWith("/")) return f[5];
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** A shell glob component (`*`, `?`, `[...]`) as an anchored RegExp, the only wildcard systemd's `EnvironmentFile=` takes. */
|
|
640
|
+
function globComponent(part) {
|
|
641
|
+
let re = "";
|
|
642
|
+
for (let i = 0; i < part.length; i++) {
|
|
643
|
+
const c = part[i];
|
|
644
|
+
if (c === "*") re += "[^/]*";
|
|
645
|
+
else if (c === "?") re += "[^/]";
|
|
646
|
+
else if (c === "[") {
|
|
647
|
+
const close = part.indexOf("]", i + 1);
|
|
648
|
+
if (close < 0) re += "\\[";
|
|
649
|
+
else {
|
|
650
|
+
re += `[${part.slice(i + 1, close).replace(/^!/, "^").replace(/\\/g, "\\\\")}]`;
|
|
651
|
+
i = close;
|
|
652
|
+
}
|
|
653
|
+
} else re += c.replace(/[.+^${}()|\\]/g, "\\$&");
|
|
654
|
+
}
|
|
655
|
+
return new RegExp(`^${re}$`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* An `EnvironmentFile=` path with its wildcards expanded against the host, as `{ paths }`, or `{ unreadable: { path,
|
|
660
|
+
* code } }` / `{ transient }` when a directory on the way cannot be listed (gate round 1: systemd takes a wildcard
|
|
661
|
+
* pattern there, which the first reading treated as one literal path and so never read). A pattern with no wildcard is
|
|
662
|
+
* itself. Hidden entries are not matched by `*`, as in a shell.
|
|
663
|
+
*/
|
|
664
|
+
export function expandEnvironmentFilePattern(fs, pattern) {
|
|
665
|
+
if (!/[*?[]/.test(pattern)) return { paths: [pattern] };
|
|
666
|
+
let bases = ["/"];
|
|
667
|
+
for (const part of pattern.split("/").filter((p) => p !== "")) {
|
|
668
|
+
const next = [];
|
|
669
|
+
for (const base of bases) {
|
|
670
|
+
if (!/[*?[]/.test(part)) {
|
|
671
|
+
next.push(`${base === "/" ? "" : base}/${part}`);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
let entries;
|
|
675
|
+
try {
|
|
676
|
+
entries = fs.readdirSync(base);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
const code = error?.code ?? "error";
|
|
679
|
+
if (code === "ENOENT" || code === "ENOTDIR") continue;
|
|
680
|
+
if (TRANSIENT_READ_ERRORS.has(code)) return { transient: { path: base, code } };
|
|
681
|
+
return { unreadable: { path: base, code } };
|
|
682
|
+
}
|
|
683
|
+
const re = globComponent(part);
|
|
684
|
+
for (const e of [...entries].sort()) if (!String(e).startsWith(".") && re.test(String(e))) next.push(`${base === "/" ? "" : base}/${e}`);
|
|
685
|
+
}
|
|
686
|
+
bases = next;
|
|
687
|
+
}
|
|
688
|
+
return { paths: bases };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* EVERY containers.conf rootful Podman's API service may read (issue #448), as `{ files, watched, unread, nameable,
|
|
693
|
+
* envFiles }`, plus at most one stop: `transient: { path, code }` when a read failed for a moment,
|
|
694
|
+
* `unreadable: { path, code }` when a part outside root's own config home exists and cannot be read, or
|
|
695
|
+
* `unjudgeable: { module }`. The chain is containers/common v0.67.0's
|
|
696
|
+
* (`systemConfigs`, measured on rootful Podman 5.8.1 and 4.9.3 by a drop-in in each candidate place):
|
|
697
|
+
* - `PODMAN_CONTAINERS_CONF_FILES` and every `*.conf` in `/etc/containers/containers.conf.d` (NOT
|
|
698
|
+
* `/usr/share/containers/containers.conf.d` nor either `containers.rootful.conf.d`: no Podman measured read them);
|
|
699
|
+
* - ROOT'S OWN `<config home>/containers/containers.conf` and its `containers.conf.d`, measured honoured by the service
|
|
700
|
+
* (with no `HOME` in the unit Podman takes root's home from passwd). The config home is every `XDG_CONFIG_HOME` and
|
|
701
|
+
* every `HOME`/.config the unit, its environment files or the manager set, and root's passwd home (`/root` when
|
|
702
|
+
* passwd names none);
|
|
703
|
+
* - every `--module` the unit passes, resolved as Podman does (`PODMAN_MODULE_DIRS`, or the path when absolute);
|
|
704
|
+
* - every file `CONTAINERS_CONF` and `CONTAINERS_CONF_OVERRIDE` name, from the unit's `Environment=`, each
|
|
705
|
+
* `EnvironmentFile=` (wildcards expanded) and the manager's environment, and, where the docker command is
|
|
706
|
+
* podman-docker (`shape: "podman"`, which may run Podman in the worker's own process), the worker's own. JUDGED IN
|
|
707
|
+
* ADDITION to the system chain, never instead of it: a superset can only refuse more.
|
|
708
|
+
* READ WHAT CAN BE READ, REFUSE WHAT CANNOT, with ONE exception (gate round 1, where a `0600` root drop-in in `/etc`
|
|
709
|
+
* applied `env` to jobs while doctor said ✓): a path under a root config home (`nameable`) that this account cannot read
|
|
710
|
+
* is named and not judged, since root's home is `0550` or `0700` on every stock host and a worker that is not root reads
|
|
711
|
+
* none of it; any other part of the chain that exists and cannot be read is `unreadable`, which refuses (and withholds
|
|
712
|
+
* `mountSet`), as the native venue's chain always has.
|
|
713
|
+
* A drop-in directory's `*.conf` that is itself a directory is skipped, as Podman skips it; a `--module` that names no
|
|
714
|
+
* file is `unjudgeable` (`{ module }`) and refuses, since systemctl's unquoted argv cannot say where a path with a
|
|
715
|
+
* space ends. The first such stop is returned BESIDE `files` and `watched`, never instead of them, so the deletion memory
|
|
716
|
+
* still records every existing file before the refusal (gate round 2 of PR #473).
|
|
717
|
+
* `files` is every chain file judged, and `watched` what the running service may have
|
|
718
|
+
* read: every existing chain file, every drop-in directory that exists (a drop-in added, removed or renamed changes it),
|
|
719
|
+
* every module and environment file, and the unit's own files. NOT a chain file's parent directory: unrelated files
|
|
720
|
+
* live there (`sed -i registries.conf` changed `/etc/containers` and refused jobs, gate round 1), and a chain file's own
|
|
721
|
+
* change time already moves when it is replaced by a rename.
|
|
722
|
+
*/
|
|
723
|
+
export function rootfulConfChain({ fs, unit, env = {}, facts = {} }) {
|
|
724
|
+
const unread = [];
|
|
725
|
+
const unitRead = unit?.read === true;
|
|
726
|
+
const values = Object.fromEntries(PODMAN_SERVICE_CONF_VARS.map((name) => [name, [...(unitRead ? (unit.environment?.[name] ?? []) : [])]]));
|
|
727
|
+
const modules = [...(unitRead ? (unit.modules ?? []) : [])];
|
|
728
|
+
const managerRead = unitRead && unit.manager?.read === true;
|
|
729
|
+
if (managerRead) {
|
|
730
|
+
for (const name of PODMAN_SERVICE_CONF_VARS) values[name].push(...(unit.manager.environment?.[name] ?? []));
|
|
731
|
+
modules.push(...(unit.manager.modules ?? []));
|
|
732
|
+
}
|
|
733
|
+
if (facts?.shape === "podman") {
|
|
734
|
+
for (const name of PODMAN_SERVICE_CONF_VARS) if (typeof env?.[name] === "string" && env[name] !== "") values[name].push(env[name]);
|
|
735
|
+
}
|
|
736
|
+
// The config homes first, so `nameable` is known before anything is read. An environment file cannot move them: it is
|
|
737
|
+
// read below, and a HOME it sets is judged too (added after), so a file it names is read, never merely named.
|
|
738
|
+
const homesOf = () => new Set([rootHomeFrom(fs) ?? "/root", ...values.HOME.filter((h) => h.startsWith("/"))]);
|
|
739
|
+
const configHomesOf = () => [...new Set([...values.XDG_CONFIG_HOME.filter((x) => x.startsWith("/")), ...[...homesOf()].map((h) => `${h}/.config`)])];
|
|
740
|
+
let homesNow = configHomesOf();
|
|
741
|
+
const nameable = (path) => homesNow.some((c) => path === c || path.startsWith(`${c}/`));
|
|
742
|
+
// The FIRST part that stops the judgement (`transient`, `unreadable`, `unjudgeable`) is kept and the walk goes on, so
|
|
743
|
+
// the caller still learns every chain file that exists: the deletion memory records them before any refusal returns
|
|
744
|
+
// (gate round 2 of PR #473: a file only ever refused for a key was never remembered, so deleting it under the running
|
|
745
|
+
// service let the next job through while the service still applied the key, measured on Ubuntu).
|
|
746
|
+
let stop = null;
|
|
747
|
+
const cannotRead = (path, code) => {
|
|
748
|
+
if (TRANSIENT_READ_ERRORS.has(code)) stop ??= { transient: { path, code } };
|
|
749
|
+
else if (nameable(path)) unread.push({ path, code });
|
|
750
|
+
else stop ??= { unreadable: { path, code } };
|
|
751
|
+
};
|
|
752
|
+
const envFiles = [];
|
|
753
|
+
for (const pattern of unitRead ? (unit.environmentFiles ?? []) : []) {
|
|
754
|
+
const expanded = expandEnvironmentFilePattern(fs, pattern);
|
|
755
|
+
if (expanded.transient) {
|
|
756
|
+
stop ??= { transient: expanded.transient };
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (expanded.unreadable) {
|
|
760
|
+
cannotRead(expanded.unreadable.path, expanded.unreadable.code);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
envFiles.push(...expanded.paths);
|
|
764
|
+
}
|
|
765
|
+
for (const file of envFiles) {
|
|
766
|
+
const got = readHostFile(fs, file);
|
|
767
|
+
if (got.missing) continue;
|
|
768
|
+
if (got.text === undefined) {
|
|
769
|
+
cannotRead(file, got.error);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const { vars, modules: m } = envFileAssignments(got.text);
|
|
773
|
+
for (const [name, vs] of Object.entries(vars)) values[name].push(...vs);
|
|
774
|
+
modules.push(...m);
|
|
775
|
+
}
|
|
776
|
+
const absolute = (v) => (v.startsWith("/") ? v : `/${v}`);
|
|
777
|
+
const configHomes = configHomesOf();
|
|
778
|
+
homesNow = configHomes;
|
|
779
|
+
const named = [...values.CONTAINERS_CONF, ...values.CONTAINERS_CONF_OVERRIDE].filter((v) => v !== "").map(absolute);
|
|
780
|
+
const moduleFiles = modules.flatMap((m) => (m.startsWith("/") ? [m] : PODMAN_MODULE_DIRS.map((d) => `${d}/${m}`)));
|
|
781
|
+
// A `--module` that names no file is UNJUDGEABLE, and refuses (gate round 2 of PR #473): systemctl prints an argv
|
|
782
|
+
// unquoted (`--module /etc/a b.conf` is two words or one, it cannot say), and a variable's words split the way
|
|
783
|
+
// `$LOGGING` splits them, not the way `${LOGGING}` would, so a module path holding a space is read cut short. A path
|
|
784
|
+
// that exists is what Podman read; one that does not is either such a cut or a module Podman itself fails to load,
|
|
785
|
+
// and neither is guessed at. A stat that fails for another reason is left to the read below.
|
|
786
|
+
for (const m of modules) {
|
|
787
|
+
const candidates = m.startsWith("/") ? [m] : PODMAN_MODULE_DIRS.map((d) => `${d}/${m}`);
|
|
788
|
+
const missing = candidates.every((c) => {
|
|
789
|
+
try {
|
|
790
|
+
fs.statSync(c);
|
|
791
|
+
return false;
|
|
792
|
+
} catch (error) {
|
|
793
|
+
return error?.code === "ENOENT";
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
if (missing) stop ??= { unjudgeable: { module: m } };
|
|
797
|
+
}
|
|
798
|
+
const files = [...new Set([...PODMAN_CONTAINERS_CONF_FILES, ...configHomes.map((c) => `${c}/containers/containers.conf`), ...moduleFiles, ...named])];
|
|
799
|
+
const dirs = [...PODMAN_CONTAINERS_CONF_DIRS, ...configHomes.map((c) => `${c}/containers/containers.conf.d`)];
|
|
800
|
+
const dropIns = [];
|
|
801
|
+
const existingDirs = [];
|
|
802
|
+
for (const dir of dirs) {
|
|
803
|
+
let entries;
|
|
804
|
+
try {
|
|
805
|
+
entries = fs.readdirSync(dir);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const code = error?.code ?? "error";
|
|
808
|
+
if (code === "ENOENT") continue;
|
|
809
|
+
cannotRead(dir, code);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
existingDirs.push(dir);
|
|
813
|
+
for (const entry of [...entries].sort()) if (String(entry).endsWith(".conf") && !isDirectoryAt(fs, `${dir}/${entry}`)) dropIns.push(`${dir}/${entry}`);
|
|
814
|
+
}
|
|
815
|
+
const judged = [...files, ...dropIns];
|
|
816
|
+
const watched = [...new Set([...judged, ...existingDirs, ...(unitRead ? (unit.unitPaths ?? []) : []), ...envFiles])];
|
|
817
|
+
return { files: judged, watched, unread, nameable, envFiles, ...(stop ?? {}) };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The vendor's own blocks both venues accept as they ship (issue #448): the stock containers.conf of Fedora 44
|
|
822
|
+
* (containers-common 0.67.0) and Ubuntu 24.04 (golang-github-containers-common) both set, uncommented,
|
|
823
|
+
* `default_sysctls = ["net.ipv4.ping_group_range=0 0"]`, which was measured reaching a job (its ping_group_range read
|
|
824
|
+
* `0 0`, and `1 0` once a drop-in replaced it). Refusing the key on presence would refuse every stock host, which is
|
|
825
|
+
* worse, and the exception is exact: that block, at the start of a line, with only whitespace and an optional trailing
|
|
826
|
+
* comma around its one string, is blanked before the scan; any other value, spelling, or a second sysctl beside it is
|
|
827
|
+
* still refused.
|
|
828
|
+
*/
|
|
829
|
+
export const STOCK_CONF_BLOCKS = Object.freeze([/^[ \t]*default_sysctls[ \t]*=[ \t]*\[\s*"net\.ipv4\.ping_group_range=0 0"\s*,?\s*\][ \t]*(?:#[^\n]*)?$/gm]);
|
|
830
|
+
|
|
831
|
+
/** `text` with every `STOCK_CONF_BLOCKS` match blanked, its newlines kept so line numbers stay true. */
|
|
832
|
+
export function stripStockBlocks(text) {
|
|
833
|
+
return STOCK_CONF_BLOCKS.reduce((t, block) => t.replace(block, (m) => m.replace(/[^\n]/g, " ")), String(text));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** What each key the rootful check refuses does to a local job, completing the sentence that names the file it is in. */
|
|
837
|
+
export const ROOTFUL_WIDENING_KEY_SAYS = Object.freeze({
|
|
838
|
+
annotations: "sets annotations, which rootful Podman adds to every container, where run.oci.keep_original_groups=1 gives a local job the supplementary groups of the Podman service that starts it (root's, and any SupplementaryGroups= on podman.service)",
|
|
839
|
+
env: "sets env, which under [containers] adds variables to every local job past the worker's own closed environment, and under [engine] is the Podman service's own environment",
|
|
840
|
+
helper_binaries_dir: "sets helper_binaries_dir, which is where rootful Podman finds the netavark and aardvark-dns it runs as root to set up every local job's network, so another program can stand in for them",
|
|
841
|
+
default_sysctls: "sets default_sysctls, which rootful Podman sets in every local job (any value but the vendor's own ping_group_range block)",
|
|
842
|
+
default_ulimits: "sets default_ulimits, which rootful Podman sets on every local job's processes",
|
|
843
|
+
userns: "sets userns, which puts every local job in a user namespace the worker's argv does not name (with auto and no subordinate range, the job cannot even be created)",
|
|
844
|
+
pidns: "sets pidns, which asks for a PID namespace the worker's argv does not name (the host's was refused at create against the job's --init)",
|
|
845
|
+
ipcns: "sets ipcns, which asks for an IPC namespace the worker's argv does not name (the host's was refused at create against the job's --shm-size)",
|
|
846
|
+
utsns: "sets utsns, which gives every local job the UTS namespace it names, the host's hostname with host",
|
|
847
|
+
cgroupns: "sets cgroupns, which gives every local job the cgroup namespace it names, the host's with host",
|
|
848
|
+
netns: "sets netns, which gives a local job with no network of its own the network namespace it names, the host's with host",
|
|
849
|
+
seccomp_profile: "sets seccomp_profile, which replaces the seccomp filter of every local job",
|
|
850
|
+
apparmor_profile: "sets apparmor_profile, which replaces every local job's AppArmor profile where AppArmor runs (unconfined removed the containers-default profile, measured)",
|
|
851
|
+
init_path: "sets init_path, which names the binary that runs as every local job's PID 1",
|
|
852
|
+
dns_servers: "sets dns_servers, which writes the nameservers of a local job with no network of its own",
|
|
853
|
+
dns_options: "sets dns_options, which writes every local job's resolver options",
|
|
854
|
+
dns_searches: "sets dns_searches, which writes every local job's resolver search list",
|
|
855
|
+
base_hosts_file: "sets base_hosts_file, which names the file every local job's /etc/hosts starts from",
|
|
856
|
+
label: "sets label, which with false runs every local job unconfined by SELinux (spc_t, measured)",
|
|
857
|
+
cgroup_conf: "sets cgroup_conf, which writes cgroup files of every local job past its own bounds (pids.max=max outlasted --pids-limit, measured)",
|
|
858
|
+
host_containers_internal_ip: "sets host_containers_internal_ip, which names the address every local job reaches as host.containers.internal",
|
|
859
|
+
runtimes: "sets runtimes, which as the [engine.runtimes] table names the OCI runtime binary that creates every local job (a wrapper there ran for every job, measured)",
|
|
860
|
+
conmon_path: "sets conmon_path, which names the conmon that monitors every local job (a wrapper there ran for every job, measured)",
|
|
861
|
+
cgroups: "sets cgroups, which with disabled runs every local job outside its cgroup with its pids and memory bounds unapplied (measured)",
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Whether a local job runs on rootful Podman's Docker API service ON THIS HOST (issue #448): the daemon answered as
|
|
866
|
+
* Podman, not rootless (an unknown answer counts, so the check fails closed), with its files this host's
|
|
867
|
+
* (`podmanOnThisHost`). A remote endpoint, Docker, and rootless Podman are not this check's, and get exactly what they got.
|
|
868
|
+
*/
|
|
869
|
+
export function rootfulPodmanHere({ endpoint, daemon }) {
|
|
870
|
+
return daemon?.answered === true && daemon.facts?.podman === true && daemon.facts.rootless !== true && podmanOnThisHost({ endpoint, facts: daemon.facts });
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** The unix socket path this endpoint reaches: the docker endpoint's own, else podman-docker's reported socket. */
|
|
874
|
+
export function endpointSocketPath({ endpoint, facts }) {
|
|
875
|
+
if (endpoint?.local === true && typeof endpoint.endpoint === "string" && endpoint.endpoint.startsWith("unix://")) return endpoint.endpoint.slice("unix://".length);
|
|
876
|
+
if (facts?.shape === "podman" && typeof facts.remoteSocketPath === "string") return facts.remoteSocketPath.replace(/^unix:\/\//, "");
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* The per-worker memory the deletion rule reads (`{ startedAtMs, files, deleted }`, mutated in place): which chain files
|
|
882
|
+
* existed while the same service start ran. A file that existed then and is gone now was DELETED after the running
|
|
883
|
+
* service read it, which no change time shows (the file has none, and its directory's also moves for unrelated files).
|
|
884
|
+
* Sticky until the service's start changes, so the widening check and the mounts observation, which both ask, agree.
|
|
885
|
+
*/
|
|
886
|
+
export function makeRootfulMemory() {
|
|
887
|
+
return { startedAtMs: null, files: new Set(), deleted: null };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* `fs` answering each `statSync`, `readFileSync` and `readdirSync` call ONCE per distinct arguments, a thrown error
|
|
892
|
+
* included, for one job's (or one boot's) two checks, the widening check and the mounts observation, which read the same
|
|
893
|
+
* chain (gate round 3 of PR #473: with 1000 drop-ins the two cost 44 ms a job, 8025 stats and 2011 reads, each path
|
|
894
|
+
* stat-ed several times over). One per job, never kept past it: a file must be read afresh by the next job.
|
|
895
|
+
*/
|
|
896
|
+
export function onceFs(fs) {
|
|
897
|
+
const memo = (fn) => {
|
|
898
|
+
const seen = new Map();
|
|
899
|
+
return (...args) => {
|
|
900
|
+
const key = JSON.stringify(args);
|
|
901
|
+
if (!seen.has(key)) {
|
|
902
|
+
try {
|
|
903
|
+
seen.set(key, { value: fn(...args) });
|
|
904
|
+
} catch (error) {
|
|
905
|
+
seen.set(key, { error });
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const got = seen.get(key);
|
|
909
|
+
if ("error" in got) throw got.error;
|
|
910
|
+
return got.value;
|
|
911
|
+
};
|
|
912
|
+
};
|
|
913
|
+
return { statSync: memo((...a) => fs.statSync(...a)), readFileSync: memo((...a) => fs.readFileSync(...a)), readdirSync: memo((...a) => fs.readdirSync(...a)) };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Whether `path` exists (a stat that fails other than ENOENT counts as existing: it is there, only not stat-able). */
|
|
917
|
+
function existsAt(fs, path) {
|
|
918
|
+
try {
|
|
919
|
+
fs.statSync(path);
|
|
920
|
+
return true;
|
|
921
|
+
} catch (error) {
|
|
922
|
+
return error?.code !== "ENOENT";
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Records in `memory` every path the running service may have read that exists now (`chain.watched`: the chain files,
|
|
928
|
+
* the drop-in directories, the environment and unit files, and the part that stopped the walk), while podman.service
|
|
929
|
+
* runs from one parseable start, and starts afresh when that start changes (a stopped service is reset by
|
|
930
|
+
* `serviceChangedSince`, and a new start always has a new time). Called FIRST by both callers, before any refusal returns
|
|
931
|
+
* (gate round 2 of PR #473): a file refused for a key, unreadable, or seen only by an observation that returned early
|
|
932
|
+
* (stock Ubuntu has no mounts.conf) is still remembered, so deleting it under the running service holds the next job.
|
|
933
|
+
* An environment file is remembered too: deleted, the service still has what it set, while the chain no longer names
|
|
934
|
+
* the file it pointed at.
|
|
935
|
+
*/
|
|
936
|
+
export function rememberChain({ fs, unit, chain, memory }) {
|
|
937
|
+
if (!memory || !unit?.read || !unit.loaded || !unit.running || typeof unit.startedAtMs !== "number") return;
|
|
938
|
+
if (memory.startedAtMs !== unit.startedAtMs) Object.assign(memory, { startedAtMs: unit.startedAtMs, files: new Set(), deleted: null });
|
|
939
|
+
const stopped = chain.unreadable?.path ?? chain.transient?.path;
|
|
940
|
+
for (const f of [...(chain.watched ?? []), ...(stopped ? [stopped] : [])]) if (existsAt(fs, f)) memory.files.add(f);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* THE RUNNING SERVICE (ledger L20), one rule for the widening check and the mounts observation, as `{ path }` (a watched
|
|
945
|
+
* path changed after the service started), `{ deleted }` (a chain file this worker saw during this service start is
|
|
946
|
+
* gone), `{ skew }` (a change time later than now), `{ transient: { path, code } }`, `{ unreadable: { path, code } }`,
|
|
947
|
+
* or `{}`. Judged by CHANGE TIME (`ctimeMs`), which the kernel sets and no `cp -p` or `touch -d` can set back (gate round
|
|
948
|
+
* 1: a key removed by a copy that kept an old mtime, and a future mtime that refused forever, restarts included). A
|
|
949
|
+
* change time after `now` is the host clock's problem, said as such, never "restart". What cannot be judged about the
|
|
950
|
+
* service is named: systemctl that did not answer, a unit not loaded, a running one whose start did not parse.
|
|
951
|
+
*/
|
|
952
|
+
function serviceChangedSince({ fs, unit, chain, unread, now, memory }) {
|
|
953
|
+
if (!unit?.read) {
|
|
954
|
+
unread.push({ path: PODMAN_SERVICE_UNIT, code: unit?.reason ?? "not-read" });
|
|
955
|
+
return {};
|
|
956
|
+
}
|
|
957
|
+
if (!unit.loaded) {
|
|
958
|
+
unread.push({ path: PODMAN_SERVICE_UNIT, code: "not-loaded" });
|
|
959
|
+
return {};
|
|
960
|
+
}
|
|
961
|
+
if (!unit.running) {
|
|
962
|
+
if (memory) Object.assign(memory, makeRootfulMemory());
|
|
963
|
+
return {};
|
|
964
|
+
}
|
|
965
|
+
if (typeof unit.startedAtMs !== "number") {
|
|
966
|
+
unread.push({ path: PODMAN_SERVICE_UNIT, code: "start-time-unread" });
|
|
967
|
+
return {};
|
|
968
|
+
}
|
|
969
|
+
const existing = new Set();
|
|
970
|
+
let found = {};
|
|
971
|
+
for (const path of chain.watched) {
|
|
972
|
+
let st;
|
|
973
|
+
try {
|
|
974
|
+
st = fs.statSync(path);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
const code = error?.code ?? "error";
|
|
977
|
+
if (code === "ENOENT") continue;
|
|
978
|
+
if (TRANSIENT_READ_ERRORS.has(code)) return { transient: { path, code } };
|
|
979
|
+
if (chain.nameable(path)) {
|
|
980
|
+
unread.push({ path, code });
|
|
981
|
+
existing.add(path);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
return { unreadable: { path, code } };
|
|
985
|
+
}
|
|
986
|
+
existing.add(path);
|
|
987
|
+
const ctimeMs = st?.ctimeMs;
|
|
988
|
+
if (typeof ctimeMs !== "number" || found.path || found.skew) continue;
|
|
989
|
+
if (ctimeMs > now) found = { skew: path };
|
|
990
|
+
else if (ctimeMs > unit.startedAtMs) found = { path };
|
|
991
|
+
}
|
|
992
|
+
if (memory) {
|
|
993
|
+
rememberChain({ fs, unit, chain, memory });
|
|
994
|
+
// Deleted means gone from disk, not merely off the chain: a file the chain stopped naming because its environment
|
|
995
|
+
// file went is caught by that file's own deletion (remembered above), and one still on disk is not a deletion.
|
|
996
|
+
if (!memory.deleted) memory.deleted = [...memory.files].find((f) => !existing.has(f) && !existsAt(fs, f)) ?? null;
|
|
997
|
+
if (!found.path && !found.skew && memory.deleted) return { deleted: memory.deleted };
|
|
998
|
+
}
|
|
999
|
+
return found;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** The evidence for a `--module` that names no file (`rootfulConfChain`'s `unjudgeable`). */
|
|
1003
|
+
function moduleEvidence(module) {
|
|
1004
|
+
return `${PODMAN_SERVICE_UNIT} passes --module ${module}, which names no file here, so what it loads cannot be judged (systemctl prints the service's arguments unquoted, so a module path holding a space is read cut short)`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** The evidence of a `serviceChangedSince` answer that holds jobs back until the service restarts, or `null`. */
|
|
1008
|
+
function staleEvidence(stale, what) {
|
|
1009
|
+
if (stale.skew) return `${stale.skew} has a change time later than this host's clock, so whether the running ${PODMAN_SERVICE_UNIT} read it cannot be told`;
|
|
1010
|
+
if (stale.deleted) return `${stale.deleted} was removed after the running ${PODMAN_SERVICE_UNIT} started, and a running Podman service keeps the containers.conf it started with, so ${what}`;
|
|
1011
|
+
if (stale.path) return `${stale.path} changed after the running ${PODMAN_SERVICE_UNIT} started, and a running Podman service keeps the containers.conf it started with, so ${what}`;
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The rootful chain judged (issue #448), as `{ refusal, unread }`. `refusal` is a `podman-conf-widens-job` finding with
|
|
1017
|
+
* `rootful: true`, or `null`:
|
|
1018
|
+
* - a file in `rootfulConfChain` that sets `PODMAN_ROOTFUL_WIDENING_KEYS` (`key`), or one this check cannot decode
|
|
1019
|
+
* (`key: null`, `spelling`), or a part outside root's config home that exists and cannot be read (`key: null`), by
|
|
1020
|
+
* `confWidening`, the scan the rootless check uses;
|
|
1021
|
+
* - `transient: true` when a read failed for a moment;
|
|
1022
|
+
* - `restart: true` (THE RUNNING SERVICE, ledger L20): podman.service is running and a path it may have read changed
|
|
1023
|
+
* after it started, or a chain file this worker saw during this start is gone; `skew: true` when a change time is
|
|
1024
|
+
* later than now. Measured: a running service does not read its containers.conf again. These three are RETRIES,
|
|
1025
|
+
* never a final refusal (gate round 1): each heals by itself, the first two once the service restarts or idles
|
|
1026
|
+
* out, the last once the clock passes, so the caller throws for the queue's retry and a boot exits 1.
|
|
1027
|
+
* `unread` is every part not judged, NAMED, never refused on: a path under a root config home this account cannot read,
|
|
1028
|
+
* and podman.service or the manager's environment when `systemctl` did not answer for it, or when the worker's socket is
|
|
1029
|
+
* not the one `podman.socket` listens on (then the service behind it is not podman.service, and its environment and
|
|
1030
|
+
* start are another process's, root's to read: the files are still judged).
|
|
1031
|
+
*/
|
|
1032
|
+
export function rootfulConfWidening({ fs, unit, env = {}, facts = {}, now = Date.now(), memory }) {
|
|
1033
|
+
const chain = rootfulConfChain({ fs, unit, env, facts });
|
|
1034
|
+
rememberChain({ fs, unit, chain, memory });
|
|
1035
|
+
const retry = (evidence, extra) => ({ refusal: { cause: PODMAN_CONF_WIDENS_JOB, key: null, rootful: true, ...extra, evidence }, unread: [] });
|
|
1036
|
+
if (chain.transient) return retry(`${chain.transient.path} could not be read (${chain.transient.code})`, { transient: true });
|
|
1037
|
+
if (chain.unreadable) return { refusal: { cause: PODMAN_CONF_WIDENS_JOB, key: null, rootful: true, evidence: `${chain.unreadable.path} could not be read (${chain.unreadable.code})` }, unread: [] };
|
|
1038
|
+
if (chain.unjudgeable) return { refusal: { cause: PODMAN_CONF_WIDENS_JOB, key: null, rootful: true, module: chain.unjudgeable.module, evidence: moduleEvidence(chain.unjudgeable.module) }, unread: [] };
|
|
1039
|
+
const unread = [...chain.unread];
|
|
1040
|
+
if (unit?.read && unit.manager && unit.manager.read !== true) unread.push({ path: "the systemd manager environment", code: unit.manager.reason ?? "not-read" });
|
|
1041
|
+
const done = (refusal) => ({ refusal, unread: dedupeUnread(unread) });
|
|
1042
|
+
const found = confWidening(fs, chain.files, { keys: PODMAN_ROOTFUL_WIDENING_KEYS, says: ROOTFUL_WIDENING_KEY_SAYS, unread, nameable: chain.nameable, strip: stripStockBlocks });
|
|
1043
|
+
if (found?.value === null) return retry(found.evidence, { transient: true });
|
|
1044
|
+
if (found) return done({ cause: PODMAN_CONF_WIDENS_JOB, key: found.key, rootful: true, evidence: found.evidence, ...(found.spelling ? { spelling: found.spelling } : {}) });
|
|
1045
|
+
const stale = serviceChangedSince({ fs, unit, chain, unread, now, memory });
|
|
1046
|
+
if (stale.transient) return retry(`${stale.transient.path} could not be read (${stale.transient.code})`, { transient: true });
|
|
1047
|
+
if (stale.unreadable) return done({ cause: PODMAN_CONF_WIDENS_JOB, key: null, rootful: true, evidence: `${stale.unreadable.path} could not be read (${stale.unreadable.code})` });
|
|
1048
|
+
const evidence = staleEvidence(stale, "a key removed since may still reach every local job");
|
|
1049
|
+
if (evidence) return done({ cause: PODMAN_CONF_WIDENS_JOB, key: null, rootful: true, ...(stale.skew ? { skew: true } : { restart: true }), evidence });
|
|
1050
|
+
return done(null);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** `unread` with each path once, sorted, so a list said twice reads the same. */
|
|
1054
|
+
function dedupeUnread(unread) {
|
|
1055
|
+
const seen = new Set();
|
|
1056
|
+
return unread.filter((u) => (seen.has(u.path) ? false : (seen.add(u.path), true))).sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* `rootfulConfWidening` for one local job's endpoint and daemon read, or `null` where it does not apply
|
|
1061
|
+
* (`rootfulPodmanHere`): so a Docker host, a remote endpoint and rootless Podman read no file and spawn nothing.
|
|
1062
|
+
* `readService` is `makePodmanServiceReader()`'s reader; `memory` the caller's `makeRootfulMemory()`.
|
|
1063
|
+
*/
|
|
1064
|
+
export async function observeRootfulConf({ endpoint, daemon, fs, readService, env = {}, unit, now, memory }) {
|
|
1065
|
+
if (!rootfulPodmanHere({ endpoint, daemon })) return null;
|
|
1066
|
+
return rootfulConfWidening({ fs, unit: unit ?? (await readRootfulService({ endpoint, daemon, readService })), env, facts: daemon.facts, ...(now !== undefined ? { now } : {}), memory });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** `path` with every symlink resolved (`fs.realpathSync`), or `path` itself when it cannot be (it does not exist here). */
|
|
1070
|
+
export function realpathOrSelf(path) {
|
|
1071
|
+
try {
|
|
1072
|
+
return realpathSync(path);
|
|
1073
|
+
} catch {
|
|
1074
|
+
return path;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* `systemctl show podman.service` where `rootfulPodmanHere` holds, else `undefined` (nothing spawned): read ONCE per boot
|
|
1080
|
+
* or job and handed to both `observeHost` (the mounts observation) and `observeRootfulConf`, so the two judge one answer.
|
|
1081
|
+
* TRUSTED ONLY FOR ITS OWN SOCKET (gate round 1: a second rootful API service on another socket, with its own
|
|
1082
|
+
* `CONTAINERS_CONF_OVERRIDE`, reached jobs while this check read podman.service's clean environment): when the worker's
|
|
1083
|
+
* socket is not one `podman.socket` listens on, or that could not be read, the unit is not this service's, and is
|
|
1084
|
+
* answered `{ read: false, reason }`, a named residual. Named rather than refused: that other service's environment,
|
|
1085
|
+
* modules and start are its own process's, which only root can read, the same class as root's own config home; the
|
|
1086
|
+
* files it shares with every rootful Podman on the host are still judged, and doctor says which socket was not trusted.
|
|
1087
|
+
* A reader that throws is `{ read: false, reason: "spawn-failed" }`.
|
|
1088
|
+
*/
|
|
1089
|
+
export async function readRootfulService({ endpoint, daemon, readService, realpath = realpathOrSelf }) {
|
|
1090
|
+
if (!rootfulPodmanHere({ endpoint, daemon })) return undefined;
|
|
1091
|
+
let unit;
|
|
1092
|
+
try {
|
|
1093
|
+
unit = await readService();
|
|
1094
|
+
} catch {
|
|
1095
|
+
return { read: false, reason: "spawn-failed" };
|
|
1096
|
+
}
|
|
1097
|
+
if (!unit?.read) return unit;
|
|
1098
|
+
const socket = endpointSocketPath({ endpoint, facts: daemon.facts });
|
|
1099
|
+
// Both sides RESOLVED before they are compared (gate round 2 of PR #473): `/var/run/podman/podman.sock` is
|
|
1100
|
+
// `/run/podman/podman.sock` through the `/var/run` symlink, and the string compare named podman.service untrusted.
|
|
1101
|
+
const resolved = new Set((Array.isArray(unit.listen) ? unit.listen : []).map((p) => realpath(p)));
|
|
1102
|
+
if (!Array.isArray(unit.listen)) return { read: false, reason: `${PODMAN_SOCKET_UNIT} not read (${unit.listen?.reason ?? "not-read"}), so the service behind ${socket ?? "this socket"} is not known to be ${PODMAN_SERVICE_UNIT}` };
|
|
1103
|
+
if (socket === null || !resolved.has(realpath(socket))) return { read: false, reason: `the worker's socket ${socket ?? "(none)"} is not the one ${PODMAN_SOCKET_UNIT} listens on (${unit.listen.join(", ") || "none"}), so the service behind it is not ${PODMAN_SERVICE_UNIT}` };
|
|
1104
|
+
return unit;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** The unread parts of a rootful chain, as one clause: `/root/.config/containers/containers.conf (EACCES), ...`. */
|
|
1108
|
+
export function rootfulUnreadList(unread) {
|
|
1109
|
+
return (unread ?? []).map((u) => `${u.path} (${u.code})`).join(", ");
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** The residual said beside a rootful answer with parts unread (issue #448): doctor's line and the worker's log. */
|
|
1113
|
+
export function rootfulConfResidual(unread) {
|
|
1114
|
+
return `rootful Podman's service may also read ${rootfulUnreadList(unread)}, which this account cannot read, so a key set there is not judged: run the worker as an account that can read them, or check them yourself as root for any key the local venue refuses (docs/podman.md) or a volumes, mounts, devices or hooks_dir key`;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* THE HOLD (gate round 2 of PR #473): a local job held by `restart` or `skew` is moved back to the queue's delayed set
|
|
1119
|
+
* every `PODMAN_RESTART_HOLD_RECHECK_MS` WITHOUT spending an attempt (`moveToDelayed`, as the pause and wait gates do),
|
|
1120
|
+
* because a service held up past one 60 s retry backoff turned a "retried, heals by itself" job into a terminal failure
|
|
1121
|
+
* (measured). Bounded: past `PODMAN_RESTART_HOLD_MAX_MS` of holding, the job fails with its own reason token,
|
|
1122
|
+
* `PODMAN_RESTART_HOLD_EXPIRED`, whose forge comment names the restart.
|
|
1123
|
+
*/
|
|
1124
|
+
export const PODMAN_RESTART_HOLD_RECHECK_MS = 60_000;
|
|
1125
|
+
export const PODMAN_RESTART_HOLD_MAX_MS = 3_600_000;
|
|
1126
|
+
export const PODMAN_RESTART_HOLD_EXPIRED = "podman-service-restart-hold-expired";
|
|
1127
|
+
|
|
1128
|
+
/** Whether a rootful finding holds jobs back only until something heals by itself (a retry), not a configuration fix. */
|
|
1129
|
+
export function rootfulConfRetries(found) {
|
|
1130
|
+
return found?.transient === true || found?.restart === true || found?.skew === true;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** The remedy half of `rootfulConfRefusal`, alone, for doctor's fix line. */
|
|
1134
|
+
export function rootfulConfFix(found) {
|
|
1135
|
+
const restart = `sudo systemctl restart ${PODMAN_SERVICE_UNIT} while no local job runs (a running job's docker run holds the service up), or stop it and let podman.socket start it again on the next request`;
|
|
1136
|
+
const held = `each local job is held, never refused: it goes back to the queue and is checked again every minute without spending an attempt, and fails, with a comment naming this, only after an hour of holding; a boot exits 1 to be restarted`;
|
|
1137
|
+
if (found?.transient) return "the read failed for a moment, not for a reason in the file; a job refused this way is retried once (the queue's second attempt) and a boot exits to be restarted, so if it recurs, fix what the host ran out of (file descriptors, memory, a failing disk)";
|
|
1138
|
+
if (found?.skew) return `fix this host's clock, or wait until it passes that file's change time; until then ${held}. Then ${restart} if it runs`;
|
|
1139
|
+
if (found?.restart) return `${restart}, so the service reads the files as they are now; until it does, ${held}. It heals by itself once the service idles out`;
|
|
1140
|
+
if (found?.module) return `give that module a path with no space, or remove that --module from ${PODMAN_SERVICE_UNIT} (its ExecStart or the variable that carries it), then ${restart}: the local venue judges every module the service loads, and refuses one it cannot find rather than guess where its path ends`;
|
|
1141
|
+
if (found?.key) {
|
|
1142
|
+
const listed = PODMAN_ROOTFUL_WIDENING_KEYS.join(", ").replace(/, ([^,]*)$/, " or $1");
|
|
1143
|
+
return `remove that key from that file, then ${restart}. The local venue refuses any containers.conf rootful Podman's service reads that sets ${listed}, whatever the value, because no flag on a job's command line takes it back. A setting you need for your own containers goes on their own command line instead, not host-wide`;
|
|
1144
|
+
}
|
|
1145
|
+
if (found?.spelling) return `rewrite ${found.spelling === "escaped" ? "that key without a backslash escape" : "that line in plain ASCII with no \"\"\" or ''' multi-line string"}: this check reads a containers.conf only in plain ASCII with plain keys, and refuses what it cannot read rather than guess`;
|
|
1146
|
+
return "make that file readable by the worker's account (only root's own config home may stay unreadable, and is then named, not judged): the local venue must read every other containers.conf rootful Podman's service reads to know that none of them widens a job, and refuses what it cannot read";
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** The operator text for a rootful finding: the boot refusal, doctor's fix and the per-job log line, never a forge comment. */
|
|
1150
|
+
export function rootfulConfRefusal(found) {
|
|
1151
|
+
return `${found?.transient ? "Not read yet" : rootfulConfRetries(found) ? "Not run yet" : "Refused"}: ${found?.evidence ?? "rootful Podman's containers.conf chain was not read"}; ${rootfulConfFix(found)} (issue #448).`;
|
|
1152
|
+
}
|