@edgehero/pi-dispatch 1.10.3 → 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.
Files changed (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +29 -2
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +363 -13
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1348 -326
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
@@ -0,0 +1,1304 @@
1
+ /**
2
+ * The native rootless `podman` venue's long-lived stack as Quadlet units (issue #430): Valkey, and while the egress
3
+ * policy is armed the allowlist proxy on its named route-out network and the rootless network keeper (issue #458) on a
4
+ * network of its own. ONE installer, used by both
5
+ * `pi-dispatch service install` (user scope) and `pi-dispatch up`, on up.mjs's own doctrine that two ways of starting
6
+ * one thing is two places for it to drift.
7
+ *
8
+ * Quadlet rather than `podman run --restart`: rootless Podman has no daemon to restart anything, so a container
9
+ * started by hand is gone after a reboot. `podman-restart.service` could bring back `--restart=always` containers,
10
+ * but it is a unit the operator would have to enable separately and it orders nothing; a Quadlet unit is a real
11
+ * systemd user unit the worker's own unit can `Wants=`/`After=`. Measured on Fedora 44 with Podman 5.8.1:
12
+ * - `NetworkName=` and `ContainerName=` give exactly those names (no `systemd-` prefix), and `Network=X.network`
13
+ * resolves through the network file's `NetworkName=`.
14
+ * - `systemctl --user enable` REFUSES a generated unit ("transient or generated"); the generator reads the
15
+ * file's own `[Install] WantedBy=default.target` instead. So install is: write the files, daemon-reload,
16
+ * `systemctl --user start`. Never enable.
17
+ * - With linger on, the units were active 25 s after a reboot with nobody logged in; with linger off they did
18
+ * not start at all. Hence the linger read after every install.
19
+ * - `systemctl --user restart` of the proxy creates a NEW container (`--replace --rm`), and every per-job network
20
+ * the worker had connected to the old one is gone. New jobs connect at their start and are fine; see docs.
21
+ *
22
+ * This module decides and describes; it spawns only through the `run` seam it is handed and writes only through the
23
+ * `fs` seam, so `service` and `up` each keep their own spawn helpers and their own tests' fakes.
24
+ */
25
+ import { execFileSync } from "node:child_process";
26
+ import { connect as netConnect } from "node:net";
27
+ import { dirname, join } from "node:path";
28
+ import { DEFAULT_EGRESS_PROXY, egressProxyName } from "./egress.mjs";
29
+ import { VALKEY_PASSWORD_KEY, valkeyEnvFileText } from "./valkey-auth.mjs";
30
+ import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, envFileSystemdHazard, envFileValueLines, invisibleCharacter, quotedRegions, readEnvAssignments } from "./env-file.mjs";
31
+ import { NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_NOW_FORMAT, STARTED_AT_FORMAT, NETNS_KEEPER_MIN_AGE_MS, NETNS_KEEPER_AFTER_PROXY_GRACE_MS, judgeNetnsKeeper, podmanNeedsNetnsKeeper, makeDetachGate, detachBlockedSentence, DETACH_GATE_READ_TIMEOUT_MS, DETACH_GATE_READ_MAX_BUFFER, runtimeFromFacts } from "./netns-keeper.mjs";
32
+
33
+ // The keeper's identity, its judge and the network detach gate live in the leaf `netns-keeper.mjs` (issue #452, gate
34
+ // round 3), because `egress.mjs`, which this module imports, routes every detach through that gate. Re-exported here.
35
+ export { NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_NOW_FORMAT, STARTED_AT_FORMAT, NETNS_KEEPER_MIN_AGE_MS, NETNS_KEEPER_AFTER_PROXY_GRACE_MS, judgeNetnsKeeper, podmanNeedsNetnsKeeper, makeDetachGate, detachBlockedSentence, DETACH_GATE_READ_TIMEOUT_MS, DETACH_GATE_READ_MAX_BUFFER, runtimeFromFacts };
36
+
37
+ /**
38
+ * What to run for a keeper that does not hold: the proxy's restart when that is the damage the order rule cannot rule
39
+ * out (`restartProxy`), else the keeper's own `reset-failed` and restart.
40
+ */
41
+ export function netnsKeeperRemedy(judged, proxy) {
42
+ const restartProxy = proxyRestartAdvice(proxy);
43
+ if (judged.restartProxy) return restartProxy;
44
+ if (judged.thenRestartProxy) return `start it as the worker's account: ${NETNS_KEEPER_START}, then ${restartProxy}, since the proxy has been up since before it`;
45
+ return `start it as the worker's account: ${NETNS_KEEPER_START}`;
46
+ }
47
+
48
+ /** "restart the egress proxy once no job is running: <the command for this proxy's name>". */
49
+ export function proxyRestartAdvice(proxy) {
50
+ return proxy === DEFAULT_EGRESS_PROXY ? `restart the egress proxy once no job is running: systemctl --user restart ${DEFAULT_EGRESS_PROXY}.service (podman restart ${DEFAULT_EGRESS_PROXY} for one started by hand)` : `restart the egress proxy once no job is running: podman restart ${proxy} (or its own unit)`;
51
+ }
52
+
53
+ /**
54
+ * The installer's hint (PR #463 round 3): a plan that starts the keeper for the first time or restarts it, while the
55
+ * proxy is NOT in the plan (an operator's own PI_EGRESS_PROXY, or `up` leaving a running proxy alone), leaves a proxy up
56
+ * since before the keeper, which the worker's order rule then answers with a retry and a request for the proxy's
57
+ * restart. So both commands say it now, with the proxy's real name. `null` when nothing of that happened.
58
+ */
59
+ export function keeperUnderRunningProxyHint(plan, proxy, { keeperStarting = null } = {}) {
60
+ const keeper = plan.files?.find((f) => f.unit === QUADLET_FILES.keeper.unit);
61
+ if (!keeper) return null;
62
+ const restarted = (plan.restart ?? []).includes(keeper.unit);
63
+ // `keeperStarting`: a caller that knows the keeper was not running (`up` plans it only then) says so; otherwise a
64
+ // file the plan writes new, or a restart, is what moves it (a `start` over a running unit changes nothing).
65
+ if (!(keeperStarting ?? (keeper.state === "new" || restarted))) return null;
66
+ if ((plan.start ?? []).includes(QUADLET_FILES.proxy.unit)) return null;
67
+ return `${NETNS_KEEPER} was ${restarted ? "restarted" : "started"} while the egress proxy (${proxy}) is not part of this install: if it is running, ${proxyRestartAdvice(proxy)}; on Podman 4.x the worker retries every egress job, asking for exactly that, until the proxy has started after the keeper`;
68
+ }
69
+
70
+ /**
71
+ * The start command every "the keeper is not holding" message names (doctor, the worker's preflight).
72
+ */
73
+ export const NETNS_KEEPER_START = `systemctl --user reset-failed ${NETNS_KEEPER}-network.service ${NETNS_KEEPER}.service; systemctl --user restart ${NETNS_KEEPER}-network.service ${NETNS_KEEPER}.service`;
74
+
75
+ /**
76
+ * The Quadlet files, in the order they are shown and written. `unit` is the service the generator makes of each; the
77
+ * networks' units are pulled in by the containers' own `Requires=` (Quadlet adds it for `Network=X.network`), so only
78
+ * the container services are ever started by name.
79
+ */
80
+ export const QUADLET_FILES = Object.freeze({
81
+ valkeyNetwork: Object.freeze({ file: "pi-dispatch-valkey.network", unit: "pi-dispatch-valkey-network.service" }),
82
+ valkey: Object.freeze({ file: "pi-dispatch-valkey.container", unit: "pi-dispatch-valkey.service", container: "pi-dispatch-valkey" }),
83
+ egressNetwork: Object.freeze({ file: "pi-dispatch-egress-out.network", unit: "pi-dispatch-egress-out-network.service" }),
84
+ proxy: Object.freeze({ file: "pi-dispatch-egress-proxy.container", unit: "pi-dispatch-egress-proxy.service", container: DEFAULT_EGRESS_PROXY }),
85
+ keeperNetwork: Object.freeze({ file: `${NETNS_KEEPER}.network`, unit: `${NETNS_KEEPER}-network.service` }),
86
+ keeper: Object.freeze({ file: `${NETNS_KEEPER}.container`, unit: `${NETNS_KEEPER}.service`, container: NETNS_KEEPER }),
87
+ });
88
+
89
+ /** Every Quadlet file this project ships, for uninstall and status, which act on what exists rather than on a plan. */
90
+ export const ALL_QUADLET_FILES = Object.freeze(Object.values(QUADLET_FILES));
91
+
92
+ /** The two placeholders the proxy's template carries, and what each becomes (TEMPLATE_PINS in service.mjs pins both). */
93
+ export const PROXY_CONF_PLACEHOLDER = "/opt/pi-dispatch/deploy/egress-proxy.conf";
94
+ export const ALLOWLIST_PLACEHOLDER = "/opt/pi-dispatch/egress-allowlist.conf";
95
+
96
+ /**
97
+ * Where the proxy's RULES are mounted from: an account-owned COPY of the package's `egress-proxy.conf`, never the
98
+ * package file itself (issue #430 review round 2, E1). The mount carries `z`, which relabels the file, and rootless
99
+ * Podman cannot relabel a file this account does not own: measured on Fedora 44 with the package installed by
100
+ * `sudo npm i -g` under /usr/local/lib/node_modules, the unit failed with `lsetxattr ... operation not permitted`,
101
+ * exit 126. A copy under this account's own config directory can always be relabelled, and is written, compared and
102
+ * forced exactly like the Quadlet files (a planned write, shown before it happens). Not the deployment folder: that
103
+ * is the operator's and may be shared with another account; this file belongs to the account whose Podman mounts it.
104
+ */
105
+ export function proxyConfCopyPath(home) {
106
+ return join(home, ".config", "pi-dispatch", "egress-proxy.conf");
107
+ }
108
+
109
+ /**
110
+ * The file the Quadlet Valkey reads its password from (issue #468): `EnvironmentFile=%h/.config/pi-dispatch/valkey.env`
111
+ * in the shipped unit, so the template needs no rewrite, and systemd expands `%h` to this account's home in both lines
112
+ * Quadlet generates from it (measured on Podman 5.8.1 and 4.9.3). Mode 0600: it holds the password. Written, compared
113
+ * and restarted-for like the proxy's rules copy, never printed (`service render` names it only).
114
+ */
115
+ export function valkeyEnvPath(home) {
116
+ return join(home, ".config", "pi-dispatch", "valkey.env");
117
+ }
118
+
119
+ /** The mode of `valkeyEnvPath`: this account alone may read it. */
120
+ export const VALKEY_ENV_MODE = 0o600;
121
+
122
+ /**
123
+ * Where the user's Quadlet files live. ALWAYS `~/.config/containers/systemd`, deliberately not `$XDG_CONFIG_HOME`
124
+ * from this shell: the generator runs in the user MANAGER's environment, which usually has no XDG_CONFIG_HOME at
125
+ * all, so a shell that exports one would put the files where the generator never looks, and install would report a
126
+ * start failure for units that simply do not exist.
127
+ */
128
+ export function quadletDir(home) {
129
+ return join(home, ".config", "containers", "systemd");
130
+ }
131
+
132
+ /**
133
+ * Characters a path may not carry into a Quadlet `Volume=`. Each is a real parse on the way to `podman run`: `:`
134
+ * splits the volume spec itself; whitespace splits the `RequiresMountsFor=` list Quadlet adds for every absolute
135
+ * source; `%` is a systemd specifier there; `$` is expanded by systemd in the generated `ExecStart=` (`${X}` and
136
+ * `$X` alike); quotes and backslashes are systemd's quoting; control bytes end the line.
137
+ * Refused rather than escaped: none of the escapes was measured, and a unit that fails at boot is the worst place to
138
+ * find out.
139
+ */
140
+ const UNSAFE_VOLUME_PATH = /[:\s%$"'\\\x00-\x1f\x7f]/;
141
+
142
+ /** The port the Quadlet Valkey publishes on 127.0.0.1 when VALKEY_URL names none (the template's own). */
143
+ export const DEFAULT_VALKEY_PORT = 6379;
144
+
145
+ /** The opt-in, in the deployment `.env`, that lets a Valkey another uid holds be this deployment's queue (issue #464). */
146
+ export const VALKEY_SHARED_KEY = "PI_VALKEY_SHARED";
147
+
148
+ /** Whether `value` (PI_VALKEY_SHARED as the service reads it) opts in: exactly `1`, nothing else. */
149
+ export function valkeySharedOn(value) {
150
+ return value === "1";
151
+ }
152
+
153
+ /**
154
+ * Where the worker's queue client connects, from VALKEY_URL as the service reads it (issue #464): `{ host, port }`,
155
+ * the host as the client dials it (an IPv6 literal without its brackets, as `parseConnection` hands it over), or
156
+ * `{ error }` for a value that is not a redis URL. Unset is the worker's own default, redis://127.0.0.1:6379.
157
+ */
158
+ /**
159
+ * The scheme a VALKEY_URL is read as (gate round 3 of PR #478): `valkey:` and `valkeys:` are aliases of `redis:` and
160
+ * `rediss:`, as Valkey's own clients take them, and connected before #477's scheme check; null for any other. Every
161
+ * place that judges a scheme asks this, so the aliases cannot be accepted in one and refused in another.
162
+ */
163
+ export function valkeySchemeOf(protocol) {
164
+ return { "redis:": "redis:", "valkey:": "redis:", "rediss:": "rediss:", "valkeys:": "rediss:" }[protocol] ?? null;
165
+ }
166
+
167
+ export function valkeyTarget(url) {
168
+ const raw = typeof url === "string" && url !== "" ? url : `redis://127.0.0.1:${DEFAULT_VALKEY_PORT}`;
169
+ let parsed;
170
+ try {
171
+ parsed = new URL(raw);
172
+ } catch {
173
+ return { error: "VALKEY_URL is not a URL (redis://host:port)" };
174
+ }
175
+ if (valkeySchemeOf(parsed.protocol) === null) return { error: `VALKEY_URL's scheme is ${parsed.protocol}, not redis:, rediss:, valkey: or valkeys:` };
176
+ const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase() || "127.0.0.1";
177
+ return { host, port: parsed.port === "" ? DEFAULT_VALKEY_PORT : Number(parsed.port) };
178
+ }
179
+
180
+ /** The 4 bytes of a dotted IPv4 address, or null. */
181
+ function ipv4Bytes(address) {
182
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
183
+ if (!m) return null;
184
+ const bytes = m.slice(1).map(Number);
185
+ return bytes.every((b) => b <= 255) ? bytes : null;
186
+ }
187
+
188
+ /** The 16 bytes of an IPv6 address (`::` shorthand and a dotted IPv4 tail allowed, a zone dropped), or null. */
189
+ function ipv6Bytes(address) {
190
+ let s = String(address).toLowerCase().replace(/%.*$/, "");
191
+ const dotted = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
192
+ let tail = [];
193
+ if (dotted) {
194
+ const v4 = ipv4Bytes(dotted[1]);
195
+ if (!v4) return null;
196
+ tail = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]];
197
+ s = s.slice(0, -dotted[1].length);
198
+ if (s.endsWith(":") && !s.endsWith("::")) s = s.slice(0, -1);
199
+ }
200
+ const halves = s.split("::");
201
+ if (halves.length > 2) return null;
202
+ const words = (part) => (part === "" ? [] : part.split(":").map((w) => (/^[0-9a-f]{1,4}$/.test(w) ? parseInt(w, 16) : NaN)));
203
+ const head = words(halves[0]);
204
+ const rest = halves.length === 2 ? words(halves[1]) : [];
205
+ const given = head.length + rest.length + tail.length;
206
+ if ([...head, ...rest].some(Number.isNaN)) return null;
207
+ if (halves.length === 1 && given !== 8) return null;
208
+ if (halves.length === 2 && given > 7) return null;
209
+ const all = halves.length === 2 ? [...head, ...Array(8 - given).fill(0), ...rest, ...tail] : [...head, ...tail];
210
+ return all.flatMap((w) => [w >> 8, w & 0xff]);
211
+ }
212
+
213
+ /**
214
+ * An address as the client dials it, reduced to what a listener must answer: `{ family: 4, bytes }` for IPv4 and for
215
+ * an IPv4-mapped IPv6 address (::ffff:a.b.c.d reaches the IPv4 listener), `{ family: 6, bytes }` for other IPv6, or
216
+ * null for a string that is neither.
217
+ */
218
+ function addressOf(address) {
219
+ const v4 = ipv4Bytes(String(address));
220
+ if (v4) return { family: 4, bytes: v4 };
221
+ const v6 = ipv6Bytes(address);
222
+ if (!v6) return null;
223
+ if (v6.slice(0, 10).every((b) => b === 0) && v6[10] === 0xff && v6[11] === 0xff) return { family: 4, bytes: v6.slice(12) };
224
+ return { family: 6, bytes: v6 };
225
+ }
226
+
227
+ /**
228
+ * An address in the kernel's /proc/net/tcp{,6} spelling: each 32-bit word printed as the host reads it, which on a
229
+ * little-endian host (x86_64, aarch64) is the word's bytes reversed. 127.0.0.1 is `0100007F`, ::1 is
230
+ * `00000000000000000000000001000000`, ::ffff:127.0.0.1 is `0000000000000000FFFF00000100007F` (measured on Fedora 44
231
+ * and Ubuntu 24.04, both aarch64).
232
+ */
233
+ export function procNetHex(bytes) {
234
+ let hex = "";
235
+ for (let i = 0; i < bytes.length; i += 4) {
236
+ for (const b of bytes.slice(i, i + 4).reverse()) hex += b.toString(16).toUpperCase().padStart(2, "0");
237
+ }
238
+ return hex;
239
+ }
240
+
241
+ /** An address shown as a client would write it with its port: `127.0.0.1:6379`, `[::1]:6379`. */
242
+ function hostPort(address, port) {
243
+ return address.includes(":") ? `[${address}]:${port}` : `${address}:${port}`;
244
+ }
245
+
246
+ /**
247
+ * The uids of the LISTEN sockets that answer a connection to `address`:`port`, read from /proc/net/tcp and
248
+ * /proc/net/tcp6 (issue #464): `{ uids }`, or `{ error }` when neither file could be read. An IPv4 address is answered
249
+ * by a socket bound to it or to 0.0.0.0, and on tcp6 by one bound to `::` or to the address IPv4-mapped; an IPv6 one by
250
+ * a socket bound to it or to `::`. A `::` socket with IPV6_V6ONLY set does not answer IPv4, which /proc does not show;
251
+ * it is counted only when the caller's probe of that address was answered, which is when this is asked. Measured on
252
+ * Fedora 44 (Podman 5.8.1, pasta) and Ubuntu 24.04 (4.9.3, rootlessport): a rootless container's published port is a
253
+ * socket of the ACCOUNT's uid, one run with `--network host` a socket of one of its SUBORDINATE uids, a rootful
254
+ * container's or docker-proxy's a socket of root; every account can read both files, where `ss -p` names the process
255
+ * only for the caller's own sockets.
256
+ */
257
+ export function listenerUids(address, port, fs) {
258
+ const at = addressOf(address);
259
+ if (!at) return { error: `${address} is not an IP address` };
260
+ const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
261
+ const answers =
262
+ at.family === 4
263
+ ? { "/proc/net/tcp": [procNetHex(at.bytes), "00000000"], "/proc/net/tcp6": ["0".repeat(32), procNetHex([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ...at.bytes])] }
264
+ : { "/proc/net/tcp": [], "/proc/net/tcp6": [procNetHex(at.bytes), "0".repeat(32)] };
265
+ const uids = new Set();
266
+ let read = 0;
267
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
268
+ let text;
269
+ try {
270
+ text = String(fs.readFileSync(file, "utf8"));
271
+ } catch {
272
+ continue;
273
+ }
274
+ read += 1;
275
+ for (const line of text.split("\n").slice(1)) {
276
+ const f = line.trim().split(/\s+/);
277
+ if (f.length < 8 || f[3] !== "0A") continue;
278
+ const [addr, p] = String(f[1]).split(":");
279
+ if (p?.toUpperCase() !== hexPort || !answers[file].includes(String(addr).toUpperCase())) continue;
280
+ const uid = Number(f[7]);
281
+ if (Number.isInteger(uid) && uid >= 0) uids.add(uid);
282
+ }
283
+ }
284
+ return read === 0 ? { error: "neither /proc/net/tcp nor /proc/net/tcp6 could be read" } : { uids: [...uids] };
285
+ }
286
+
287
+ /**
288
+ * The uid ranges this account's containers run as: its subordinate uids from /etc/subuid, the lines naming it by
289
+ * `user` or by `euid` (issue #464). A container run with `--network host` publishes from a socket owned by one of these,
290
+ * not by the account (measured: subuid 1467000998 for a valkey container of uid 1467). `[]` when the file cannot be read.
291
+ */
292
+ export function subordinateUids(fs, { user, euid }) {
293
+ let text;
294
+ try {
295
+ text = String(fs.readFileSync("/etc/subuid", "utf8"));
296
+ } catch {
297
+ return [];
298
+ }
299
+ const ranges = [];
300
+ for (const line of text.split("\n")) {
301
+ const [who, start, count] = line.trim().split(":");
302
+ if (who === undefined || (who !== user && who !== String(euid))) continue;
303
+ const lo = Number(start);
304
+ const n = Number(count);
305
+ if (Number.isInteger(lo) && Number.isInteger(n) && n > 0) ranges.push({ lo, hi: lo + n - 1 });
306
+ }
307
+ return ranges;
308
+ }
309
+
310
+ /**
311
+ * This account's subordinate uid ranges as the system hands them out (issue #464, gate round 2): `getsubids(1)` where it
312
+ * exists, which also answers for ranges an SSSD or LDAP provider serves (nsswitch `subid:`), else /etc/subuid. Returns
313
+ * `{ ranges, source }`, `source` naming where they came from, for the refusal to say. `run(cmd, args)` returns stdout
314
+ * or null (not installed, or it failed).
315
+ */
316
+ export function readSubuidRanges({ user, euid, fs, run = defaultRunSync }) {
317
+ if (user) {
318
+ const out = run("getsubids", [user]);
319
+ if (typeof out === "string") {
320
+ const ranges = [];
321
+ for (const line of out.split("\n")) {
322
+ // `0: gx467c 1481000000 65536` (shadow-utils 4.14, measured on Fedora 44 and Ubuntu 24.04).
323
+ const m = /^\s*\d+:\s+\S+\s+(\d+)\s+(\d+)\s*$/.exec(line);
324
+ if (m && Number(m[2]) > 0) ranges.push({ lo: Number(m[1]), hi: Number(m[1]) + Number(m[2]) - 1 });
325
+ }
326
+ if (ranges.length > 0) return { ranges, source: "getsubids" };
327
+ }
328
+ }
329
+ return { ranges: subordinateUids(fs, { user, euid }), source: "/etc/subuid" };
330
+ }
331
+
332
+ /** `cmd args` run to completion, bounded, its stdout, or null when it could not run or failed. */
333
+ function defaultRunSync(cmd, args) {
334
+ try {
335
+ return execFileSync(cmd, args, { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /** The account a subordinate uid belongs to, from /etc/subuid, as `{ name, uid }`, or null (issue #464 gate round 2). */
342
+ function subuidOwnerOf(uid, fs) {
343
+ let text;
344
+ try {
345
+ text = String(fs.readFileSync("/etc/subuid", "utf8"));
346
+ } catch {
347
+ return null;
348
+ }
349
+ for (const line of text.split("\n")) {
350
+ const [who, start, count] = line.trim().split(":");
351
+ const lo = Number(start);
352
+ const n = Number(count);
353
+ if (who && Number.isInteger(lo) && Number.isInteger(n) && uid >= lo && uid < lo + n) return who;
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * Whether `address` is this host's: loopback (127.0.0.0/8, ::1), an unspecified address (0.0.0.0/8, ::, ::ffff:0.0.0.0,
360
+ * which a connect reaches this host's loopback through), or an address on one of its interfaces.
361
+ */
362
+ function isThisHost(address, interfaces) {
363
+ const at = addressOf(address);
364
+ if (!at) return false;
365
+ if (at.family === 4 && (at.bytes[0] === 127 || at.bytes[0] === 0)) return true;
366
+ if (at.family === 6 && at.bytes.slice(0, 15).every((b) => b === 0) && (at.bytes[15] === 1 || at.bytes[15] === 0)) return true;
367
+ let all = {};
368
+ try {
369
+ all = interfaces() ?? {};
370
+ } catch {
371
+ // No interface list: only loopback is known to be this host.
372
+ }
373
+ const mine = Object.values(all).flat().map((i) => addressOf(i?.address)).filter(Boolean);
374
+ return mine.some((m) => m.family === at.family && m.bytes.every((b, k) => b === at.bytes[k]));
375
+ }
376
+
377
+ /**
378
+ * The address a connect to `address` actually reaches (issue #464, gate round 2): an unspecified address is this host's
379
+ * loopback of its family (a client dialling 0.0.0.0 reaches 127.0.0.1, one dialling :: reaches ::1; measured on Fedora
380
+ * 44: `redis://0.0.0.0:16483` reached another account's 127.0.0.1:16483), an IPv4-mapped address its IPv4 form, and
381
+ * any other address itself. This is what is probed, judged and pinned.
382
+ */
383
+ export function reachedAddress(address) {
384
+ const at = addressOf(address);
385
+ if (!at) return String(address);
386
+ if (at.family === 4) return at.bytes[0] === 0 ? "127.0.0.1" : at.bytes.join(".");
387
+ if (at.bytes.every((b) => b === 0)) return "::1";
388
+ return String(address).toLowerCase().replace(/%.*$/, "");
389
+ }
390
+
391
+ /** `lookup(host)` bounded by `timeoutMs`: a resolver that does not answer is a name that did not resolve. */
392
+ function lookupWithin(lookup, host, timeoutMs) {
393
+ let timer;
394
+ return Promise.race([
395
+ Promise.resolve().then(() => lookup(host, { all: true, verbatim: true })),
396
+ new Promise((_, reject) => {
397
+ timer = setTimeout(() => reject(Object.assign(new Error(`no answer within ${timeoutMs} ms`), { code: "ETIMEOUT" })), timeoutMs);
398
+ }),
399
+ ]).finally(() => clearTimeout(timer));
400
+ }
401
+
402
+ /**
403
+ * THE owner rule for a Valkey the worker would use (issue #464), one function for the worker's own boot
404
+ * (`resolveWorkerValkey`), `service install`, `up` and doctor, so none of them can disagree.
405
+ *
406
+ * VALKEY_URL's host is resolved ONCE, the way the worker's client resolves it (`lookup`, every address, in the order the
407
+ * client tries them; bounded by `lookupTimeoutMs`). Each address is reduced to the one a connect reaches
408
+ * (`reachedAddress`: an unspecified 0.0.0.0 or :: is this host's loopback), those that are this host's are probed in
409
+ * that order, and each one something answers is judged by the uid of its listening socket in /proc/net/tcp{,6}: this
410
+ * account's own when it is the euid or one of its subordinate uids (`readSubuidRanges`), someone else's otherwise.
411
+ *
412
+ * The CHOSEN address is the one the worker then connects to, as a literal (`pinnedValkeyUrl`), so no later DNS answer or
413
+ * address order can send it elsewhere:
414
+ * - the first answering address this account holds, if any. A foreign listener on ANOTHER address of the same name
415
+ * (another account publishing [::1] beside this account's 127.0.0.1, measured on Fedora 44) is never dialled, and
416
+ * is reported in `elsewhere` rather than refused: refusing would let any account stop another's worker by
417
+ * publishing a port, while the pinned client cannot reach it;
418
+ * - else, with `shared` (PI_VALKEY_SHARED=1, read from the deployment `.env` only), the first answering address;
419
+ * - else nothing is chosen, and `refusal` names the owners of the answering addresses and both ways out.
420
+ * Nothing answering chooses nothing and refuses nothing: the caller decides (install adds a Quadlet Valkey; the worker
421
+ * waits, then retries through its service manager).
422
+ *
423
+ * Returns `{ error }` for a value that is not a redis URL, `{ remote: host }` when none of its addresses is this host's,
424
+ * else `{ host, port, addresses, answered: [{ address, uids, own }], chosen, refusal, heldBy, own, elsewhere }`.
425
+ * No uid ranges from login.defs: an LDAP or SSSD account sits above UID_MAX and Lima's default user at 501, below
426
+ * UID_MIN, so a range cannot tell another person from a system service. Only this account's own uids are its own.
427
+ */
428
+ export async function judgeValkeyListeners({ url, probeTcp, lookup, fs, euid, user = null, shared = false, rootOk = false, ownerName = () => null, interfaces = () => ({}), envPath = ".env", subuids = null, lookupTimeoutMs = 3000 }) {
429
+ const target = valkeyTarget(url);
430
+ if (target.error) return { error: target.error };
431
+ const { host, port } = target;
432
+ let addresses;
433
+ if (addressOf(host)) addresses = [host];
434
+ else {
435
+ try {
436
+ const found = await lookupWithin(lookup, host, lookupTimeoutMs);
437
+ addresses = (Array.isArray(found) ? found : [found]).map((a) => a?.address).filter((a) => typeof a === "string");
438
+ } catch (err) {
439
+ // Gate round 3: a lookup that failed or timed out is NOT another host. It used to be read as one, and the client
440
+ // then dialled the name unjudged, landing wherever the resolver answered next (measured: EAI_AGAIN, and a
441
+ // resolver silent past the 3 s bound). Nothing can be judged, so the caller retries.
442
+ return { unresolved: host, why: err?.code ?? err?.message ?? "no answer" };
443
+ }
444
+ if (addresses.length === 0) return { unresolved: host, why: "no address" };
445
+ }
446
+ const local = [...new Set(addresses.filter((a) => isThisHost(a, interfaces)).map(reachedAddress))];
447
+ if (local.length === 0) return { remote: host };
448
+ const { ranges, source } = subuids ?? { ranges: subordinateUids(fs, { user, euid }), source: "/etc/subuid" };
449
+ const isOwn = (uid) => uid === euid || ranges.some((r) => uid >= r.lo && uid <= r.hi);
450
+ const named = (uid) => {
451
+ if (uid === 0) return "root (uid 0: docker-proxy, or a rootful container any account with sudo can start)";
452
+ const name = ownerName(uid);
453
+ if (name) return `${name} (uid ${uid})`;
454
+ const parent = subuidOwnerOf(uid, fs);
455
+ return parent ? `a container of ${parent} (its subordinate uid ${uid})` : `uid ${uid}`;
456
+ };
457
+ const answered = [];
458
+ for (const address of local) {
459
+ if (!(await probeTcp(address, port))) continue;
460
+ const held = listenerUids(address, port, fs);
461
+ const uids = held.uids ?? [];
462
+ const why = held.error ?? (uids.length === 0 ? "no listening socket for it is in /proc/net/tcp or /proc/net/tcp6" : null);
463
+ const own = why === null && uids.every(isOwn);
464
+ // Gate round 3: three classes, not two. ROOT covers uid 0 (docker-proxy, a rootful container) and a listener no
465
+ // socket row explains (a kernel NAT rule, which only root can set up); FOREIGN is any other uid.
466
+ const cls = own ? "own" : why !== null || uids.filter((u) => !isOwn(u)).every((u) => u === 0) ? "root" : "foreign";
467
+ answered.push({ address, uids, own, why, cls });
468
+ }
469
+ const base = { host, port, addresses: local, answered };
470
+ const mineFirst = answered.find((a) => a.own) ?? null;
471
+ const others = answered.filter((a) => !a.own);
472
+ const ownersOf = (a) => (a.why ? "an owner /proc does not name" : [...new Set(a.uids.filter((u) => !isOwn(u)))].map(named).join(" and "));
473
+ const describe = (list) => list.map((a) => `${hostPort(a.address, port)} (${a.why ? `held by an owner /proc does not name: ${a.why}` : `held by ${ownersOf(a)}`})`).join(", ");
474
+ if (answered.length === 0) return { ...base, chosen: null, refusal: null, heldBy: null, own: true, elsewhere: [] };
475
+ if (mineFirst) {
476
+ const heldBy = mineFirst.uids.every((u) => u === euid) ? "this account" : `this account's containers (a subordinate uid of it, from ${source})`;
477
+ return { ...base, chosen: mineFirst.address, refusal: null, heldBy, own: true, elsewhere: others.length > 0 ? [describe(others)] : [] };
478
+ }
479
+ if (shared) {
480
+ const first = answered[0];
481
+ return { ...base, chosen: first.address, refusal: null, heldBy: `${ownersOf(first)}, shared on purpose as ${VALKEY_SHARED_KEY}=1 in ${envPath} says`, own: false, elsewhere: [] };
482
+ }
483
+ // Root's listener where root is acceptable (every client but the podman venue's: docker's Valkey is published by
484
+ // root's docker-proxy there). Another account's listener is never acceptable without the opt-in.
485
+ const rootFirst = rootOk ? (answered.find((a) => a.cls === "root") ?? null) : null;
486
+ if (rootFirst) {
487
+ const foreign = answered.filter((a) => a.cls === "foreign");
488
+ return { ...base, chosen: rootFirst.address, refusal: null, heldBy: rootFirst.why ? "an owner /proc does not name (root's NAT, as docker without its proxy)" : "root", own: false, elsewhere: foreign.length > 0 ? [describe(foreign)] : [] };
489
+ }
490
+ const ways = `Give this account a Valkey of its own on another port, VALKEY_URL=redis://127.0.0.1:<port> in ${envPath} (\`service install\` and \`up\` then publish the Quadlet Valkey there); or, if that Valkey is shared on purpose, say so with ${VALKEY_SHARED_KEY}=1 in ${envPath}`;
491
+ const own = `this account (uid ${euid}) or its containers (subordinate uids read from ${source})`;
492
+ const unknown = others.filter((a) => a.why);
493
+ if (unknown.length === others.length) {
494
+ const what = unknown.map((a) => hostPort(a.address, port)).join(" and ");
495
+ return { ...base, chosen: null, heldBy: null, own: false, elsewhere: [], refusal: { short: `something answers ${what}, and which account holds it could not be told`, text: `something answers ${what}, and which account holds it could not be told (${unknown[0].why}), so it is not taken to be this account's Valkey. ${ways}`, fix: ways } };
496
+ }
497
+ // Only the addresses a refused uid holds are named, each with its own owner (gate round 2: an own 127.0.0.1 used to be
498
+ // listed beside another account's ::1 as if both were that account's).
499
+ const refusedAt = others.filter((a) => !a.why && (a.cls === "foreign" || !rootOk));
500
+ const where = refusedAt.map((a) => hostPort(a.address, port)).join(" and ");
501
+ const who = [...new Set(refusedAt.flatMap((a) => a.uids.filter((u) => !isOwn(u))))].map(named).join(" and ");
502
+ // Gate round 3 nit: "are held by" when the refusal names more than one address.
503
+ const held = refusedAt.length > 1 ? "are held by" : "is held by";
504
+ return {
505
+ ...base,
506
+ chosen: null,
507
+ heldBy: null,
508
+ own: false,
509
+ elsewhere: [],
510
+ refusal: {
511
+ short: `${where} ${held} ${who}, not by ${own}`,
512
+ text: `${where} ${held} ${who}, not by ${own}: taking it as this deployment's Valkey would put this account's jobs in a queue another account can read and drain. ${ways}`,
513
+ fix: ways,
514
+ },
515
+ };
516
+ }
517
+
518
+ /**
519
+ * VALKEY_URL with its host replaced by the judged literal `address` (issue #464, gate round 2), so every client the
520
+ * worker makes connects exactly there. `servername` is the original host name for TLS (`rediss:`), so a certificate
521
+ * for that name still verifies against a connection made to its address; null for a literal or plain `redis:`.
522
+ */
523
+ export function pinnedValkeyUrl(url, address) {
524
+ const u = new URL(url);
525
+ const original = u.hostname.replace(/^\[(.*)\]$/, "$1");
526
+ u.hostname = address.includes(":") ? `[${address}]` : address;
527
+ const servername = valkeySchemeOf(u.protocol) === "rediss:" && !addressOf(original) ? original : null;
528
+ return { url: u.toString(), servername };
529
+ }
530
+
531
+ /**
532
+ * Whether the podman venue's stack adds its Valkey, and on which port (issue #464, and #430 before it). Returns
533
+ * `{ include, port, notes, refusal, error? }`. The rule, on `judgeValkeyListeners`' verdict:
534
+ * - `local` blessed: never (docker's Valkey is the queue, as before).
535
+ * - VALKEY_URL on another host: never, and said.
536
+ * - a refusal (no answering address is this account's, and no PI_VALKEY_SHARED=1): refused, whether or not our Quadlet
537
+ * Valkey is installed. Not forceable: the way out is a port of this account's own, or the named opt-in.
538
+ * - our Quadlet Valkey already installed: kept (and republished on VALKEY_URL's port) while what the worker will use
539
+ * is this account's; a shared Valkey taken with the opt-in is the queue instead.
540
+ * - nothing answers: added, published on 127.0.0.1 at that port, when VALKEY_URL reaches 127.0.0.1 (an `[::1]` or
541
+ * interface-address URL would not reach it, which is an error naming the fix).
542
+ * - this account's listener (or a shared one, opted in): taken to be the queue, as `up` always did, and named, with
543
+ * any other account's listener on another address of the name said too (the worker never dials it).
544
+ */
545
+ export async function decideValkey({ venues, url, installed, probeTcp, lookup, euid, user = null, shared = false, fs, ownerName = () => null, interfaces = () => ({}), envPath = ".env", subuids = null }) {
546
+ const notes = [];
547
+ if (venues.localUsed) return { include: false, port: DEFAULT_VALKEY_PORT, notes, refusal: null };
548
+ const verdict = await judgeValkeyListeners({ url, probeTcp, lookup, fs, euid, user, shared, ownerName, interfaces, envPath, subuids });
549
+ if (verdict.error) return { include: false, port: DEFAULT_VALKEY_PORT, notes, refusal: null, error: `${envPath}: ${verdict.error}` };
550
+ if (verdict.unresolved) return { include: false, port: DEFAULT_VALKEY_PORT, notes, refusal: null, error: `VALKEY_URL's host ${verdict.unresolved} did not resolve here (${verdict.why}), so whose Valkey it reaches cannot be judged. Retry when it resolves, or write the address` };
551
+ if (verdict.remote) {
552
+ notes.push(`VALKEY_URL names ${verdict.remote}, not this host, so no Valkey is added here`);
553
+ return { include: false, port: DEFAULT_VALKEY_PORT, notes, refusal: null, remote: true };
554
+ }
555
+ const { port } = verdict;
556
+ if (verdict.refusal) return { include: false, port, notes, refusal: verdict.refusal };
557
+ for (const other of verdict.elsewhere) notes.push(`another account also listens on an address VALKEY_URL's host resolves to: ${other}. The worker connects only to the address judged this account's, so it never reaches that one`);
558
+ if (verdict.chosen === null || (installed && verdict.own)) {
559
+ if (!verdict.addresses.includes("127.0.0.1")) {
560
+ // Said as what it is (gate round 2): a literal is itself, or the loopback an unspecified one reaches, and only a name
561
+ // "resolves" to something.
562
+ const literal = addressOf(verdict.host) !== null;
563
+ const is = !literal ? `resolves to ${verdict.addresses.join(", ")} here` : verdict.addresses[0] === verdict.host ? `is ${verdict.host}` : `is ${verdict.host}, which reaches ${verdict.addresses[0]}`;
564
+ return { include: false, port, notes, refusal: null, error: `${envPath}: VALKEY_URL's host ${is}, not 127.0.0.1, and the Quadlet Valkey is published on 127.0.0.1 only, so the worker would not reach it. Write VALKEY_URL=redis://127.0.0.1:${port}` };
565
+ }
566
+ return { include: true, port, notes, refusal: null };
567
+ }
568
+ notes.push(`something already listens on ${hostPort(verdict.chosen, port)}, held by ${verdict.heldBy}, so no Valkey is added for it: that listener is taken to be your Valkey, as \`up\` does`);
569
+ return { include: false, port, notes, refusal: null, chosen: verdict.chosen };
570
+ }
571
+
572
+ /**
573
+ * The worker's own Valkey endpoint, judged at boot by `judgeValkeyListeners` (issue #464, gate round 2): the owner rule
574
+ * where the connection is made, so a VALKEY_URL edited after `service install` refused it, a 0.0.0.0 URL, or another
575
+ * account publishing on ::1 after the install cannot hand this account's jobs to someone else's queue.
576
+ *
577
+ * Only on the podman venue without `local`, on Linux (where /proc names the owner), as install, `up` and doctor judge
578
+ * it: docker's Valkey is the queue wherever `local` is blessed, published by root's docker-proxy. PI_VALKEY_SHARED is
579
+ * read from the deployment `.env` (`envText`) only; one in the worker's environment but not the file is ignored, and
580
+ * said. Returns `{ url, servername, pinned, notes }` (the URL unchanged, pinned null, when nothing is judged), or throws:
581
+ * a `configError` (exit 2, never restarted into the same answer) for a refusal, a plain Error (exit 1, restarted) when
582
+ * nothing answers within `waitMs`, since then no owner can be judged and the Valkey may still be starting.
583
+ */
584
+ export async function resolveWorkerValkey({ url, venues, platform, env, envText, envPath, probeTcp, lookup, fs, euid, user, ownerName, interfaces, subuids, waitMs = 20_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), configError: refuse }) {
585
+ // Gate round 3: judged on EVERY venue on Linux, not only podman's. Another account's listener is refused everywhere
586
+ // (item 7: a local-venue worker silently adopted another account's rootless Valkey); root's (docker-proxy) is refused
587
+ // only where the podman venue is this deployment's and `local` is not.
588
+ const rootRefused = venues.podmanUsed && !venues.localUsed;
589
+ const same = { url, servername: null, pinned: null, notes: [], rootRefused };
590
+ if (platform !== "linux") return same;
591
+ const notes = [];
592
+ let fileKeys = {};
593
+ if (envText !== null && envText !== undefined) {
594
+ // The file's BYTES through the hardened reader (gate round 3): a line systemd reads differently, or refuses to
595
+ // load, is named and refused, never taken as "no opt-in" silently.
596
+ const read = readValkeyKeys(envText, { loader: "systemd", path: envPath });
597
+ if (read.error) throw refuse(`${read.error}. The worker reads PI_VALKEY_SHARED and VALKEY_URL from it, so it does not start`);
598
+ fileKeys = read.keys;
599
+ }
600
+ if (typeof env[VALKEY_SHARED_KEY] === "string" && !Object.hasOwn(fileKeys, VALKEY_SHARED_KEY)) notes.push(`${VALKEY_SHARED_KEY} is set in the worker's environment and not in ${envPath}: ignored, since only the deployment's .env may say a Valkey is shared on purpose`);
601
+ const shared = valkeySharedOn(fileKeys[VALKEY_SHARED_KEY]);
602
+ const deadline = now() + waitMs;
603
+ for (;;) {
604
+ const verdict = await judgeValkeyListeners({ url, probeTcp, lookup, fs, euid, user, shared, rootOk: !rootRefused, ownerName, interfaces, envPath, subuids });
605
+ if (verdict.error) throw refuse(`VALKEY_URL: ${verdict.error}`);
606
+ if (verdict.remote) return { ...same, notes };
607
+ if (verdict.refusal) throw refuse(`the Valkey VALKEY_URL reaches is refused: ${verdict.refusal.text}`);
608
+ if (verdict.chosen) {
609
+ for (const other of verdict.elsewhere) notes.push(`another account also listens on an address VALKEY_URL's host resolves to: ${other}; this worker connects only to ${hostPort(verdict.chosen, verdict.port)}`);
610
+ const pinned = pinnedValkeyUrl(url, verdict.chosen);
611
+ return { url: pinned.url, servername: pinned.servername, pinned: { address: verdict.chosen, port: verdict.port, heldBy: verdict.heldBy }, notes, rootRefused };
612
+ }
613
+ if (now() >= deadline) {
614
+ if (verdict.unresolved) throw new Error(`VALKEY_URL's host ${verdict.unresolved} did not resolve here (${verdict.why}), so whose Valkey it reaches cannot be judged; the service manager retries`);
615
+ throw new Error(`nothing answers VALKEY_URL (${verdict.addresses.map((a) => hostPort(a, verdict.port)).join(", ")}), so whose Valkey it is cannot be judged; the service manager retries`);
616
+ }
617
+ await sleep(500);
618
+ }
619
+ }
620
+
621
+ /** An account's name from /etc/passwd through `fs`, or null (the owner a Valkey refusal names). */
622
+ export function passwdNameFrom(fs, uid) {
623
+ try {
624
+ for (const line of String(fs.readFileSync("/etc/passwd", "utf8")).split("\n")) {
625
+ const f = line.split(":");
626
+ if (f.length >= 3 && f[2] === String(uid) && f[0] !== "") return f[0];
627
+ }
628
+ } catch {
629
+ // Unreadable: the uid alone is named.
630
+ }
631
+ return null;
632
+ }
633
+
634
+ /** A TCP connect to `host`:`port` that settles true when something answers within `timeoutMs`, else false. */
635
+ export function probeTcpAddress(host, port, timeoutMs = 1500) {
636
+ return new Promise((resolvePromise) => {
637
+ const socket = netConnect({ host, port });
638
+ const done = (result) => {
639
+ socket.destroy();
640
+ resolvePromise(result);
641
+ };
642
+ socket.setTimeout(timeoutMs, () => done(false));
643
+ socket.on("connect", () => done(true));
644
+ socket.on("error", () => done(false));
645
+ });
646
+ }
647
+
648
+ /**
649
+ * What the stack for this deployment should hold, and why each part is or is not in it. Pure: every fact is a
650
+ * parameter, so `service` and `up` can each answer "is it listening" and "is it already installed" their own way
651
+ * and get the same rule.
652
+ *
653
+ * valkey only where `local` is NOT blessed. With `local` in the list, docker is on this host and its Valkey
654
+ * (compose, or up's docker step) is the queue, as it always was; a second Valkey on the same port would
655
+ * only fail to bind. Then `includeValkey` (the caller's own "listening / already installed" answer).
656
+ * proxy only while the egress policy is armed, and only under the default name. A PI_EGRESS_PROXY naming
657
+ * another container is the operator's own proxy: a Quadlet of that name would `podman run --replace`
658
+ * it away, so it is left alone and the reason is said.
659
+ * keeper whenever the egress policy is armed (issue #458), WHATEVER the proxy's name: the worker disconnects the
660
+ * proxy from every egress job's network, an operator's own proxy as much as ours, and that disconnect is
661
+ * what Podman 4.9 turns into a dead route out. On every Podman version, since on 5.x it is one idle
662
+ * container (measured harmless on 5.8.1), and a rule with no version read in it cannot misread one.
663
+ */
664
+ export function stackComponents({ venues, env, includeValkey, armed, valkeyPort = DEFAULT_VALKEY_PORT, valkeyPassword = null }) {
665
+ const notes = [];
666
+ const valkey = !venues.localUsed && includeValkey;
667
+ const keeper = armed === true;
668
+ let proxy = false;
669
+ if (armed) {
670
+ const name = egressProxyName(env);
671
+ if (name === DEFAULT_EGRESS_PROXY) proxy = true;
672
+ else notes.push(`PI_EGRESS_PROXY names ${name}, your own proxy: the shipped ${DEFAULT_EGRESS_PROXY} unit is not installed for it, because its \`--replace\` would remove any container of that name. Keep ${name} running under this account's Podman yourself`);
673
+ }
674
+ // Issue #468: the password the Quadlet Valkey starts with (null for none, which starts it without one, as before).
675
+ return { valkey, proxy, keeper, notes, valkeyPort, valkeyPassword: valkey ? valkeyPassword : null };
676
+ }
677
+
678
+ /**
679
+ * The files and commands for `components`, rendered from the shipped templates (`readTemplate(name)`, separate from
680
+ * `fs` because the templates are the PACKAGE's files while `fs` is the host's, and a caller's fake of the one is not a
681
+ * fake of the other). Returns `{ error }` for a path the
682
+ * proxy's mounts cannot carry, else `{ dir, files: [{ path, text, unit, state }], start: [units], actions }`, where
683
+ * `state` is "new", "same" or "changed" against what is on disk, and `actions` is exactly what `applyStack` does,
684
+ * in order: the caller shows these lines, and a test holds the two equal.
685
+ */
686
+ export function planStack({ components, templatesDir, deployDir, home, fs, readTemplate = (name) => fs.readFileSync(join(templatesDir, name), "utf8"), restartUnits = [], restartAfterValkey = [] }) {
687
+ const dir = quadletDir(home);
688
+ const picked = [];
689
+ if (components.valkey) picked.push(QUADLET_FILES.valkeyNetwork, QUADLET_FILES.valkey);
690
+ if (components.proxy) picked.push(QUADLET_FILES.egressNetwork, QUADLET_FILES.proxy);
691
+ if (components.keeper) picked.push(QUADLET_FILES.keeperNetwork, QUADLET_FILES.keeper);
692
+ const conf = proxyConfCopyPath(home);
693
+ const allowlist = join(deployDir, "egress-allowlist.conf");
694
+ if (components.proxy) {
695
+ for (const p of [conf, allowlist]) {
696
+ if (UNSAFE_VOLUME_PATH.test(p)) {
697
+ return { error: `the egress proxy's Quadlet unit would mount ${JSON.stringify(p)}, and a Quadlet Volume= cannot carry a colon, whitespace, %, $, a quote, a backslash or a control byte in a path (each is split or expanded on the way to podman run). Move the deployment folder, or start the proxy by hand (docs/podman.md)` };
698
+ }
699
+ }
700
+ }
701
+ const files = picked.map(({ file, unit }) => {
702
+ let text = String(readTemplate(file));
703
+ if (file === QUADLET_FILES.proxy.file) {
704
+ // Function replacements: a computed path is the REPLACEMENT, and String.replace reads `$&` out of a string one.
705
+ text = text.replace(`Volume=${PROXY_CONF_PLACEHOLDER}:`, () => `Volume=${conf}:`).replace(`Volume=${ALLOWLIST_PLACEHOLDER}:`, () => `Volume=${allowlist}:`);
706
+ }
707
+ if (file === QUADLET_FILES.valkey.file && Number.isInteger(components.valkeyPort) && components.valkeyPort !== DEFAULT_VALKEY_PORT) {
708
+ // Issue #464: VALKEY_URL's loopback port, where this account's own Valkey is published (the container still
709
+ // listens on 6379 inside). The template's line is pinned (TEMPLATE_PINS), so this replacement always lands.
710
+ text = text.replace(`PublishPort=127.0.0.1:${DEFAULT_VALKEY_PORT}:6379`, () => `PublishPort=127.0.0.1:${components.valkeyPort}:6379`);
711
+ }
712
+ const path = join(dir, file);
713
+ let state = "new";
714
+ if (fs.existsSync(path)) {
715
+ let current = null;
716
+ try {
717
+ current = String(fs.readFileSync(path, "utf8"));
718
+ } catch {
719
+ // Unreadable reads as changed: the write below is then what tells the operator.
720
+ }
721
+ state = current === text ? "same" : "changed";
722
+ }
723
+ // `restarts`: the unit a CHANGE to this file must restart. A .container file restarts its own unit; a .network file
724
+ // restarts nothing, because its unit runs `podman network create --ignore`, which cannot change an existing
725
+ // network, so restarting the container over it would cost the proxy's per-job networks and apply nothing.
726
+ return { path, text, unit, state, restarts: file.endsWith(".container") ? unit : null };
727
+ });
728
+ if (components.proxy) {
729
+ // The account-owned copy of the rules (E1), placed before the proxy's unit so it exists when the unit starts.
730
+ // squid reads it only at start, so a changed copy restarts the proxy, as a changed unit file does.
731
+ const text = String(readTemplate("egress-proxy.conf"));
732
+ let state = "new";
733
+ if (fs.existsSync(conf)) {
734
+ let current = null;
735
+ try {
736
+ current = String(fs.readFileSync(conf, "utf8"));
737
+ } catch {
738
+ // Unreadable reads as changed, as for the unit files.
739
+ }
740
+ state = current === text ? "same" : "changed";
741
+ }
742
+ files.splice(files.findIndex((f) => f.unit === QUADLET_FILES.proxy.unit), 0, { path: conf, text, unit: null, state, restarts: QUADLET_FILES.proxy.unit, kind: "conf" });
743
+ }
744
+ if (components.valkey) {
745
+ // Issue #468: the Valkey's password file, placed before its unit so it exists when the unit starts (its
746
+ // EnvironmentFile= is not optional). Valkey reads the password only at start, so a changed file restarts it, as a
747
+ // changed unit file does; the volume, and the queue in it, is kept across that restart. A file readable by anyone
748
+ // else counts as changed, so the write puts its mode back to 0600.
749
+ const path = valkeyEnvPath(home);
750
+ const text = valkeyEnvFileText(components.valkeyPassword);
751
+ let state = "new";
752
+ if (fs.existsSync(path)) {
753
+ let current = null;
754
+ let wide = false;
755
+ try {
756
+ current = String(fs.readFileSync(path, "utf8"));
757
+ wide = typeof fs.statSync === "function" && (fs.statSync(path).mode & 0o077) !== 0;
758
+ } catch {
759
+ // Unreadable reads as changed, as for the unit files.
760
+ }
761
+ state = current === text && !wide ? "same" : "changed";
762
+ }
763
+ files.splice(files.findIndex((f) => f.unit === QUADLET_FILES.valkey.unit), 0, { path, text, unit: null, state, restarts: QUADLET_FILES.valkey.unit, kind: "secret", mode: VALKEY_ENV_MODE });
764
+ }
765
+ const containers = files.filter((f) => f.path.endsWith(".container"));
766
+ const start = containers.map((f) => f.unit);
767
+ // A unit whose file changed is RESTARTED, not started: `start` on an active unit is a no-op, so a replaced file
768
+ // would otherwise change nothing until the next reboot while the command said it was done.
769
+ // `restartUnits`: units the caller knows are up but wrong with their files unchanged (PR #463 round 2: `up` meeting a
770
+ // Quadlet keeper that does not hold, a paused one say), where `start` would be the same no-op.
771
+ const restart = start.filter((u) => restartUnits.includes(u) || files.some((f) => f.state === "changed" && f.restarts === u));
772
+ // A keeper (re)started under a proxy that stays up is what the worker and doctor read as possible damage (issue #458,
773
+ // PR #463 round 2): a teardown while it was down cuts the proxy's route out for good, nothing outside shows it, so
774
+ // both ask for a proxy restart whenever the keeper started more than the grace (15 s) after the proxy. So a plan that
775
+ // restarts the keeper, or starts it beside a proxy whose unit file it leaves as it was (an upgrade: that proxy has
776
+ // been up all along), restarts the proxy with it. Both files new is a first install: both start together.
777
+ const keeperFile = files.find((f) => f.unit === QUADLET_FILES.keeper.unit);
778
+ const proxyFile = files.find((f) => f.unit === QUADLET_FILES.proxy.unit);
779
+ const keeperMoves = keeperFile && (restart.includes(keeperFile.unit) || keeperFile.state === "new");
780
+ if (keeperMoves && proxyFile && proxyFile.state === "same" && !restart.includes(proxyFile.unit)) restart.push(proxyFile.unit);
781
+ const fresh = start.filter((u) => !restart.includes(u));
782
+ const actions = [];
783
+ for (const f of files) if (f.state !== "same") actions.push({ kind: "write", path: f.path });
784
+ if (files.length > 0) {
785
+ // daemon-reload even when every file is unchanged: a file written by an earlier run that failed before its own
786
+ // reload is otherwise invisible to the manager, and the reload is idempotent.
787
+ actions.push({ kind: "run", argv: ["systemctl", "--user", "daemon-reload"] });
788
+ if (fresh.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "start", ...fresh] });
789
+ if (restart.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "restart", ...restart] });
790
+ }
791
+ // Issue #468: a Valkey whose password this plan changes (a first password for a deployment that had none, most often)
792
+ // is one every running client of it can no longer talk to, since the worker and the receiver read VALKEY_PASSWORD at
793
+ // start. `restartAfterValkey` names those the caller found running; they are restarted after it (`try-restart`: one
794
+ // that stopped meanwhile stays stopped). A worker restart lets its in-flight job finish first (its SIGTERM drain).
795
+ const secret = files.find((f) => f.kind === "secret");
796
+ const clients = secret && secret.state !== "same" ? restartAfterValkey : [];
797
+ if (clients.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "try-restart", ...clients] });
798
+ return { dir, files, start, restart, actions, clientsRestarted: clients };
799
+ }
800
+
801
+ /**
802
+ * What a plan that sets Valkey's password costs, said wherever one does (issue #468): the Valkey restart, and the running
803
+ * clients restarted after it. Null when the plan leaves the password file as it is.
804
+ */
805
+ export function valkeyPasswordRestartWarning(plan) {
806
+ const secret = plan.files?.find((f) => f.kind === "secret");
807
+ if (!secret || secret.state === "same") return null;
808
+ const valkeyMoves = (plan.restart ?? []).includes(QUADLET_FILES.valkey.unit);
809
+ if (!valkeyMoves) return null;
810
+ const clients = plan.clientsRestarted ?? [];
811
+ return `${QUADLET_FILES.valkey.unit} restarts with the password in ${secret.path} (the queue in its volume is kept)${clients.length > 0 ? `, and ${clients.join(" and ")} ${clients.length === 1 ? "restarts" : "restart"} after it to send that password` : ""}. A job running right now is interrupted: pause first if one is (pi-dispatch pause, wait for active jobs, then pi-dispatch resume)`;
812
+ }
813
+
814
+ /** The measured cost of restarting the proxy, said wherever a plan restarts it. */
815
+ export function proxyRestartWarning(plan) {
816
+ if (!plan.restart?.includes(QUADLET_FILES.proxy.unit)) return null;
817
+ return `restarting ${QUADLET_FILES.proxy.unit} makes a NEW proxy container (measured: the unit runs podman run --replace --rm), so a job running right now loses its only route out for the rest of that run. Pause first if one is (pi-dispatch pause, wait for active jobs, then pi-dispatch resume)`;
818
+ }
819
+
820
+ /**
821
+ * Containers this plan would REPLACE without owning them (issue #430 review). A Quadlet container unit runs
822
+ * `podman run --replace`, which removes any container of the same name, running or not: a proxy started by hand from
823
+ * docs/podman.md, with every job's per-job network on it, would vanish without a word. One rule for both commands and
824
+ * both containers: a container of the unit's name that does not carry the `PODMAN_SYSTEMD_UNIT` label naming OUR unit
825
+ * is someone else's, and the caller refuses unless told to replace it. Podman labels a container with the unit
826
+ * that started it from the `PODMAN_SYSTEMD_UNIT` variable the generated unit sets (the auto-update mechanism reads
827
+ * the same label).
828
+ *
829
+ * `runQuery(cmd, args)` resolves `{ code, stdout, stderr }`, the two streams SEPARATE (round 2, E2): podman prints
830
+ * warnings on stderr on an ordinary account ("cgroupv2 manager is set to systemd but there is no systemd user session
831
+ * available", "/ is not a shared mount"), and a merged capture made our own containers read as foreign. Only stdout
832
+ * is the label.
833
+ *
834
+ * FAILS CLOSED (round 2, E3). podman exits 125 for a container that does not exist AND for a store it cannot open
835
+ * ("database is locked"), so a non-zero exit is "absent" only when stderr says no such container or object; any other
836
+ * failure means the state is not known, and a caller must not run a `--replace` into it.
837
+ *
838
+ * Returns `{ found: [{ container, unit, label }], unknown: [{ container, detail }] }`.
839
+ */
840
+ export async function foreignContainers(plan, runQuery) {
841
+ const found = [];
842
+ const unknown = [];
843
+ for (const q of [QUADLET_FILES.valkey, QUADLET_FILES.proxy, QUADLET_FILES.keeper]) {
844
+ if (!plan.start.includes(q.unit)) continue;
845
+ const res = await runQuery("podman", ["container", "inspect", "--format", '{{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}}', q.container]);
846
+ if (res.code === 0) {
847
+ const label = String(res.stdout ?? "").trim();
848
+ if (label !== q.unit) found.push({ container: q.container, unit: q.unit, label });
849
+ continue;
850
+ }
851
+ if (res.code !== null && /no such (container|object)/i.test(String(res.stderr ?? ""))) continue;
852
+ unknown.push({ container: q.container, detail: res.code === null ? "podman could not be run" : `podman container inspect exited ${res.code} without saying the container does not exist` });
853
+ }
854
+ return { found, unknown };
855
+ }
856
+
857
+ /** The refusal for containers whose state could not be read. Not forceable: `--replace` into an unknown is a guess. */
858
+ export function unknownContainerRefusal(unknown) {
859
+ return `whether ${unknown.map((u) => u.container).join(" and ")} already ${unknown.length === 1 ? "exists" : "exist"} could not be read (${unknown.map((u) => u.detail).join("; ")}). The unit's podman run --replace would remove whatever is there, so nothing is installed until podman answers: check \`podman ps -a\` as this account, then re-run`;
860
+ }
861
+
862
+ /**
863
+ * The refusal when this process cannot reach its own user manager (round 2, E8, measured). `sudo -iu <account>` is the
864
+ * natural way to act as a dedicated account and gives neither XDG_RUNTIME_DIR nor a session bus, so every
865
+ * `systemctl --user` fails with "Failed to connect to user scope bus"; checked BEFORE anything is written, so a
866
+ * refused run leaves no Quadlet file behind that nothing loaded. `null` when either is set.
867
+ */
868
+ export function userBusRefusal({ env, user, euid }) {
869
+ // env-internal XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS: how systemctl --user finds the user manager; set by a login.
870
+ if (env?.XDG_RUNTIME_DIR || env?.DBUS_SESSION_BUS_ADDRESS) return null;
871
+ const uid = Number.isInteger(euid) ? euid : "<uid>";
872
+ return `this shell has no user manager to talk to (neither XDG_RUNTIME_DIR nor DBUS_SESSION_BUS_ADDRESS is set, as under \`sudo -iu ${user}\`), so \`systemctl --user\` would fail after the files were written. Run it from a real login as ${user}, or \`machinectl shell ${user}@\`, or, while ${user}'s manager is running (linger on), \`sudo -iu ${user} env XDG_RUNTIME_DIR=/run/user/${uid} pi-dispatch ...\``;
873
+ }
874
+
875
+ /**
876
+ * The user MANAGER's own environment, read before anything is written (PR #463 round 3, measured). Every Quadlet unit
877
+ * this installer starts, and the worker unit, runs with the manager's environment, and the Quadlet generator reads its
878
+ * unit files from the manager's XDG_CONFIG_HOME. On a host whose /etc/environment names another account's
879
+ * XDG_RUNTIME_DIR or XDG_CONFIG_HOME (Ubuntu's user managers read /etc/environment through environment.d; a GitHub
880
+ * runner image writes both), measured on Podman 4.9.3: the generator looked in the other home and the units were "not
881
+ * found" (exit 5), and with that fixed, every podman command in a unit failed "XDG_RUNTIME_DIR directory
882
+ * \"/run/user/1001\" is not owned by the current user". This installer writes to ~/.config/containers/systemd (see
883
+ * `quadletDir`), so either value being another account's makes a stack that cannot start. `read` is
884
+ * `systemctl --user show-environment`'s `{ code, stdout }`; `null` when both are this account's own or unset.
885
+ * A manager that did not answer is left to the commands that follow, whose failures are said already.
886
+ */
887
+ /**
888
+ * One value as `systemctl --user show-environment` prints it (PR #463 round 3): plain when it needs no quoting, else
889
+ * shell-quoted as `$'...'` with C escapes (systemd's shell_maybe_quote with ESCAPE_POSIX), so a home with a space reads
890
+ * `XDG_CONFIG_HOME=$'/home/a b/.config'`. Anything else is taken as printed.
891
+ */
892
+ export function unquoteShowEnvironment(value) {
893
+ const m = /^\$'(.*)'$/s.exec(value);
894
+ if (!m) return value;
895
+ return m[1].replace(/\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|.)/g, (_all, e) => {
896
+ const simple = { n: "\n", t: "\t", r: "\r", a: "\x07", b: "\b", e: "\x1b", f: "\f", v: "\v", "\\": "\\", "'": "'", '"': '"', "?": "?" };
897
+ if (e[0] === "x") return String.fromCharCode(parseInt(e.slice(1), 16));
898
+ if (/^[0-7]+$/.test(e)) return String.fromCharCode(parseInt(e, 8));
899
+ return Object.hasOwn(simple, e) ? simple[e] : e;
900
+ });
901
+ }
902
+
903
+ export function managerEnvRefusal(read, { home, euid, user, realpath = (p) => p }) {
904
+ if (read?.code !== 0) return null;
905
+ const env = {};
906
+ for (const line of String(read.stdout ?? "").split("\n")) {
907
+ const eq = line.indexOf("=");
908
+ if (eq > 0) env[line.slice(0, eq)] = unquoteShowEnvironment(line.slice(eq + 1));
909
+ }
910
+ // A symlinked home (PR #463 round 3): the same directory under two spellings is the same directory.
911
+ const same = (a, b) => {
912
+ const tidy = (p) => p.replace(/\/+$/, "");
913
+ if (tidy(a) === tidy(b)) return true;
914
+ try {
915
+ return tidy(realpath(tidy(a))) === tidy(realpath(tidy(b)));
916
+ } catch {
917
+ return false;
918
+ }
919
+ };
920
+ const wrong = [];
921
+ // env-internal XDG_RUNTIME_DIR, XDG_CONFIG_HOME: read from the user MANAGER's show-environment, never this process's.
922
+ const ownRuntime = Number.isInteger(euid) ? `/run/user/${euid}` : null;
923
+ if (env.XDG_RUNTIME_DIR !== undefined && ownRuntime !== null && !same(env.XDG_RUNTIME_DIR, ownRuntime)) wrong.push(`XDG_RUNTIME_DIR=${env.XDG_RUNTIME_DIR}, not ${ownRuntime}: every podman command in a unit would fail ("XDG_RUNTIME_DIR directory ... is not owned by the current user", measured)`);
924
+ const ownConfig = typeof home === "string" && home ? join(home, ".config") : null;
925
+ if (env.XDG_CONFIG_HOME !== undefined && ownConfig !== null && !same(env.XDG_CONFIG_HOME, ownConfig)) wrong.push(`XDG_CONFIG_HOME=${env.XDG_CONFIG_HOME}, not ${ownConfig}: the Quadlet generator would look for the units there, not in ${quadletDir(home)} where they are written, and say they do not exist (measured)`);
926
+ if (wrong.length === 0) return null;
927
+ return `${user}'s user manager runs with ${wrong.join("; and ")}. That environment is what every unit it starts inherits, so nothing is installed. Find where it is set (a line in /etc/environment, which Ubuntu's user managers read through environment.d, or a file in ~/.config/environment.d), override it for this account in ~/.config/environment.d/ (for example a file zz-pi-dispatch.conf with ${ownRuntime ? `XDG_RUNTIME_DIR=${ownRuntime}` : "XDG_RUNTIME_DIR=/run/user/<uid>"}${ownConfig ? ` and XDG_CONFIG_HOME=${ownConfig}` : ""}), restart the manager (sudo systemctl restart user@${Number.isInteger(euid) ? euid : "<uid>"}.service, which stops this account's units), check \`systemctl --user show-environment\`, and re-run`;
928
+ }
929
+
930
+ /** The refusal for `foreignContainers`' answer, naming both ways out. */
931
+ export function foreignContainerRefusal(found, { forceHint }) {
932
+ const names = found.map((f) => f.container).join(" and ");
933
+ return `${names} already ${found.length === 1 ? "exists" : "exist"} under this account's Podman and ${found.length === 1 ? "is" : "are"} not managed by the Quadlet ${found.length === 1 ? "unit" : "units"} (started by hand, or by an older setup). The unit's podman run --replace would remove ${found.length === 1 ? "it" : "them"} without asking, and a proxy takes every running job's per-job network with it. Remove ${found.length === 1 ? "it" : "them"} yourself (podman rm -f ${found.map((f) => f.container).join(" ")}), or ${forceHint}`;
934
+ }
935
+
936
+ /**
937
+ * The three stack keys as a deployment's `.env` assigns them, for the loader that reads that file (issue #430 review,
938
+ * D6). `{ keys }` (only keys the file assigns), or `{ error }` when a line touching one of them is in a form this
939
+ * reader cannot vouch for. A key with NO record is not proof of no assignment: `PI_BACKENDS =podman` is set by
940
+ * systemd 252 (measured, see env-file.mjs's ASSIGNMENT) and produces no record here, `export PI_BACKENDS=podman` is
941
+ * ignored by systemd and set by the shells, and a line inside a multi-line quote belongs to the value above it. Any
942
+ * such line, or any file-level hazard while a key is mentioned at all, refuses: guessing "no podman" installs a
943
+ * docker-shaped worker for a podman deployment, and the opposite guess a stack for a docker one. So does a line systemd
944
+ * splits differently from this reader (`envFileSystemdHazard`, issue #447), for the whole file.
945
+ */
946
+ export const STACK_KEYS = Object.freeze(["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY"]);
947
+
948
+ /** The longest venue-key value `readStackKeys` accepts, in bytes (issue #447 gate round 2; systemd's own limit is ~128 KiB). */
949
+ export const STACK_VALUE_MAX = 4096;
950
+
951
+ /** A sourcing shell's named hazard (a NUL, a CRLF file), as the sentence that names it and its fix. systemd's shapes are refused above. */
952
+ function shapedRefusal(path, hazard, why) {
953
+ const shape = SYSTEMD_HAZARD_SHAPES[hazard.shape];
954
+ return `${path} line ${hazard.line} has ${shape.what}, and ${why}, so what the service reads for it is unknown: ${shape.fix}`;
955
+ }
956
+
957
+ export function readStackKeys(content, { loader = "systemd", path = ".env", assumeSpelled = false } = {}) {
958
+ // The file's BYTES where the caller has them (issue #447, gate round 1): systemd refuses to LOAD a file with a NUL
959
+ // or with invalid UTF-8 in a key or value, and the unit then fails with every key unset, which no reading of the
960
+ // decoded text can see. Refused whatever the file assigns, because the service does not start on it at all.
961
+ const { text, loadHazard } = decodeEnvFile(content, { loader });
962
+ if (loadHazard !== null) {
963
+ const shape = SYSTEMD_HAZARD_SHAPES[loadHazard.shape];
964
+ return { error: `${path} line ${loadHazard.line} has ${shape.what}${loadHazard.detail ? ` (${loadHazard.detail})` : ""}: ${shape.fix}` };
965
+ }
966
+ const lines = String(text ?? "").split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
967
+ // A line TOUCHES a key when the key is its leading word, `export ` allowed: that is where a loader could read an
968
+ // assignment of it. A key name inside another key's value (`PI_ENV_SETUP=/opt/PI_BACKENDS.sh`) touches nothing.
969
+ const touches = new RegExp(`^[ \\t]*(?:export[ \\t]+)?(${STACK_KEYS.join("|")})(?![A-Za-z0-9_])`);
970
+ // `export K=v` is an assignment only to a loader that SOURCES the file (the macOS wrapper); systemd ignores it.
971
+ const exact = new RegExp(`^[ \\t]*${loader === "shell" ? "(?:export[ \\t]+)?" : ""}(${STACK_KEYS.join("|")})=`);
972
+ // A `#` line is a comment to every loader. A `;` line (a comment to systemd's EnvironmentFile=) needs no rule of its
973
+ // own: its leading word is `;`, never a key, so it touches nothing and is not refused (the round 2 nit).
974
+ const comment = /^[ \t]*#/;
975
+ // WHERE systemd AND THIS READER DISAGREE ABOUT WHAT A LINE IS (issue #447): a lone CR, a quote reopened after a
976
+ // close, a quoted value under a non-identifier key, a continuation the line scan misses, a quoted value whose extent
977
+ // the reader's region model gets wrong. Refused rather than modelled, for the whole file, because such a line can
978
+ // move a venue key into or out of another value anywhere below it, or split one out of the middle of a line
979
+ // (`X=1<CR>PI_BACKENDS=podman`, which the `touches` scan above never sees).
980
+ //
981
+ // GATED on a venue key being SPELLED where a loader could read it as one, which is sound: systemd builds a key only
982
+ // from contiguous text on one line, and a line that starts with `#` or `;` is a comment to it except for text after
983
+ // a lone CR, which systemd reads as a line break (`# note<CR>PI_BACKENDS=podman` sets the key, measured). So a key
984
+ // spelled on a non-comment line counts, and on a comment line only after a lone CR on that same line; a comment
985
+ // that is really the tail of a continuation is value text, split into a line again only by such a CR. Counting
986
+ // every comment spelling refused every docker deployment with an unrelated odd line, since `init` copies
987
+ // `.env.example`, which spells all three keys in comments (gate round 1), and counting them all whenever the file
988
+ // had a lone CR ANYWHERE did the same for a CR on another line (gate round 2). `assumeSpelled` is for a caller about
989
+ // to WRITE a key into this file (the setup wizard), which must refuse on any hazard before it changes a byte.
990
+ // A comment is a line that STARTS as one: a `#` line inside a value or a continuation for this loader is part of that
991
+ // value, and to a shell part of the joined line (`X=a\` + `#;PI_EGRESS=0` sets PI_EGRESS, gate round 2).
992
+ const afterLoneCr = (l) => (l.includes("\r") ? l.slice(l.indexOf("\r") + 1) : "");
993
+ const inValue = loader === "cmd" ? [] : envFileValueLines(text, { loader });
994
+ const spelledOutside = (commentLine) => (assumeSpelled ? STACK_KEYS[0] : STACK_KEYS.find((k) => lines.some((l, i) => (commentLine.test(l) && !inValue[i] ? afterLoneCr(l) : l).includes(k))));
995
+ if (loader === "systemd") {
996
+ const h = envFileSystemdHazard(text);
997
+ const spelled = h === null ? undefined : spelledOutside(/^[ \t]*[#;]/);
998
+ if (h !== null && spelled !== undefined) {
999
+ const shape = SYSTEMD_HAZARD_SHAPES[h.shape];
1000
+ return { error: `${path} line ${h.line} has ${shape.what}. The service's systemd would read the lines of this file differently from this command, so which venue keys (${STACK_KEYS.join(", ")}) it sets is unknown: ${shape.fix}` };
1001
+ }
1002
+ }
1003
+ let touched = null;
1004
+ for (let i = 0; i < lines.length; i++) {
1005
+ const line = lines[i];
1006
+ if (comment.test(line)) continue;
1007
+ const m = touches.exec(line);
1008
+ if (!m) continue;
1009
+ touched ??= { key: m[1], line: i + 1 };
1010
+ if (!exact.test(line)) {
1011
+ return { error: `${path} line ${i + 1} assigns ${m[1]} in a form other than a plain ${m[1]}=value line, which the loaders do not agree on (systemd sets \`${m[1]} =x\` and ignores \`export ${m[1]}=x\`; the shells do the opposite). Whether this deployment runs the podman venue is therefore unknown: write it as a plain ${m[1]}=value line` };
1012
+ }
1013
+ }
1014
+ if (touched) {
1015
+ // A value that OPENS with a quote continues across lines in systemd's parser until that quote closes (round 2,
1016
+ // E4: systemd's test-env-file.c, env_file_6), so a key line INSIDE such a value is part of it, not an
1017
+ // assignment. The general reader knows this too since #447; this check stays for the sentence it can say. Only a
1018
+ // key line inside a still-open region is in doubt (round 3, D1): the documented multi-line
1019
+ // GITHUB_APP_PRIVATE_KEY="-----BEGIN ...-----" closes, and a PI_BACKENDS above or below it reads normally
1020
+ // (measured on systemd 259: the unit saw both). A quote that never closes runs to the end of the file, so every
1021
+ // key line after it is in doubt.
1022
+ if (loader !== "cmd") {
1023
+ const regions = quotedRegions(text);
1024
+ for (let i = 0; i < lines.length; i++) {
1025
+ const m = touches.exec(lines[i]);
1026
+ if (!m || comment.test(lines[i])) continue;
1027
+ const inside = regions.find((r) => i + 1 > r.open && (r.close === null || i + 1 <= r.close));
1028
+ if (inside) {
1029
+ return { error: `${path} line ${i + 1} (${m[1]}) lies inside the quoted value that opens on line ${inside.open}${inside.close === null ? " and never closes" : ` and closes on line ${inside.close}`}, so the service reads it as part of that value, not as ${m[1]}. Close that quote on its own line, write the value's newlines as \\n escapes, or for a GitHub App key use GITHUB_APP_PRIVATE_KEY_PATH` };
1030
+ }
1031
+ }
1032
+ }
1033
+ const hazard = envFileHazard(text, { loader });
1034
+ if (hazard?.shape?.startsWith("shell-")) return { error: shapedRefusal(path, hazard, `the file assigns ${touched.key}`) };
1035
+ if (hazard !== null) {
1036
+ return { error: `${path} line ${hazard.line} is one this command cannot read (an open quote, a continuation, or a line that runs), and the file assigns ${touched.key}, so what the service reads for it is unknown. Fix that line first` };
1037
+ }
1038
+ } else if (loader === "shell" || (assumeSpelled && loader !== "cmd")) {
1039
+ // A sourcing shell can assign a key in the MIDDLE of a line (`X=a PI_EGRESS=0` is two assignments), which touches
1040
+ // no line at its start, so here the shell's file-level hazard is asked whenever a key is spelled on a line that is
1041
+ // not a comment (issue #447, gate round 1; the same gate as systemd's above, with `#` the shells' only comment).
1042
+ const named = spelledOutside(/^[ \t]*#/);
1043
+ const hazard = named === undefined ? null : envFileHazard(text, { loader });
1044
+ const why = assumeSpelled ? "a venue key is about to be written into it" : `the file names ${named}`;
1045
+ if (hazard?.shape?.startsWith("shell-")) return { error: shapedRefusal(path, hazard, why) };
1046
+ if (hazard !== null) {
1047
+ return { error: `${path} line ${hazard.line} is one this command cannot read (an open quote, a continuation, a line that runs, or a second assignment on one line), and ${why}, so what the service reads for it is unknown. Fix that line first` };
1048
+ }
1049
+ }
1050
+ const found = readEnvAssignments(text, STACK_KEYS, { loader });
1051
+ const keys = {};
1052
+ for (const key of STACK_KEYS) {
1053
+ const read = found[key];
1054
+ if (!read) continue;
1055
+ if (!read.plain) return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will ($, quotes, spaces or a backslash in the value), so whether this deployment runs the podman venue is unknown. Write it as a plain ${key}=value line` };
1056
+ // THE PROJECT'S OWN CAP, not systemd's (gate round 3 corrected the wording): systemd 259 passes a value up to about
1057
+ // 128 KiB, and past that the whole environment is `envFileLoadHazard`'s `exec-too-large`. A venue key names a list,
1058
+ // a switch or a container, so 4096 bytes is far more than one needs, and a longer one is a mistake worth naming.
1059
+ if (Buffer.byteLength(read.value, "utf8") > STACK_VALUE_MAX) return { error: `${path} line ${read.line} assigns ${key} a value of ${Buffer.byteLength(read.value, "utf8")} bytes, and a venue value longer than ${STACK_VALUE_MAX} bytes is refused by pi-dispatch (a venue key never needs that much; systemd itself passes up to about 128 KiB). Shorten it to at most ${STACK_VALUE_MAX} bytes` };
1060
+ keys[key] = read.value;
1061
+ }
1062
+ return { keys };
1063
+ }
1064
+
1065
+ /**
1066
+ * Keys of a deployment `.env` as the service's loader reads them, through the hardened reader (issue #464, gate round
1067
+ * 3): `content` as BYTES where the caller has them, so what systemd refuses to LOAD (a NUL, invalid UTF-8, an
1068
+ * environment too big to exec) is seen before decoding erases it (`decodeEnvFile`, issue #447's rule); then a line
1069
+ * systemd splits or joins differently from this reader (`envFileHazard`, systemd's own line structure included), when
1070
+ * the file spells one of `keys` at all; then each key's own line, which must be one every loader reads the same.
1071
+ * `{ keys }` (only keys the file assigns), or `{ error }` naming the line and the fix. Never an error dropped into
1072
+ * "unset": a key this cannot read is not a key the service lacks.
1073
+ */
1074
+ export function readServiceKeys(content, keys, { loader = "systemd", path = ".env" } = {}) {
1075
+ const { text, loadHazard } = decodeEnvFile(content, { loader });
1076
+ if (loadHazard !== null) {
1077
+ const shape = SYSTEMD_HAZARD_SHAPES[loadHazard.shape];
1078
+ return { error: `${path} line ${loadHazard.line} has ${shape.what}${loadHazard.detail ? ` (${loadHazard.detail})` : ""}: ${shape.fix}` };
1079
+ }
1080
+ if (keys.some((k) => text.includes(k))) {
1081
+ const hazard = envFileHazard(text, { loader });
1082
+ if (hazard !== null) {
1083
+ const shape = hazard.shape !== undefined ? SYSTEMD_HAZARD_SHAPES[hazard.shape] : null;
1084
+ return { error: `${path} line ${hazard.line} is one this command cannot read the way the service's loader will${shape ? ` (${shape.what}): ${shape.fix}` : " (an open quote, a continuation, or a line that runs): fix that line first"}, and the file assigns ${keys.filter((k) => text.includes(k)).join(" or ")}` };
1085
+ }
1086
+ }
1087
+ const lines = text.split("\n");
1088
+ const found = readEnvAssignments(text, keys, { loader });
1089
+ const out = {};
1090
+ for (const key of keys) {
1091
+ const read = found[key];
1092
+ if (!read) continue;
1093
+ if (!read.plain) {
1094
+ const cause = unplainCause(String(lines[read.line - 1] ?? "").replace(/\r$/, "").replace(/^[^=]*=/, ""));
1095
+ return { error: `${path} line ${read.line} assigns ${key} in a form this command cannot read the way the service's loader will (${cause})` };
1096
+ }
1097
+ out[key] = read.value;
1098
+ }
1099
+ return { keys: out };
1100
+ }
1101
+
1102
+ /**
1103
+ * VALKEY_URL, PI_VALKEY_SHARED and VALKEY_PASSWORD as a deployment's `.env` assigns them (issues #464 and #468), through
1104
+ * `readServiceKeys`: `service install`, `up`, the worker and every client judge the Valkey the SERVICE will use and
1105
+ * send the password the service will send, so they read these where the service does, and a line they cannot read is
1106
+ * refused, naming what in it is the problem.
1107
+ */
1108
+ export function readValkeyKeys(content, { loader = "systemd", path = ".env" } = {}) {
1109
+ const read = readServiceKeys(content, ["VALKEY_URL", VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY], { loader, path });
1110
+ return read.error ? { error: `${read.error}, so which Valkey the worker uses is unknown` } : read;
1111
+ }
1112
+
1113
+ /** What in a `.env` value keeps it from reading the same under every loader, with the way to write it (issue #464). */
1114
+ export function unplainCause(value) {
1115
+ const v = String(value).replace(/[ \t]+$/, "");
1116
+ const quoted = /^["']/.test(v);
1117
+ if (!quoted && /[[\]]/.test(v)) return `an unquoted [ or ]: the macOS wrapper sources the file with sh, which may read it as a filename pattern. Quote the value, for example VALKEY_URL="redis://[::1]:6379", which every loader reads the same`;
1118
+ // A control or invisible character is refused quoted or not (gate round 2 of PR #478), so quoting cannot help.
1119
+ const hidden = invisibleCharacter(v);
1120
+ if (hidden !== null) return `${hidden}, which doctor does not show back, quoted or not. Remove it`;
1121
+ if (/\$/.test(v)) return "a $, which the loaders expand differently. Write the value out";
1122
+ if (/^[ \t]/.test(v)) return "a space before the value, which the shells drop. Remove it";
1123
+ if (quoted) return "a quote that is not the whole value, or a $, \\ or ` inside double quotes. Write it as one quoted value with none of those";
1124
+ if (/\\/.test(v)) return "a backslash, which the loaders read differently. Remove it";
1125
+ if (/\s/.test(v)) return "a space in the value. Remove it, or quote the whole value";
1126
+ // Issue #477: the reader's bare set takes `=` inside a value, and refuses it at the start or after a `:`, where zsh
1127
+ // expands it; each of those, and any other character, is named as what it is.
1128
+ if (v.startsWith("=")) return "an = at the start of an unquoted value, which zsh expands as a command name. Quote the whole value";
1129
+ if (v.includes(":=")) return "a := in an unquoted value, which zsh expands as a command name. Quote the whole value";
1130
+ const bad = /[^A-Za-z0-9_@+=:,./-]/u.exec(v)?.[0];
1131
+ const shown = bad === undefined ? "" : /^[\x21-\x7e]$/.test(bad) ? `\`${bad}\` ` : `U+${bad.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")} `;
1132
+ return `a character (${shown.trim() || "one"}) outside A-Z, a-z, 0-9 and _@+=:,./- in an unquoted value. Quote the whole value`;
1133
+ }
1134
+
1135
+ /** The shown form of one action. `applyStack` runs the same objects these lines were made from. */
1136
+ export function describeAction(action) {
1137
+ return action.kind === "write" ? `write ${action.path}` : action.argv.join(" ");
1138
+ }
1139
+
1140
+ /**
1141
+ * Carry out `plan.actions`, in order, stopping at the first failure. `run(cmd, args)` resolves an exit code, null for
1142
+ * a command that could not launch. Returns `{ ok: true }` or `{ ok: false, failed, code, ran }`, `ran` saying whether
1143
+ * any command had run before the failure.
1144
+ *
1145
+ * Issue #464: `journal`, when given, is an array each write is recorded in BEFORE it happens (`journalWrite`), so a
1146
+ * caller can put every file back when a later step fails (`rollBackWrites`). `beforeRuns`, when given, is awaited
1147
+ * once after the last write and before the first command, and a `{ ok: false, ... }` from it stops the apply there:
1148
+ * `service install` writes its worker unit in it, so every file is written before anything is started.
1149
+ */
1150
+ export async function applyStack(plan, { fs, run, journal = null, beforeRuns = null }) {
1151
+ const byPath = new Map(plan.files.map((f) => [f.path, f]));
1152
+ let ran = false;
1153
+ const gate = async () => {
1154
+ if (!beforeRuns) return null;
1155
+ const hook = beforeRuns;
1156
+ beforeRuns = null;
1157
+ const res = await hook();
1158
+ return res && res.ok === false ? { ...res, ran } : null;
1159
+ };
1160
+ for (const action of plan.actions) {
1161
+ if (action.kind === "write") {
1162
+ try {
1163
+ fs.mkdirSync(dirname(action.path), { recursive: true });
1164
+ journalWrite(fs, journal, action.path, byPath.get(action.path).text, { mode: byPath.get(action.path).mode });
1165
+ } catch (err) {
1166
+ return { ok: false, failed: describeAction(action), code: null, message: err?.message, ran };
1167
+ }
1168
+ continue;
1169
+ }
1170
+ const stopped = await gate();
1171
+ if (stopped) return stopped;
1172
+ const [cmd, ...args] = action.argv;
1173
+ ran = true;
1174
+ const code = await run(cmd, args);
1175
+ if (code !== 0) return { ok: false, failed: describeAction(action), code, ran };
1176
+ }
1177
+ const stopped = await gate();
1178
+ if (stopped) return stopped;
1179
+ return { ok: true };
1180
+ }
1181
+
1182
+ /**
1183
+ * Write `text` to `path`, first recording in `journal` (when given) what was there: `{ path, existed, previous }`.
1184
+ * Recorded before the write so a write that fails part way is put back too.
1185
+ */
1186
+ export function journalWrite(fs, journal, path, text, { mode } = {}) {
1187
+ // Issue #468: a file with a `mode` (the Valkey's password file) is created with it, and an existing one is narrowed
1188
+ // to it BEFORE the new text goes in, so the password is never in a file another account may read.
1189
+ const write = () => {
1190
+ if (mode === undefined) {
1191
+ fs.writeFileSync(path, text);
1192
+ return;
1193
+ }
1194
+ if (fs.existsSync(path)) fs.chmodSync(path, mode);
1195
+ fs.writeFileSync(path, text, { mode });
1196
+ fs.chmodSync(path, mode);
1197
+ };
1198
+ if (!journal) {
1199
+ write();
1200
+ return;
1201
+ }
1202
+ let existed = false;
1203
+ let previous = null;
1204
+ if (fs.existsSync(path)) {
1205
+ existed = true;
1206
+ previous = fs.readFileSync(path);
1207
+ }
1208
+ const entry = { path, existed, previous };
1209
+ journal.push(entry);
1210
+ try {
1211
+ write();
1212
+ } catch (err) {
1213
+ // A write refused before it touched the file (EACCES, EROFS at open) left nothing to put back, and a rollback that
1214
+ // then tried to write the same path would fail the same way and report a file this run never changed. Dropped
1215
+ // from the journal only when the file reads exactly as before.
1216
+ let unchanged = false;
1217
+ try {
1218
+ unchanged = existed ? fs.existsSync(path) && String(fs.readFileSync(path)) === String(previous) : !fs.existsSync(path);
1219
+ } catch {
1220
+ // Unreadable now: kept, and the rollback says what it could not do.
1221
+ }
1222
+ if (unchanged) journal.splice(journal.indexOf(entry), 1);
1223
+ throw err;
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Put back every write in `journal`, newest first: a file that existed gets its old bytes, a new one is removed (an
1229
+ * already absent one counts as removed). Returns `{ restored, removed, left: [{ path, message }] }`, `left` being the
1230
+ * files that could not be put back and so remain as this run wrote them. Directories a write created are left: empty
1231
+ * and harmless, and one may hold files that are not ours.
1232
+ */
1233
+ export function rollBackWrites(fs, journal) {
1234
+ const restored = [];
1235
+ const removed = [];
1236
+ const left = [];
1237
+ const seen = new Set();
1238
+ for (const entry of [...(journal ?? [])].reverse()) {
1239
+ // The FIRST record of a path holds what was there before this run; a later one would hold this run's own bytes.
1240
+ const first = journal.find((e) => e.path === entry.path);
1241
+ if (seen.has(entry.path)) continue;
1242
+ seen.add(entry.path);
1243
+ try {
1244
+ if (first.existed) {
1245
+ fs.writeFileSync(first.path, first.previous);
1246
+ restored.push(first.path);
1247
+ } else {
1248
+ try {
1249
+ fs.unlinkSync(first.path);
1250
+ } catch (err) {
1251
+ if (err?.code !== "ENOENT") throw err;
1252
+ }
1253
+ removed.push(first.path);
1254
+ }
1255
+ } catch (err) {
1256
+ left.push({ path: first.path, message: err?.message ?? String(err) });
1257
+ }
1258
+ }
1259
+ return { restored, removed, left };
1260
+ }
1261
+
1262
+ /**
1263
+ * The sentence for a rollback's result, for a refusal message: what was put back and what remains. `partial` is a
1264
+ * rollback of only some of this run's files (the worker unit, after a stack command failed), whose caller names the
1265
+ * rest itself, so it never says that no file of this run remains.
1266
+ */
1267
+ export function describeRollBack(rolled, { partial = false } = {}) {
1268
+ const parts = [];
1269
+ if (rolled.removed.length > 0) parts.push(`removed ${rolled.removed.join(", ")}`);
1270
+ if (rolled.restored.length > 0) parts.push(`put back the earlier ${rolled.restored.join(", ")}`);
1271
+ const done = parts.length > 0 ? `rolled back what this run wrote (${parts.join("; ")})` : "this run had written nothing";
1272
+ if (rolled.left.length === 0) return partial ? done : `${done}; no file of this run remains`;
1273
+ return `${done}; these could NOT be put back and remain as this run wrote them: ${rolled.left.map((l) => `${l.path} (${l.message})`).join(", ")}`;
1274
+ }
1275
+
1276
+ /**
1277
+ * The worker unit's dependency lines on the stack. `Wants=`, never `Requires=`: a Valkey that failed to start must not
1278
+ * also take the worker down with it, since the worker's own queue connection retries and says why, and a proxy that
1279
+ * is down refuses jobs pre-spend by itself. `After=` so a boot starts the queue before the worker reaches for it.
1280
+ */
1281
+ export function workerUnitDeps(units) {
1282
+ if (units.length === 0) return "";
1283
+ return `# Added by \`pi-dispatch service install\` for the podman venue (issue #430): the Quadlet units it installed.\nWants=${units.join(" ")}\nAfter=${units.join(" ")}\n`;
1284
+ }
1285
+
1286
+ /**
1287
+ * Linger, read without side effects. `loginctl show-user <user> -p Linger` prints `Linger=yes|no`, and unlike
1288
+ * `systemctl --machine=<user>@ --user status` it does not START the user manager it asks about (measured: that probe
1289
+ * starts it, and the units with it, which would report a boot-time answer that is not one). Returns true, false, or
1290
+ * null when loginctl could not answer.
1291
+ */
1292
+ export async function readLinger(user, runCapture) {
1293
+ const res = await runCapture("loginctl", ["show-user", user, "-p", "Linger"]);
1294
+ if (res.code !== 0) return null;
1295
+ const m = /^Linger=(yes|no)\s*$/m.exec(String(res.output ?? ""));
1296
+ return m ? m[1] === "yes" : null;
1297
+ }
1298
+
1299
+ /** The sentence for each linger answer, shared by `service install` and `up`. */
1300
+ export function lingerNote(linger, user) {
1301
+ if (linger === true) return `linger is on for ${user}: measured, the Quadlet units come back at boot with nobody logged in\n`;
1302
+ if (linger === false) return `⚠ linger is OFF for ${user}: measured, without it neither these Quadlet units nor a user-scope worker start at boot. Turn it on: sudo loginctl enable-linger ${user}\n`;
1303
+ return `note: could not read linger for ${user} (loginctl did not answer). Without linger these units start only while you have a session: sudo loginctl enable-linger ${user}\n`;
1304
+ }