@edgehero/pi-dispatch 1.10.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +32 -5
  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 +387 -17
  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 +1395 -268
  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,367 @@
1
+ /**
2
+ * THE judge-and-pin every Valkey client of this project goes through (issue #464, gate round 2 follow-up).
3
+ *
4
+ * The owner rule (`judgeValkeyListeners`) first lived only in `service install`, `up` and doctor, then in the worker's
5
+ * boot; every client that connected elsewhere (`pi-dispatch run`, the receiver, the admin panel, doctor's own probes)
6
+ * still resolved VALKEY_URL's name itself, so during another account's `[::1]` squat a `localhost` URL carried a job's
7
+ * task text to that account's Valkey (measured on Fedora 44 with `pi-dispatch run`). So the rule lives where every
8
+ * connection is made: `connection.mjs` builds each ioredis and BullMQ client with `JudgedConnector`, which asks
9
+ * `judgedEndpoint` for the address to dial on every connect, and `makeQueue` and the worker refuse a connection that
10
+ * was not built that way. The receiver and the admin panel import `connection.mjs` from this package
11
+ * (`@edgehero/pi-dispatch/connection`), so they share this function rather than carrying a copy of it.
12
+ *
13
+ * The rule, for every client (gate round 3's simpler rule: nothing here depends on the cwd or on a venue guess):
14
+ * - VALKEY_URL's host is resolved ONCE per process and URL, as the client would; an address that is not this host's
15
+ * (another machine's Valkey) is dialled by name, as before, since pinning a remote name would defeat its DNS
16
+ * failover. A name that does not resolve, or a resolver that does not answer, is a judgement that failed and is
17
+ * retried, never "another host".
18
+ * - On Linux, a listener held by a uid that is neither this account's euid, nor one of its subordinate uids, nor
19
+ * root is refused, from any cwd, unless PI_VALKEY_SHARED=1 in the deployment's `.env` says it is shared on purpose.
20
+ * - Root's listener (docker-proxy) is refused as well only where the deployment's venue is podman without `local`
21
+ * (`valkeyClientContext`'s `rootRefused`), which is where it cannot be the deployment's queue.
22
+ * - Of this host's addresses that answer, the first this account holds is pinned (else the first acceptable one), and
23
+ * every later connect of the process goes to that literal address, its owner judged again on each one (a Valkey that
24
+ * restarts is a port another account may take meanwhile).
25
+ * A judgement that throws never ends a client: `JudgedConnector` hands ioredis a socket destroyed with the error, and
26
+ * ioredis retries by its strategy and judges again (gate round 3).
27
+ */
28
+ import { lookup as dnsLookup } from "node:dns/promises";
29
+ import { readFileSync } from "node:fs";
30
+ import { networkInterfaces, userInfo } from "node:os";
31
+ import { join } from "node:path";
32
+ import { venuesOf } from "./backends.mjs";
33
+ import { VALKEY_SHARED_KEY, judgeValkeyListeners, passwdNameFrom, pinnedValkeyUrl, probeTcpAddress, readStackKeys, readSubuidRanges, readValkeyKeys, valkeySharedOn, valkeySchemeOf } from "./podman-stack.mjs";
34
+ import { VALKEY_PASSWORD_KEY, isLoopbackHost } from "./valkey-auth.mjs";
35
+
36
+ /** A refused Valkey: tagged as the configError it is, so the worker's CLI exits 2 on it. */
37
+ export function valkeyRefusal(message) {
38
+ return Object.assign(new Error(message), { piDispatchConfig: true, valkeyRefused: true });
39
+ }
40
+
41
+ /**
42
+ * Where a client stands when its caller names no context (issue #468): `valkeyClientContext()`, the `.env` in the
43
+ * working directory, unless a process installed its own (`useValkeyContext`). The admin panel installs one built from
44
+ * the ONE `.env` reader it has (issue #471's `readDeploymentEnv`, through the worker's shared resolver, the pointer's
45
+ * folder only, and nothing from a file another account can change), so no second reader of a `.env` runs in the panel.
46
+ */
47
+ let contextProvider = null;
48
+
49
+ /** Install (or with null, remove) the function that makes the default context. */
50
+ export function useValkeyContext(fn) {
51
+ contextProvider = typeof fn === "function" ? fn : null;
52
+ }
53
+
54
+ /** The context a client gets when its caller names none. */
55
+ export function defaultValkeyContext() {
56
+ return contextProvider ? contextProvider() : valkeyClientContext();
57
+ }
58
+
59
+ /**
60
+ * Where a client stands (issue #464, gate round 3): `{ envPath, shared, rootRefused, error, password }`.
61
+ *
62
+ * The owner decision itself does not depend on this (gate round 3's simpler rule): on Linux EVERY client refuses a
63
+ * listener held by a uid that is neither this account's euid, nor one of its subordinate uids, nor root, from any cwd.
64
+ * Only two facts come from here:
65
+ * - `rootRefused`: root's listener (and one no socket row explains) is refused too where the deployment's venue is
66
+ * podman without `local`: this environment's PI_BACKENDS, else the `.env` in `cwd`. Elsewhere root's is docker's
67
+ * Valkey, published by root's docker-proxy, and is accepted. A caller that has judged its venue (the worker) passes
68
+ * it.
69
+ * - `shared`: PI_VALKEY_SHARED=1 from the `.env` in `cwd`, and from nowhere else. NEVER from the environment, even
70
+ * where no `.env` is found: the opt-in states a fact about the DEPLOYMENT (this Valkey is shared on purpose), and
71
+ * the service's worker reads it from the deployment's `.env` alone. An environment opt-in would let `pi-dispatch run`
72
+ * from a shell, or a panel or receiver started from $HOME, send the deployment's jobs into another account's queue
73
+ * that its own worker refuses; a CLI user who means it runs the command in the deployment folder, whose `.env` says
74
+ * so.
75
+ * The `.env` is read as BYTES through the hardened reader (`readStackKeys`, `readValkeyKeys`); a file that cannot be
76
+ * read, or a line they refuse, is `error`, never dropped: a client whose judged listener is not this account's own is
77
+ * then refused, naming it, since neither the opt-in nor the venue can be told.
78
+ *
79
+ * `password` (issue #468): `{ environment, file }`, VALKEY_PASSWORD from this process's environment (the service's
80
+ * loader puts the `.env` there) and from the `.env` in `cwd` (so `pi-dispatch pause` in the deployment folder needs no
81
+ * export), each null when unset or empty. Which one a client sends is `valkeyPasswordFor`'s rule. The values are never
82
+ * logged; a line may say only whether one is set.
83
+ */
84
+ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), platform = process.platform, rootRefused, readEnv = (p) => readFileSync(p) } = {}) {
85
+ const envPath = join(cwd, ".env");
86
+ let content = null;
87
+ let error = null;
88
+ try {
89
+ content = readEnv(envPath);
90
+ } catch (err) {
91
+ if (err?.code !== "ENOENT") error = `${envPath} could not be read (${err?.code ?? err?.message})`;
92
+ }
93
+ const fileKeys = {};
94
+ if (content !== null) {
95
+ const stack = readStackKeys(content, { loader: "systemd", path: envPath });
96
+ if (stack.error) error = stack.error;
97
+ else if (typeof stack.keys.PI_BACKENDS === "string") fileKeys.PI_BACKENDS = stack.keys.PI_BACKENDS;
98
+ const keys = readValkeyKeys(content, { loader: "systemd", path: envPath });
99
+ if (keys.error) error ??= keys.error;
100
+ else for (const k of [VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, "VALKEY_URL"]) if (typeof keys.keys[k] === "string") fileKeys[k] = keys.keys[k];
101
+ }
102
+ return valkeyContextFromKeys({ env, envPath, fileKeys, error, platform, rootRefused });
103
+ }
104
+
105
+ /**
106
+ * A context from issue #471's resolution of the service keys (`resolveServiceEnv`'s `{ env, fromFile, disagreements }`,
107
+ * doctor's): this shell's side is what the resolution did NOT take from the file, the file's side is what it did, or the
108
+ * file's value where the two disagree. So a password the resolution took from the file is sent as the file's, to a
109
+ * loopback Valkey only (`valkeyPasswordFor`), and a shell's stays the shell's.
110
+ */
111
+ export function valkeyContextFromResolution({ service, envPath = null, shared, platform = process.platform } = {}) {
112
+ const fileValue = (key) => service?.fromFile?.[key] ?? service?.disagreements?.find((d) => d.key === key)?.file;
113
+ const shellValue = (key) => (Object.hasOwn(service?.fromFile ?? {}, key) ? undefined : service?.env?.[key]);
114
+ const env = {};
115
+ const fileKeys = {};
116
+ for (const key of ["VALKEY_URL", VALKEY_PASSWORD_KEY]) {
117
+ if (typeof shellValue(key) === "string") env[key] = shellValue(key);
118
+ if (typeof fileValue(key) === "string") fileKeys[key] = fileValue(key);
119
+ }
120
+ if (typeof service?.env?.PI_BACKENDS === "string") env.PI_BACKENDS = service.env.PI_BACKENDS;
121
+ if (typeof shared === "string") fileKeys[VALKEY_SHARED_KEY] = shared;
122
+ return valkeyContextFromKeys({ env, envPath, fileKeys, platform });
123
+ }
124
+
125
+ /** The keys a client's context takes from a deployment `.env` (`valkeyContextFromKeys`). */
126
+ export const VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_KEY, VALKEY_SHARED_KEY, "PI_BACKENDS"]);
127
+
128
+ /**
129
+ * A context from keys a caller has already read out of the deployment's `.env` (`fileKeys`, the `VALKEY_CONTEXT_KEYS`
130
+ * it supplied) and this process's environment. `valkeyClientContext` reads the file itself; the admin panel passes what
131
+ * issue #471's resolver read, so the panel has one `.env` reader. `error` is a reason the file could not be read, as
132
+ * `valkeyClientContext`'s.
133
+ */
134
+ export function valkeyContextFromKeys({ env = process.env, envPath = null, fileKeys = {}, error = null, platform = process.platform, rootRefused } = {}) {
135
+ const fileVenue = typeof fileKeys.PI_BACKENDS === "string" ? { PI_BACKENDS: fileKeys.PI_BACKENDS } : {};
136
+ const shared = valkeySharedOn(fileKeys[VALKEY_SHARED_KEY]);
137
+ const filePassword = fileKeys[VALKEY_PASSWORD_KEY] || null;
138
+ const fileUrl = typeof fileKeys.VALKEY_URL === "string" ? fileKeys.VALKEY_URL : null;
139
+ const envPassword = typeof env[VALKEY_PASSWORD_KEY] === "string" && env[VALKEY_PASSWORD_KEY] !== "" ? env[VALKEY_PASSWORD_KEY] : null;
140
+ let venues = { localUsed: true, podmanUsed: false };
141
+ try {
142
+ venues = venuesOf({ PI_BACKENDS: typeof env.PI_BACKENDS === "string" ? env.PI_BACKENDS : fileVenue.PI_BACKENDS });
143
+ } catch {
144
+ // An unparseable list: the default venue, as doctor reads it; the worker refuses to boot on it anyway.
145
+ }
146
+ const derived = platform === "linux" && venues.podmanUsed && !venues.localUsed;
147
+ const envUrl = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : null;
148
+ return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
149
+ }
150
+
151
+ /**
152
+ * Why `url` cannot name a Valkey database, as a sentence, or null (PR #478's gate). The path of a VALKEY_URL is the
153
+ * database number (`redis://host:port/2`); anything else (`/abc`, `/0,x=y`) became `db: NaN`, and ioredis's SELECT
154
+ * then failed outside any caller's await, so doctor and every client died on "an unhandled rejection: ERR value is not
155
+ * an integer or out of range" (measured on pd-fedora). A URL that does not parse is left to the callers that already
156
+ * say so. The path is shown through `urlShown`, which never prints a credential.
157
+ */
158
+ export function valkeyUrlProblem(url) {
159
+ let u;
160
+ try {
161
+ u = new URL(String(url));
162
+ } catch {
163
+ return null;
164
+ }
165
+ // A scheme no client here speaks gets its own sentence (gate round 2: `unix:///path` was "<no host> names no
166
+ // database", and before this change it dialled 127.0.0.1, ioredis' default host).
167
+ if (valkeySchemeOf(u.protocol) === null) return `VALKEY_URL uses the scheme ${JSON.stringify(u.protocol.replace(/[^\x21-\x7e]/g, " ").slice(0, 20))}, which pi-dispatch does not connect with: write redis://host:port (or rediss://host:port for TLS; valkey:// and valkeys:// are the same two)`;
168
+ // Leading zeros are the number they spell (gate round 2): ioredis and the code before this read `/01` as database 1,
169
+ // so an upgrading deployment with one keeps its database.
170
+ if (!u.pathname || u.pathname === "/" || /^\/[0-9]{1,9}$/.test(u.pathname)) return null;
171
+ return `VALKEY_URL ${urlShown(url)} names no database: its path must be a whole number (redis://host:port/0 is database 0, and no path means the same), not ${JSON.stringify(u.pathname.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 60))}`;
172
+ }
173
+
174
+ /**
175
+ * The sentence for a VALKEY_URL whose database the server refuses to SELECT (gate round 2 of PR #478): `databases` is
176
+ * the server's count when it could be read, null when it could not, undefined when it was not asked. Measured on Valkey: `/16` on a default server answers `ERR DB
177
+ * index is out of range`, and ioredis only emitted an `error` and carried on on database 0, which may be another
178
+ * deployment's queue.
179
+ */
180
+ export function valkeyDbRangeSentence(url, db, databases) {
181
+ // `databases`: the server's count; null where it could not be read; undefined where it was not asked (a client's own
182
+ // refusal, which has only the server's answer to go on).
183
+ const has = Number.isInteger(databases) && databases > 0 ? `it has ${databases} (databases 0 to ${databases - 1}, its \`databases\` setting)` : databases === null ? "its `databases` setting could not be read" : "it answered the SELECT with \"DB index is out of range\"";
184
+ return `VALKEY_URL ${urlShown(url)} names database ${db}, which that Valkey does not have: ${has}. No client uses another database in its place; name one it has, or raise \`databases\` in that Valkey's configuration`;
185
+ }
186
+
187
+ /** The default VALKEY_URL, the worker's own (config.mjs). */
188
+ export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379";
189
+
190
+ /**
191
+ * The VALKEY_URL a command run from a deployment folder uses (PR #475's review), by the same rule as the password it
192
+ * sends: this shell's when it sets one, else the deployment `.env`'s, else the default. `pi-dispatch run`, `pause`,
193
+ * `resume`, `status`, `cancel` and `service restart --drain` read the password from `.env` already and read the URL
194
+ * from the shell alone, so in the deployment folder of a Valkey on another port they dialled 6379 with that
195
+ * deployment's password. A shell and a `.env` that disagree are NAMED in `note` (the shell's is used: it is the
196
+ * operator's explicit choice, and the kill switch must never refuse over it); a `.env` that could not be read is too.
197
+ * Returns `{ url, from, note }`, `from` naming the source ("the environment", the `.env` path, or null for the default).
198
+ */
199
+ export function valkeyUrlFor(context) {
200
+ const envUrl = context?.url?.environment ?? null;
201
+ const fileUrl = context?.url?.file ?? null;
202
+ if (typeof envUrl === "string") {
203
+ const note = typeof fileUrl === "string" && fileUrl !== envUrl ? `VALKEY_URL is ${urlShown(envUrl)} in this shell and ${urlShown(fileUrl)} in ${context.envPath}: using this shell's, while the service uses the file's` : null;
204
+ return { url: envUrl, from: "the environment", note };
205
+ }
206
+ if (typeof fileUrl === "string") return { url: fileUrl, from: context.envPath, note: null };
207
+ return { url: DEFAULT_VALKEY_URL, from: null, note: context?.error ? `${context.error}, so VALKEY_URL is the default, ${DEFAULT_VALKEY_URL}` : null };
208
+ }
209
+
210
+ /**
211
+ * The Valkey(s) a kill switch acts on (PR #475's review, rounds 2 and 3), for the CLI's `pause`, `resume`, `status` and
212
+ * `cancel` and the panel's `/dispatch pause|resume`: ONE rule, so the two surfaces cannot disagree about which queue
213
+ * "paused" is true of.
214
+ * - `flagUrl` (the CLI's `--valkey-url`) when given. Refused when it carries a password: a command line is readable by
215
+ * every account in /proc, so the password belongs in VALKEY_PASSWORD, which every client sends. When it names
216
+ * neither this shell's VALKEY_URL nor the deployment .env's, `note` says which it is.
217
+ * - else this shell's (or the process environment's, the panel's pointer layered in) and the deployment .env's
218
+ * VALKEY_URL: when both are set and differ, BOTH, with `disagreement` naming the two; the caller pauses both, shows
219
+ * both, and refuses a resume or a cancel until one is named.
220
+ * - else `valkeyUrlFor`'s one URL, with its `note`.
221
+ * Returns `{ urls, disagreement, note }` or `{ error }`. URLs in the sentences go through `urlShown`.
222
+ */
223
+ export function killSwitchValkeyUrls({ env = process.env, cwd, flagUrl = null, context: given = null } = {}) {
224
+ // `context`: a caller that has read the deployment's `.env` itself (the admin panel, issue #471) passes what it read.
225
+ const context = given ?? valkeyClientContext({ env, ...(cwd ? { cwd } : {}) });
226
+ const shell = context.url?.environment ?? null;
227
+ const file = context.url?.file ?? null;
228
+ if (flagUrl !== null && flagUrl !== undefined) {
229
+ let u;
230
+ try {
231
+ u = new URL(String(flagUrl));
232
+ } catch {
233
+ return { error: `--valkey-url is not a URL (redis://host:port): ${urlShown(flagUrl)}` };
234
+ }
235
+ if (u.password || u.username) {
236
+ return { error: `--valkey-url carries a password (or a user): a command line is readable by every account on this host in /proc, so the URL is refused. Put the password in ${VALKEY_PASSWORD_KEY} (this shell or ${context.envPath}), and give --valkey-url ${urlShown(flagUrl)}` };
237
+ }
238
+ const known = [shell, file].filter((x) => typeof x === "string").map(urlShown);
239
+ const note = known.includes(urlShown(flagUrl)) ? null : `using --valkey-url ${urlShown(flagUrl)}, which is neither this shell's VALKEY_URL (${typeof shell === "string" ? urlShown(shell) : "unset"}) nor ${context.envPath}'s (${typeof file === "string" ? urlShown(file) : "unset"})`;
240
+ return { urls: [String(flagUrl)], disagreement: null, note };
241
+ }
242
+ if (typeof shell === "string" && typeof file === "string" && shell !== file) {
243
+ return { urls: [shell, file], disagreement: `VALKEY_URL is ${urlShown(shell)} in this shell and ${urlShown(file)} in ${context.envPath} (what the service uses)`, note: null };
244
+ }
245
+ const resolved = valkeyUrlFor(context);
246
+ return { urls: [resolved.url], disagreement: null, note: resolved.note };
247
+ }
248
+
249
+ /**
250
+ * A URL as doctor, the CLI and the panel may print it (issue #453, gate round 3 and the re-review; moved here in PR #475's
251
+ * review so every surface prints a Valkey URL through ONE function): scheme, host, port and database only, never the
252
+ * userinfo (a password or a username that is a token), the query (`password=`, a token) or the fragment; `<no host>`
253
+ * without a host, and `<unparseable URL>` when it does not parse, since then nothing can say where a credential in it
254
+ * starts or ends. Control characters are blanked: another party's value can reach this line.
255
+ */
256
+ export function urlShown(url) {
257
+ let parsed;
258
+ try {
259
+ parsed = new URL(String(url));
260
+ } catch {
261
+ return "<unparseable URL>";
262
+ }
263
+ if (!parsed.hostname) return "<no host>";
264
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname && parsed.pathname !== "/" ? parsed.pathname : ""}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
265
+ }
266
+
267
+ /**
268
+ * The password a client of `url` sends, and where it came from (issue #468): `{ password, from }`, both null for none.
269
+ * - the URL's own userinfo first: an operator's VALKEY_URL with a password (a managed Valkey) is left as it is;
270
+ * - else VALKEY_PASSWORD from the environment, for any host (the service's loader, compose's receiver container
271
+ * dialling `valkey:6379`, or an operator's export);
272
+ * - else VALKEY_PASSWORD from the deployment `.env`, and only for a loopback host: the password pi-dispatch generates
273
+ * belongs to the Valkey it starts on this machine, and is never sent to another one a shell's VALKEY_URL names.
274
+ * `withoutPassword` is for a probe asking whether a Valkey answers clients that send none (doctor, `up`).
275
+ */
276
+ export function valkeyPasswordFor(url, context, { withoutPassword = false } = {}) {
277
+ if (withoutPassword) return { password: null, from: null };
278
+ let u;
279
+ try {
280
+ u = new URL(url);
281
+ } catch {
282
+ return { password: null, from: null };
283
+ }
284
+ if (u.password) return { password: decodedUserinfo(u.password), from: "VALKEY_URL" };
285
+ const pw = context?.password ?? {};
286
+ if (pw.environment) return { password: pw.environment, from: "the environment" };
287
+ if (pw.file && isLoopbackHost(u.hostname)) return { password: pw.file, from: context.envPath ?? ".env" };
288
+ return { password: null, from: null };
289
+ }
290
+
291
+ /** A URL's userinfo part as the Valkey sees it: percent-decoded (`p%40ss` is `p@ss`), as written when it does not decode. */
292
+ export function decodedUserinfo(part) {
293
+ try {
294
+ return decodeURIComponent(part);
295
+ } catch {
296
+ return part;
297
+ }
298
+ }
299
+
300
+ let hostFacts = null;
301
+ /** This host's facts for the judge, read once per process: the euid, the account name and its subordinate ranges. */
302
+ function defaultHostFacts() {
303
+ if (hostFacts) return hostFacts;
304
+ const fs = { readFileSync };
305
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : null;
306
+ let user = null;
307
+ try {
308
+ user = userInfo().username;
309
+ } catch {
310
+ // A uid with no passwd entry: its subordinate ranges are read by uid.
311
+ }
312
+ hostFacts = { fs, euid, user, subuids: readSubuidRanges({ user, euid, fs }), probeTcp: probeTcpAddress, lookup: (host, opts) => dnsLookup(host, opts), interfaces: networkInterfaces, ownerName: (uid) => passwdNameFrom(fs, uid) };
313
+ return hostFacts;
314
+ }
315
+
316
+ /**
317
+ * The address a client of `url` connects to, judged (see the header): `{ host, servername, pinned }`, `pinned` the
318
+ * literal address when one was judged, null for another machine's Valkey dialled by name. Throws `valkeyRefusal` for a
319
+ * refused Valkey, and a plain Error (`retryable`) when nothing answers or the name does not resolve: the client retries,
320
+ * and the next connect judges again. `cache` holds the pinned address per URL for the life of the process: resolved
321
+ * once, re-judged on every connect.
322
+ */
323
+ export async function judgedEndpoint(url, context, { cache = PINNED, facts = defaultHostFacts() } = {}) {
324
+ const key = `${url}\u0000${context.rootRefused}\u0000${context.shared}`;
325
+ const known = cache.get(key);
326
+ const judge = (target) => judgeValkeyListeners({ url: target, probeTcp: facts.probeTcp, lookup: facts.lookup, fs: facts.fs, euid: facts.euid, user: facts.user, shared: context.shared, rootOk: !context.rootRefused, ownerName: facts.ownerName, interfaces: facts.interfaces, envPath: context.envPath, subuids: facts.subuids });
327
+ const verdict = await judge(known ? pinnedValkeyUrl(url, known).url : url);
328
+ if (verdict.error) throw valkeyRefusal(`VALKEY_URL: ${verdict.error}`);
329
+ if (verdict.unresolved) throw retryable(`VALKEY_URL's host ${verdict.unresolved} did not resolve here (${verdict.why}), so whose Valkey it reaches cannot be judged yet`);
330
+ if (verdict.remote) return { host: verdict.remote, servername: null, pinned: null };
331
+ if (verdict.refusal) throw valkeyRefusal(`the Valkey VALKEY_URL reaches is refused: ${verdict.refusal.text}`);
332
+ const address = verdict.chosen;
333
+ if (!address) throw retryable(`nothing answers VALKEY_URL (${verdict.addresses.join(", ")}:${verdict.port}), so whose Valkey it is cannot be judged yet`);
334
+ // A listener this account does not hold, taken on a fact of the `.env` (the opt-in, or a venue that accepts root's),
335
+ // is refused when that file could not be read: neither fact can then be told (gate round 3, item 5).
336
+ if (!verdict.own && context.error) throw valkeyRefusal(`the Valkey VALKEY_URL reaches is held by ${verdict.heldBy}, not by this account, and ${context.error}, so whether it may be used cannot be told`);
337
+ if (!known) cache.set(key, address);
338
+ return { host: address, servername: pinnedValkeyUrl(url, address).servername, pinned: address };
339
+ }
340
+
341
+ /** A judgement that may succeed later (nothing answers yet, a name that does not resolve yet): retried, never final. */
342
+ function retryable(message) {
343
+ return Object.assign(new Error(message), { valkeyRetryable: true });
344
+ }
345
+
346
+ /**
347
+ * Judge `url` before a long-running process builds anything on it (the receiver, the poller, the CLI; issue #464, gate
348
+ * round 3): the refusal thrown at once (a configError: exit 2, as the worker's boot does), a retryable judgement retried
349
+ * every 500 ms for `waitMs`, then thrown as the plain Error it is (exit 1: the service manager starts it again).
350
+ */
351
+ export async function judgeValkeyAtStart(url, context, { waitMs = 20_000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), judge = judgedEndpoint } = {}) {
352
+ // PR #478's gate: a path that names no database is a refusal (exit 2 at a start), said before anything connects.
353
+ const dbProblem = valkeyUrlProblem(url);
354
+ if (dbProblem) throw valkeyRefusal(dbProblem);
355
+ const deadline = now() + waitMs;
356
+ for (;;) {
357
+ try {
358
+ return await judge(url, context);
359
+ } catch (err) {
360
+ if (!err?.valkeyRetryable || now() >= deadline) throw err;
361
+ }
362
+ await sleep(500);
363
+ }
364
+ }
365
+
366
+ /** The per-process pins, by URL and context. Exported for the worker to seed with its boot judgement, and for tests. */
367
+ export const PINNED = new Map();
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The stop handle every live-edit directory watch hands back. Its own IMPORT-FREE module (issue #301),
3
+ * because BOTH composition roots register one: `startWorker` in the `extraClosers` list that already
4
+ * closes the queues and the host registry (`index.mjs` -> shutdown), and the receiver in the `closers`
5
+ * array its own shutdown drains. `transient.mjs` set the shape (one import-free rule module in
6
+ * `worker/src`, never copied into the receiver) but reaches the receiver indirectly, re-exported by the
7
+ * identity modules; this one rides the exports map itself as `./watch-closer`, because its consumer is
8
+ * the receiver's composition root and no re-exporting module sits between. The two roots' `log` shapes differ -- the
9
+ * worker's takes `(event, fields)`, the receiver's one object -- so the receiver hands this factory an
10
+ * adapter at its call site rather than this module growing a second signature.
11
+ *
12
+ * A WATCH NOTHING CAN CLOSE IS NOT A DETAIL (issue #295). `watch(dir, cb).unref?.()` retained nothing, so
13
+ * the watch outlived the worker that armed it, and the reload it later fired ran through THAT boot's
14
+ * `log` closure: that boot's injected `write`, stamped with that boot's `workerName`. One process running
15
+ * one worker, that is a rounding error at exit. One process running forty boots, which is what a test file
16
+ * is, and a worker that shut down two tests ago writes into a live worker's capture under a host that is
17
+ * not running -- `every log line carries the host` went red in CI reading `runnervmejwal` where it
18
+ * asserted `mac-mini-1`.
19
+ *
20
+ * UNREF'D IS NOT CLEANED UP, and that difference is what hid this across three features. `unref` says only
21
+ * that a handle will not hold the event loop open; the watch stays armed either way.
22
+ * `INT-HOST-REGISTRY-CONTRACT` states the same distinction from the opposite side, where a bound's timer
23
+ * is deliberately NOT unref'd because an unref'd timer does not fire when the hung command is the last
24
+ * thing holding the loop.
25
+ *
26
+ * Three properties, each one a way the shutdown breaks without it:
27
+ *
28
+ * - It CLOSES the FSWatcher, which is the leak itself.
29
+ * - It CANCELS the debounce the watcher already armed. Closing a watcher does not cancel a `setTimeout`
30
+ * the callback already set, and only the watcher was ever unref'd -- the 150ms timer never was. In a
31
+ * real worker that costs nothing, because the shutdown ends in `process.exit(0)` either way; it is the
32
+ * harness, where the loop is left to drain on its own, that the stray timer reaches.
33
+ * - Its `close()` NEVER THROWS for the handles its three callers build, and is idempotent -- by NULLING
34
+ * what it closed rather than by an early return, which would be a guard with nothing behind it. Node's
35
+ * own `FSWatcher.close()` is already both (measured: a second close returns early and neither throws),
36
+ * but this closer must not
37
+ * INHERIT that guarantee, it must MAKE it: the shutdown loop in `index.mjs` cannot catch a SYNCHRONOUS
38
+ * throw from a closer, and the comment there carries the argument. The swallow is
39
+ * `makeHostRegistry.close`'s posture rather than a new one.
40
+ *
41
+ * A watch that was never created -- the `catch` arm of each function below, a platform without `fs.watch`
42
+ * -- still gets a closer, so registration is unconditional and the list's shape never depends on the
43
+ * platform. That is why all three return from OUTSIDE their try/catch.
44
+ *
45
+ * WHAT IT CANNOT DO, because the list above would otherwise read as complete: cancel a reload that has
46
+ * ALREADY started. `reloadSchedules` is async and awaits a Valkey round trip, so a debounce that fired
47
+ * just before the close is still running after it -- and the watchers stay armed for the whole drain
48
+ * ahead of the closer loop, not merely 150ms. That reload cannot be recalled, so what is gated instead is
49
+ * its VOICE: `reloadLog` below goes quiet once `closed` is set, and every reload is handed that instead
50
+ * of the boot's own `log`, which is what the
51
+ * issue actually asks for -- a stopped worker writes no line. The reload's own Valkey work may still be
52
+ * cut off mid-flight by the queue closing beside it, leaving a scheduler set the next boot's reconcile
53
+ * repairs; that race predates this change and is not narrowed by it.
54
+ *
55
+ * EXPORTED for the reason `reloadScopedLimits` is: none of the three properties is observable through a
56
+ * real `fs.watch` without racing the filesystem, and a guarantee the shutdown rests on deserves a
57
+ * deterministic pin rather than a sleep.
58
+ */
59
+ export function makeWatchCloser(handles, log) {
60
+ return {
61
+ // The reload's voice, and the reason this factory is handed the boot's `log` rather than only its
62
+ // handles. A reload already in flight cannot be recalled, so what the close gates is what it can
63
+ // still SAY: after `closed`, a line from this watch would carry the host of a worker that has
64
+ // stopped, which is the bleed the issue is about. The arming lines keep the real `log` -- they run
65
+ // before any close.
66
+ reloadLog: (event, fields) => {
67
+ if (!handles.closed) log(event, fields);
68
+ },
69
+ close() {
70
+ // `closed` FIRST, before anything is torn down: it is what gates `reloadLog` above and the watch
71
+ // callback below, so a callback or a reload landing mid-close is already silenced.
72
+ handles.closed = true;
73
+ clearTimeout(handles.timer);
74
+ handles.timer = null;
75
+ try {
76
+ handles.watcher?.close();
77
+ } catch {
78
+ // A close that failed has already stopped mattering, and a THROW here rejects the shutdown.
79
+ }
80
+ handles.watcher = null;
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The debounce every live-edit watch in this project uses, in one place (issue #386).
87
+ *
88
+ * The literal 150 was written four times -- the receiver's triggers watch and the worker's triggers,
89
+ * pause-windows and scoped-limits watches -- and four copies of a number is the shape `CLAUDE.md` warns
90
+ * about: they agree until one of them does not. It rides this module because this module is already what
91
+ * both composition roots import for the same four watches.
92
+ *
93
+ * WHY A DEBOUNCE AT ALL, since the number alone does not say: an atomic tmp+rename delivers more than one
94
+ * directory event for one edit, and the panel's own writer renames. The window only has to outlast that
95
+ * burst, which is why it is small enough that an operator never notices it.
96
+ */
97
+ export const WATCH_DEBOUNCE_MS = 150;
98
+
99
+ /**
100
+ * Close the BOOT RACE that every one of those four watches had (issue #386).
101
+ *
102
+ * Each service reads its file once at boot and arms the watch afterwards. Nothing re-reads in between, so
103
+ * an edit landing in that gap is never loaded: it waits for the NEXT edit, or for a restart. The gap is not
104
+ * instantaneous -- in the receiver the watch is deliberately the last fallible step of the boot, behind
105
+ * identity resolution, which retries for up to `RECEIVER_IDENTITY_RETRY_SECONDS`.
106
+ *
107
+ * ON MACOS THE GAP EXTENDS PAST `watch()` RETURNING, which is what makes this a lost edit rather than a
108
+ * late one. libuv answers `fs.watch` from `uv__fsevents_init`, which queues the path and signals its
109
+ * CoreFoundation thread; that thread destroys the process's single FSEvents stream and creates a new one
110
+ * covering every watched path, starting from "now" (libuv 1.49.2, the version Node 23.5 bundles). An edit
111
+ * that lands before the new stream is live is not delivered late, it is not delivered at all. Measured
112
+ * against the real receiver boot, ISSUE #386's own measurement: 24 of 40 trials lost the edit with four
113
+ * boots in one process, 1 of 40 with one boot beside a loaded machine, 0 of 40 idle. A re-measure on
114
+ * another machine reproduced the SHAPE and not the rate (1 of 40, 0 of 40, 0 of 40), so read the 24 as one
115
+ * host's worst case rather than a constant; the loss itself is real on both. Linux's inotify registers
116
+ * before `fs.watch` returns, so there an edit can be late but not lost.
117
+ *
118
+ * The answer is one read AFTER arming, compared against what the boot read, and a reload only when they
119
+ * differ -- so a quiet boot stays quiet and costs one stat-and-read per watch.
120
+ *
121
+ * WHAT IT DOES NOT CLOSE, because a helper that reads as complete is worse than one that states its edge.
122
+ * The FSEvents window above extends past this read too, so an edit landing between this read and the moment
123
+ * the stream goes live is still lost: this closes the boot-load-to-arming window and does not make the
124
+ * other zero, and closing that one needs a watch that reports when it is live, which `fs.watch` does not
125
+ * offer. And a file DELETED and recreated between the two reads is not seen, because an unreadable read on
126
+ * either side is treated as no change -- right for a file that went away, since every reload path keeps
127
+ * last-good, and wrong for one that arrived. All three loaders refuse a configured-but-missing file at
128
+ * boot, so reaching that means deleting and recreating inside the window.
129
+ */
130
+ export function readBeforeArming(handles, read, atBoot = undefined) {
131
+ // `atBoot` is what the BOOT LOADER read, and it is the only baseline that measures the right window. A
132
+ // baseline read here instead measures the microseconds around the arming: the first version of this
133
+ // helper did exactly that and closed 0.1 to 0.4 milliseconds while the window the race lives in -- boot
134
+ // load to arming, which in the receiver holds identity resolution and its retries -- stayed open, proven
135
+ // end to end on both services. `undefined` means the caller has no boot read to hand over and accepts
136
+ // the narrower window; every caller in this project hands one over.
137
+ handles.armedWith = atBoot === undefined ? safeRead(read) : typeof atBoot === "string" ? atBoot : null;
138
+ }
139
+
140
+ /** True when the file changed between `readBeforeArming` and now, and there is a watch to have missed it. */
141
+ export function changedWhileArming(handles, read) {
142
+ // No watcher means the arming THREW, and the service logged that it is running without a live reload.
143
+ // Re-reading there would paper over that with one lucky read.
144
+ if (!handles.watcher) return false;
145
+ const now = safeRead(read);
146
+ // An unreadable file on either side is not a change: the reload paths all keep last-good on a bad read,
147
+ // and firing one here would only replace a good in-memory value with the same keep-last-good outcome.
148
+ return now !== null && handles.armedWith !== null && now !== handles.armedWith;
149
+ }
150
+
151
+ function safeRead(read) {
152
+ try {
153
+ const v = read();
154
+ return typeof v === "string" ? v : null;
155
+ } catch {
156
+ return null; // a file that is absent or unreadable at boot is the loaders' business, not this one's
157
+ }
158
+ }