From 24fd92382a13eb062f93d332aec336dd1f10a042 Mon Sep 17 00:00:00 2001 From: Rob Boerman Date: Mon, 28 Sep 2026 23:08:01 +0200 Subject: [PATCH 1/2] fix(worker): the Valkey pi-dispatch starts gets a per-deployment password (#468) The Valkey pi-dispatch starts (the Quadlet unit, the compose file and up's docker run) had no password. Loopback is shared by every account on a host, so any local account could read another account's queue, enqueue jobs its worker would run with that account's provider key, or wipe it. - A per-deployment VALKEY_PASSWORD in .env, separate from VALKEY_URL (which doctor prints and the panel's pointer stores). init writes one and creates .env at 0600; service install and up add one when missing, never over a value, and narrow .env to its owner. None is generated for a shared Valkey or an operator's own VALKEY_URL. - Never on a command line: the image's PID 1 keeps its whole argv, so --requirepass shows in /proc to every account (measured on both VMs). The password reaches the container in its environment (a 0600 valkey.env for the Quadlet unit, --env-file .env for compose, the docker CLI's own environment for up), and one start script hands it to valkey-server on stdin and unsets it. The health check requires PONG with REDISCLI_AUTH. - Every client attaches it through parseConnection. A failed command's arguments are stripped from every error any client builds, at emit, silentEmit and each command's reject, and every Queue and Worker logs errors by message only, so a rotation never prints the password; the entry points print an unhandled rejection by its message. - A Valkey that refuses the credential makes every entry point refuse by name; a loopback Valkey with no password is a doctor warning naming the upgrade. service install --force and up restart the Valkey with the password on its volume, warning that a running job is interrupted. - The CLI reads VALKEY_URL like the password (shell, else .env). On a disagreement pause pauses both queues, status shows both, and resume and cancel refuse until --valkey-url names one; URLs print without userinfo. - Compose publishes Valkey on VALKEY_URL's port (PI_VALKEY_PORT). The wizard's compose copy keeps its old project name with -p, and when up's Valkey exists it hands that volume to compose (pull first, every exit code checked, the way back named), so worker and receiver share one queue; up then starts compose's Valkey in such a folder. - A Valkey container or volume counts as this deployment's only when it provably is: up labels what it creates with the deployment folder, an unlabelled container counts only when it publishes this deployment's port, an unlabelled volume is adopted only on an explicit yes (never --yes alone), and a pi-dispatch:owner marker inside the Valkey is checked on every start. Nothing stops, removes or starts beside another deployment's Valkey or on its volume. PI_VALKEY_PORT is written into .env for a non-default port; the admin panel's pause and resume follow the CLI's kill-switch rule. - On #471's rule (doctor hands no program anything from .env): doctor starts no Valkey any more; its --fix offer for an unreachable loopback Valkey is gone, since that docker run needed the password in the docker CLI's environment, and the fix line names pi-dispatch up. Doctor's own in-process AUTH check uses the resolved password; VALKEY_PASSWORD and PI_VALKEY_PORT are service keys, the password a secret shown by name only. The panel's Valkey clients and kill switch read the pointer's .env through #471's one reader. - Found on the way: a password in VALKEY_URL reached the client still percent-encoded; the .env writer's temp file was world-readable until its chmod. Specs: REQ-DEPLOYMENT-BOOTSTRAP, REQ-OPERATOR-JOB-CANCEL, DES-PODMAN-STACK-AS-QUADLET-UNITS, DES-WORKER-ON-HOST, DES-FIRST-RUN-SETUP-WIZARD and INT-DEPLOYMENT-POINTER-CONTRACT amended; the rest named UNCHANGED, checked in the revision rows. Signed-off-by: Rob Boerman --- .env.example | 14 +- README.md | 13 +- SECURITY.md | 2 +- admin/src/deployment-pointer.mjs | 3 + admin/src/index.ts | 81 ++- admin/src/read-model.mjs | 65 +- admin/src/setup-wizard.ts | 252 ++++++- admin/test/deployment-pointer.test.mjs | 12 + admin/test/read-model.test.mjs | 62 ++ admin/test/setup-wizard.test.mjs | 303 +++++++- admin/test/wiring.test.mjs | 5 +- deploy/docker-compose.yml | 37 +- deploy/pi-dispatch-valkey.container | 19 +- deploy/worker.service | 2 +- docs/azure-devops.md | 2 +- docs/backup.md | 6 +- docs/egress.md | 8 +- docs/forgejo.md | 2 +- docs/gitlab.md | 2 +- docs/podman.md | 64 +- docs/secrets.md | 12 +- launch/demo.md | 2 +- receiver/src/cli.mjs | 4 +- receiver/src/start.mjs | 3 + specs/constitution.md | 1 + specs/design.md | 80 ++- specs/interfaces.md | 8 + specs/requirements.md | 63 +- worker/.env.example | 14 +- worker/deploy/docker-compose.yml | 37 +- worker/deploy/pi-dispatch-valkey.container | 19 +- worker/deploy/worker.service | 2 +- worker/package.json | 1 + worker/src/cancel-cli.mjs | 6 +- worker/src/cli.mjs | 230 ++++--- worker/src/config.mjs | 15 +- worker/src/connection.mjs | 199 +++++- worker/src/doctor.mjs | 177 +++-- worker/src/env-file.mjs | 20 +- worker/src/exit-code.mjs | 15 + worker/src/index.mjs | 5 +- worker/src/init.mjs | 15 +- worker/src/podman-stack.mjs | 92 ++- worker/src/processor.mjs | 2 +- worker/src/queue.mjs | 7 +- worker/src/sandbox.mjs | 2 +- worker/src/service.mjs | 110 ++- worker/src/start.mjs | 24 +- worker/src/up.mjs | 507 ++++++++++++-- worker/src/valkey-auth.mjs | 452 ++++++++++++ worker/src/valkey-endpoint.mjs | 187 ++++- worker/test/config.test.mjs | 15 + worker/test/doctor.test.mjs | 218 +++--- worker/test/env-docs.test.mjs | 4 +- worker/test/init.test.mjs | 33 +- worker/test/podman-stack.test.mjs | 183 ++++- worker/test/processor.test.mjs | 2 +- worker/test/start-wiring.test.mjs | 17 + worker/test/up.test.mjs | 409 ++++++++++- worker/test/valkey-auth.test.mjs | 760 +++++++++++++++++++++ worker/test/valkey-clients.test.mjs | 32 +- 61 files changed, 4427 insertions(+), 511 deletions(-) create mode 100644 worker/src/valkey-auth.mjs create mode 100644 worker/test/valkey-auth.test.mjs diff --git a/.env.example b/.env.example index 13a3dff1..30f8f016 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,18 @@ PI_CONCURRENCY=3 # --- Infrastructure --- VALKEY_URL=redis://127.0.0.1:6379 +# The Valkey's password, per deployment: `pi-dispatch init` writes a new one here (and creates this file readable by you +# alone), and `up` / `service install` add one to a deployment that has none and restart its Valkey with it (the queue is +# kept). Every client sends it: the worker, the receiver, the CLI, the panel and doctor. Without it any local account can +# read, enqueue or delete this deployment's jobs. Hex or base64url only (A-Z a-z 0-9 - _), at least 16 characters. +# A key of its own, never inside VALKEY_URL, which doctor prints and the panel's pointer file stores; a VALKEY_URL with +# its own password (a managed Valkey) is left as it is, and that password wins. With PI_VALKEY_SHARED=1 below, set it to +# the password of the account whose Valkey it is. docker compose reads it with --env-file .env (docs/podman.md, README). +# VALKEY_PASSWORD= +# docker compose only: the port deploy/docker-compose.yml publishes its Valkey on, 127.0.0.1:. Default 6379. Keep it +# equal to VALKEY_URL's port: `pi-dispatch up` and /dispatch setup write it here when that is not 6379 (never over a value +# already set), and doctor flags one that disagrees. Not read by the worker, the receiver or the CLI, which dial VALKEY_URL. +# PI_VALKEY_PORT= # podman venue: the worker (at every start), `service install`, `up` and doctor take a Valkey that answers VALKEY_URL only when THIS account # (or one of its containers) holds it. One held by another account, by root (docker-proxy, a rootful container) or by a # system service is refused, naming its owner: give this account its own port instead (VALKEY_URL=redis://127.0.0.1:6380). @@ -269,7 +281,7 @@ PI_SCHEDULER_STALL_MAX=2 # --- Egress policy: what a job container may reach on the network (docs/egress.md) --- # ON by default. Every job runs on its own --internal Docker network whose only other member is an allowlist # proxy, with no route off this host, and a job whose policy cannot serve it is refused BEFORE it spends a slot. -# START THE PROXY: docker compose -f deploy/docker-compose.yml --profile egress up -d +# START THE PROXY: docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d # (on the rootless podman backend, `pi-dispatch up` or `pi-dispatch service install` starts it as a Quadlet unit instead) # Until you do, every job is refused pre-spend (loud, free, and naming that command). The hosts live in # egress-allowlist.conf next to this file (`pi-dispatch init` writes it, and never overwrites it). diff --git a/README.md b/README.md index 6c419f89..0d741342 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ The same setup as plain commands. No clone needed: mkdir my-dispatch && cd my-dispatch npx @edgehero/pi-dispatch up # one consented pass: pulls the job image, starts Valkey, # scaffolds the config files, fills in the .env lines that have - # no value yet, runs the doctor preflight. + # no value yet (VALKEY_PASSWORD among them: the queue's own + # password, so no other account on the host can read it), + # runs the doctor preflight. # Every docker action shows its command and asks first; the .env # lines are filled without a prompt, and never overwritten. # edit .env and set your provider key. Already logged into pi with an API key? Leave it blank. @@ -669,10 +671,15 @@ which is what it offers right after the credentials step): 1. **Webhook receiver on the host** (lowest latency): `npx pi-dispatch-receiver` from your deployment folder, or `pi-dispatch service install --receiver` to run it as a user-level service. Your reverse proxy or tunnel does the public exposure. -2. **Webhook receiver in a container**: `docker compose -f deploy/docker-compose.yml --profile receiver +2. **Webhook receiver in a container**: `docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d` runs the prebuilt [`ghcr.io/edgehero/pi-dispatch-receiver`](https://github.com/edgehero/pi-dispatch/pkgs/container/pi-dispatch-receiver) - beside Valkey, triggers mounted read-only, no docker socket anywhere. + beside Valkey, triggers mounted read-only, no docker socket anywhere. In a folder `/dispatch setup` built, the + compose file sits in `deploy/` of that folder, and the command it runs names the folder's own project, `-p ` (lower case, only letters, digits, `_` and `-`), and, where `pi-dispatch up` had already started the + deployment's Valkey, `-f deploy/docker-compose.valkey.yml`, which gives compose's Valkey that same volume so the + worker and the receiver share one queue. Run it the same way afterwards. Setup hands over only a + `pi-dispatch-valkey` that is this folder's, and refuses while another deployment's Valkey uses that volume. 3. **No public URL at all**: `pi-dispatch-receiver poll` fetches issue events, comments and PRs over TLS with your own credential (conditional requests, nearly free against the rate limit). Same gates, same queue, about 60 seconds of latency, zero public surface. Pair with `setup github --no-webhook`. A diff --git a/SECURITY.md b/SECURITY.md index df5b9486..f99680e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -137,7 +137,7 @@ Jobs are a **trigger × target** matrix, and the triggers do not share a threat prefer (its own bullet below states the consequences). One implementation detail, recorded because the code records it: Azure's constant-time compare returns early on a length mismatch, so the secret's **length** leaks even though its content does not. - The receiver may run **containerised** (`docker compose -f deploy/docker-compose.yml --profile receiver up`, + The receiver may run **containerised** (`docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up`, issue #82; the `-f` is load-bearing, because that file's relative paths resolve against `deploy/` and not your cwd). That changes none of the verification above: it is the same code, the container mounts `triggers.json` read-only and **no docker diff --git a/admin/src/deployment-pointer.mjs b/admin/src/deployment-pointer.mjs index 68df3fa6..4e7c2f9b 100644 --- a/admin/src/deployment-pointer.mjs +++ b/admin/src/deployment-pointer.mjs @@ -181,6 +181,9 @@ function layerPointer(env, fs) { ownedKeys.add(key); applied.push(key); } + // Issue #468: the panel's Valkey clients take VALKEY_PASSWORD (and PI_VALKEY_SHARED) from the POINTED-AT deployment's + // `.env` (`panelValkeyContext`, read-model.mjs, from `pointerState()` below, through issue #471's one reader). The + // pointer still carries no credential, and the password is never copied into the environment. return { applied }; } diff --git a/admin/src/index.ts b/admin/src/index.ts index 04ef2d44..4ab07418 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -74,6 +74,8 @@ import { cancelHeldJob, listRunIds, setQueuePaused, + killSwitchSet, + installPanelValkeyContext, writeSettings, writeTriggers, writePauseWindows, @@ -256,6 +258,9 @@ export default function admin(pi: ExtensionAPI): void { // Deliberately swallowed: applyDeploymentPointer degrades internally ({ ignored } + notice), so // anything reaching here is unexpected -- and still must not take the whole extension down. } + // Issue #468 over issue #471: every Valkey client of the panel takes its password (and its facts about the + // deployment's Valkey) from the pointer's folder through the one `.env` reader the panel has, never another. + installPanelValkeyContext(); pi.registerCommand("dispatch", { // The operator-visible summary; a subcommand once went missing from it while USAGE carried it, @@ -384,17 +389,20 @@ function registerTools(pi: ExtensionAPI): void { executionMode: "sequential", parameters: Type.Object({}), async execute() { - const paths = resolvePaths(deploymentEnv()); - const res = await setQueuePaused({ url: paths.valkeyUrl, paused: true }); - if (res.unreachable) { - throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`); - } - // A PARTIAL result is the dangerous one: some queues switched, one did not, and reporting a bare - // "paused" for it tells an operator the deployment is stopped while a host keeps spending. - if (res.partial) { - throw new Error(`paused ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} — the deployment is half paused: ${res.partial.error}`); + // The CLI's kill-switch rule (PR #475's review, round 3): both Valkeys when this process's VALKEY_URL and the + // deployment .env's disagree, every URL shown without its userinfo. + const set = await killSwitchSet({ paused: true }); + if (set.refused) throw new Error(set.refused); + const lines = []; + for (const { shown, res } of set.results) { + const at = set.results.length > 1 ? ` at ${shown}` : ""; + if (res.unreachable && !res.partial) throw new Error(`could not reach the queue at ${shown}: ${res.unreachable}`); + // A PARTIAL result is the dangerous one: some queues switched, one did not, and reporting a bare + // "paused" for it tells an operator the deployment is stopped while a host keeps spending. + if (res.partial) throw new Error(`paused ${res.partial.done.join(", ")}${at} but FAILED at ${res.partial.failed}: the deployment is half paused: ${res.partial.error}`); + lines.push(`paused${at}${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`); } - return toolText(`paused${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`); + return toolText(`${set.disagreement ? `${set.disagreement}: paused both. ` : ""}${lines.join("; ")}`); }, }); @@ -405,10 +413,11 @@ function registerTools(pi: ExtensionAPI): void { executionMode: "sequential", parameters: Type.Object({}), async execute() { - const paths = resolvePaths(deploymentEnv()); - const res = await setQueuePaused({ url: paths.valkeyUrl, paused: false }); - if (res.unreachable) { - throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`); + const set = await killSwitchSet({ paused: false }); + if (set.refused) throw new Error(set.refused); + const { shown, res } = set.results[0]; + if (res.unreachable && !res.partial) { + throw new Error(`could not reach the queue at ${shown}: ${res.unreachable}`); } if (res.partial) { throw new Error(`resumed ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} — the deployment is half resumed: ${res.partial.error}`); @@ -1372,26 +1381,36 @@ async function dispatch(pi: ExtensionAPI, args: string, rawCtx: any): Promise 1 ? ` at ${shown}` : ""; + if (res.unreachable) { + // A HALF switched deployment gets its own message. "Could not reach Valkey" reads as "nothing + // happened", and walking away from a fleet with one host stopped and another spending is the one + // outcome this switch exists to prevent. + notify?.( + res.partial + ? `${paused ? "paused" : "resumed"} ${res.partial.done.join(", ")}${at} but FAILED at ${res.partial.failed}: the deployment is half ${paused ? "paused" : "resumed"}` + : `could not reach Valkey at ${shown}: is it running? (docker compose up)`, + "error", + ); + continue; + } + const span = res.blind ? ` (fleet unknown: ${res.blind}, only ${res.queues[0]})` : res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""; notify?.( - res.partial - ? `${paused ? "paused" : "resumed"} ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} — the deployment is half ${paused ? "paused" : "resumed"}` - : `could not reach Valkey at ${paths.valkeyUrl} — is it running? (docker compose up)`, - "error", + paused + ? `paused${at}: worker will stop taking new jobs (jobs still enqueue; durable, survives restart)${span}` + : `resumed${at}${span}`, + "info", ); - return; } - const span = res.blind ? ` (fleet unknown: ${res.blind}, only ${res.queues[0]})` : res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""; - notify?.( - paused - ? `paused — worker will stop taking new jobs (jobs still enqueue; durable, survives restart)${span}` - : `resumed${span}`, - "info", - ); return; } case "set": { diff --git a/admin/src/read-model.mjs b/admin/src/read-model.mjs index d143ee5c..274cefc9 100644 --- a/admin/src/read-model.mjs +++ b/admin/src/read-model.mjs @@ -32,7 +32,8 @@ import { HELD_SET, jobKey } from "@edgehero/pi-dispatch/wait-state"; // The subscriptions validator is shared for the same anti-drift reason: the admin prices finished runs // against the exact schema the file declares, and re-deriving it here is how the two would disagree. import { parseSubscriptions, SUBSCRIPTIONS_VERSION } from "@edgehero/pi-dispatch/subscriptions"; -import { parseConnection, makeRedisClient } from "@edgehero/pi-dispatch/connection"; +import { parseConnection, makeRedisClient, killSwitchValkeyUrls, urlShown, useValkeyContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS } from "@edgehero/pi-dispatch/connection"; +import { pointerState } from "./deployment-pointer.mjs"; import { readLiveHosts } from "@edgehero/pi-dispatch/host-registry"; import { QUEUE, makeQueue, enqueueLocalJob, fleetQueueNames, hostQueueName, discoverHostQueues, unionQueueNames } from "@edgehero/pi-dispatch/queue"; import { hostsIn, mergeRuns, readMirroredRuns } from "@edgehero/pi-dispatch/run-mirror"; @@ -312,6 +313,68 @@ export async function readFleetQueues({ url, redisFn = makeRedisClient, timeoutM } } +/** + * Issue #468 with issue #471's rule: where the panel's Valkey clients stand (`valkeyClientContext`'s shape), from the + * pointer's deployment folder ONLY, read through the one reader the panel has (`deploymentServiceEnv`, one descriptor, + * nothing from a file another account can change): its VALKEY_PASSWORD, PI_VALKEY_SHARED and PI_BACKENDS, and its + * VALKEY_URL beside pi's own. pi's own environment is taken without what the pointer layered into it (the pointer's + * VALKEY_URL is the wizard's snapshot of the file, which the file outranks), so a disagreement is between the operator's + * export and the file. No pointer: pi's environment alone, and no file. A file that could not be taken from is the + * context's `error`, as `valkeyClientContext` says one it could not read. + */ +export function panelValkeyContext({ env = process.env, pointerDir = null, owned = [], fs = nodeFs, platform = process.platform, uid = process.geteuid?.() } = {}) { + const own = {}; + for (const [key, value] of Object.entries(env)) if (!owned.includes(key)) own[key] = value; + if (pointerDir === null) return valkeyContextFromKeys({ env: own, platform }); + // The file's own values (resolved against an empty shell, so `fromFile` is exactly what the file says). + const res = deploymentServiceEnv({ env: {}, dir: pointerDir, keys: VALKEY_CONTEXT_KEYS, platform, fs, uid }); + const error = res.untrusted + ? `${res.path} ${res.untrusted}` + : res.unreadable + ? `${res.path} could not be read (${res.unreadable})` + : res.hazardSkipped.length > 0 + ? `${res.path} line ${res.hazard.line} ${res.hazard.what}` + : res.unread.length > 0 + ? `${res.path} assigns ${res.unread.map((u) => `${u.key} (line ${u.line})`).join(", ")} in a form the service's loader may read differently` + : null; + return valkeyContextFromKeys({ env: own, envPath: res.path, fileKeys: res.fromFile, error, platform }); +} + +/** + * Make `panelValkeyContext`, from the pointer's current state, the context every Valkey client of this process gets + * when its caller names none (`useValkeyContext`), so no client of the panel reads a `.env` by any other way. Read per + * client, like `panelEnv`, so an edit to the file reaches the next command. + */ +export function installPanelValkeyContext({ env = process.env } = {}) { + useValkeyContext(() => { + const { deploymentDir, owned } = pointerState(); + return panelValkeyContext({ env, pointerDir: deploymentDir, owned }); + }); +} + +/** + * The panel's kill switch (PR #475's review, round 3): the CLI's rule, from the one resolver both import + * (`killSwitchValkeyUrls`), over `panelValkeyContext` (issue #471: the pointer's folder only, through the one reader). + * The panel used this process's VALKEY_URL alone, so a stale one paused a Valkey the worker does not drain and said + * "paused". When pi's own VALKEY_URL and the deployment .env's disagree, a PAUSE pauses both, and a RESUME, which would + * start spending, is refused until they agree. Returns `{ refused }`, or `{ results: [{ url, shown, res }], + * disagreement }` with each URL as `urlShown` prints it, never with its userinfo; every client sends the deployment's + * password by that same context. + */ +export async function killSwitchSet({ paused, env = process.env, setFn = setQueuePaused, resolveFn = killSwitchValkeyUrls, stateFn = pointerState, fs = nodeFs, uid = process.geteuid?.() } = {}) { + const { deploymentDir, owned } = stateFn(); + const context = panelValkeyContext({ env, pointerDir: deploymentDir, owned, fs, uid }); + const picked = resolveFn({ env, context }); + if (picked.error) return { refused: picked.error }; + if (picked.urls.length > 1 && !paused) { + return { refused: `${picked.disagreement}: resume would start jobs on one of them, so it resumes neither. Make them agree (the .env is what the service reads), or run: pi-dispatch resume --valkey-url ` }; + } + const conn = { parseConnectionFn: (u, o = {}) => parseConnection(u, { ...o, context }), redisFn: (u, o = {}) => makeRedisClient(u, { ...o, context }) }; + const results = []; + for (const url of picked.urls) results.push({ url, shown: urlShown(url), res: await setFn({ url, paused, ...conn }) }); + return { results, disagreement: picked.urls.length > 1 ? picked.disagreement : null }; +} + export async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) { const opened = []; try { diff --git a/admin/src/setup-wizard.ts b/admin/src/setup-wizard.ts index da4bc864..5bb46de0 100644 --- a/admin/src/setup-wizard.ts +++ b/admin/src/setup-wizard.ts @@ -38,14 +38,17 @@ import { reapplyDeploymentPointer, writePointer, } from "./deployment-pointer.mjs"; -import { DEPLOYMENT_SCAFFOLD_FILES, readQueueState, resolvePaths, writeTriggers } from "./read-model.mjs"; +import { DEPLOYMENT_SCAFFOLD_FILES, panelValkeyContext, readQueueState, resolvePaths, writeTriggers } from "./read-model.mjs"; // The worker's own answers to "which venues does this list bless" and "set this .env key unless the operator already // did" (issue #430): one parse and one never-clobber writer, never a second copy of either in the admin. import { venuesOf } from "@edgehero/pi-dispatch/backends"; import { envFileEditCheck, envFileEditRefusal, updateEnvFile } from "@edgehero/pi-dispatch/env-file"; // The venue keys read exactly as `service install` and `up` read them (issue #430 review round 2, E4): the general // reader takes a line inside a quoted value that systemd continues for an assignment, and this one refuses that file. -import { readStackKeys } from "@edgehero/pi-dispatch/podman-stack"; +import { readStackKeys, readValkeyKeys, valkeyTarget } from "@edgehero/pi-dispatch/podman-stack"; +import { COMPOSE_VALKEY_OVERRIDE, OWNER_MARKER_KEY, VALKEY_HANDOVER_OVERRIDE, VALKEY_PORT_KEY, adoptVolumeQuestion, composeArgs, composeHandoverPlan, composeProjectName, foreignMarkerRefusal, unadoptedVolumeRefusal, valkeyPortEnvDecision } from "@edgehero/pi-dispatch/valkey-auth"; +import { claimValkeyOwner } from "@edgehero/pi-dispatch/connection"; +import { deploymentServiceEnv } from "@edgehero/pi-dispatch/service-env"; // buildTriggerEntry is index.ts's on x run matrix -- the SAME builder the dialogs and the LLM tool use, // so the wizard's first trigger has exactly their shape. The import is circular on paper (index.ts // lazy-imports this module from its /dispatch handler and statically imports registerNudge below), but @@ -289,6 +292,40 @@ const DOCKER_PROBE_TIMEOUT_MS = 10_000; * would freeze pi's render loop for as long as it hung. A timeout kill lands in the daemonDown branch, * which is exactly the right verdict for it. */ +// The folder's compose project and the hand-over override are the worker package's (valkey-auth.mjs), shared with +// `pi-dispatch up`, which starts compose's Valkey in a folder this wizard handed over (PR #475's review). +export { composeProjectName, VALKEY_HANDOVER_OVERRIDE }; + +/** + * VALKEY_URL's port as the deployment's `.env` gives it (6379 unset, or for a URL this reader cannot use): the port + * compose's Valkey is published on (PI_VALKEY_PORT), so a hand-over never moves the queue off the port the worker + * dials (PR #475's review, measured: a 16495 deployment's queue went to 6379). + */ +export function composeValkeyPort(dir: string, fs: any): number { + try { + // Through issue #471's one reader (`deploymentServiceEnv`: one descriptor, nothing from a file another account can + // change), as the panel reads this folder's `.env`, never a second reader. + const read: any = deploymentServiceEnv({ env: {}, dir, keys: ["VALKEY_URL"], fs, platform: "linux" }); + const target = valkeyTarget(read.fromFile.VALKEY_URL); + return target.error ? 6379 : target.port; + } catch { + return 6379; + } +} + +/** + * One docker query for the hand-over's ownership questions (PR #475's review, round 3): `{ code, stdout, stderr }`, the + * shape `composeHandoverPlan` asks through. A docker that cannot be launched is `code: null`, which it never reads as ours. + */ +export function dockerQuery(cmd: string, args: string[], execFn: any = execFileSync): { code: number | null; stdout: string; stderr: string } { + try { + const stdout = execFn(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: DOCKER_PROBE_TIMEOUT_MS }); + return { code: 0, stdout: String(stdout ?? ""), stderr: "" }; + } catch (err: any) { + return { code: typeof err?.status === "number" ? err.status : null, stdout: String(err?.stdout ?? ""), stderr: String(err?.stderr ?? "") }; + } +} + export function probeDocker( execFn: any = execFileSync, ): { ok: true } | { missing: true } | { daemonDown: string } { @@ -593,6 +630,12 @@ export async function runSetupWizard(paths: any, rawCtx: any, notify: Notify, de existsSyncFn = (p: string) => fs.existsSync(p), probeDockerFn = probeDocker, probePodmanFn = probePodman, + // Whether `up`'s pi-dispatch-valkey container exists (the compose answer's hand-over; a seam for its test). + dockerQueryFn = dockerQuery, + // The volume gap (PR #475's review): the started Valkey's pi-dispatch:owner marker, recorded and read back through + // the project's one connection function with the deployment's credential; a seam so no test dials a Valkey. + // The context from that folder's `.env` through issue #471's one reader (`panelValkeyContext`), never a second reader. + claimOwnerFn = (url: string, folder: string, dir: string, env: any) => claimValkeyOwner(url, folder, { context: panelValkeyContext({ env, pointerDir: dir }) }), // The venue-key reading behind the wizard's own post-edit check (a seam for its test; see podmanWriteVerify). readBackFn = readStackKeys, initialDetection, @@ -858,7 +901,7 @@ export async function runSetupWizard(paths: any, rawCtx: any, notify: Notify, de // Credentials (step 9) mint the App; they do not make a delivery arrive. This step closes that gap, // which was previously left to the README: a receiver UNIT, a receiver CONTAINER, or polling (no // public URL at all). Every answer -- including Skip -- continues to the next step. - await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn, runtime }); + await offerTriggerEdge(dir, ctx, ui, notify, { fs, platform, env, execPath, cliPath, runAttachedFn, runtime, dockerQueryFn, claimOwnerFn }); // ── (11) a first trigger for the repo pi is sitting in ────────────────────────────────────────── // Offered only when ctx.cwd names an existing directory: the trigger's folder is the one hard @@ -903,7 +946,7 @@ async function offerTriggerEdge( ctx: any, ui: any, notify: Notify, - { fs, platform, env, execPath, cliPath, runAttachedFn, runtime = "docker" }: any, + { fs, platform, env, execPath, cliPath, runAttachedFn, runtime = "docker", dockerQueryFn = dockerQuery, claimOwnerFn = async (_u: string, folder: string) => ({ owner: folder, claimed: false }) }: any, ): Promise { const choice = await ui.select("How should GitHub events reach the queue?", [ EDGE_SERVICE, @@ -971,19 +1014,39 @@ async function offerTriggerEdge( } if (choice === EDGE_COMPOSE) { - // The compose file the RUNTIME ships (worker/deploy/docker-compose.yml, published in its `files`), - // copied beside the deployment's own config so `-f` names a stable path the operator can edit and - // keep. CREATE-ONLY, the import-pi.mjs:294 idiom the root package.json already follows: an operator - // who tuned their compose file must never lose it to a re-run of the wizard. - const src = join(runtimeDirFor(dir), "deploy", "docker-compose.yml"); - const dest = join(dir, "docker-compose.yml"); - if (fs.existsSync(dest)) { - notify?.(`${dest} already exists — keeping yours as it is (setup never overwrites a compose file you may have edited)`, "info"); - } else { + // The compose file the RUNTIME ships (worker/deploy/docker-compose.yml, published in its `files`), copied into the + // deployment folder so `-f` names a stable path the operator can edit and keep. CREATE-ONLY, the import-pi.mjs:294 + // idiom the root package.json already follows: an operator who tuned their compose file must never lose it to a + // re-run of the wizard. + // + // Into `/deploy/`, the layout the file is written for: its relative paths resolve against its OWN directory + // (the Compose spec, for `env_file` and bind mounts alike), and it names `../.env`, `../triggers.json`, + // `../egress-allowlist.conf` and `./egress-proxy.conf`. Copied to `/docker-compose.yml`, as it was until issue + // #468's follow-up, every `../` reached the folder ABOVE the deployment, so the receiver container read another + // directory's .env and triggers. Beside it goes the proxy's rules file its `./egress-proxy.conf` names, also + // create-only, so `--profile egress` finds it too. The command is then the one the docs give, run from `dir`: + // `docker compose --env-file .env -f deploy/docker-compose.yml ...`. Rewriting the paths on copy was the other way, + // rejected: a copy that differs from the shipped file cannot be compared with it or diffed against a newer one. + const deployDir = join(dir, "deploy"); + const copies: Array<[string, string, boolean]> = [ + [join(runtimeDirFor(dir), "deploy", "docker-compose.yml"), join(deployDir, "docker-compose.yml"), true], + [join(runtimeDirFor(dir), "deploy", "egress-proxy.conf"), join(deployDir, "egress-proxy.conf"), false], + ]; + const dest = copies[0][1]; + for (const [src, to, required] of copies) { + if (fs.existsSync(to)) { + notify?.(`${to} already exists: keeping yours as it is (setup never overwrites a file you may have edited)`, "info"); + continue; + } try { - fs.copyFileSync(src, dest); - notify?.(`copied the runtime's compose file to ${dest}`, "info"); + fs.mkdirSync(deployDir, { recursive: true }); + fs.copyFileSync(src, to); + notify?.(`copied the runtime's ${src.split(/[\\/]/).pop()} to ${to}`, "info"); } catch (err: any) { + if (!required) { + notify?.(`could not copy ${src}: ${err?.message ?? err}: only the egress profile needs it, not the receiver`, "warning"); + continue; + } notify?.( `could not copy ${src}: ${err?.message ?? err} — the receiver profile needs that file; skipping this step (install the runtime first, or copy it by hand)`, "error", @@ -991,24 +1054,163 @@ async function offerTriggerEdge( return; } } + // PR #475's review: the project is named after the FOLDER, as compose named the copy that sat at its top, since + // compose names a project after the directory of its first file and that is now `deploy/` for every deployment. + const project = composeProjectName(dir); + const legacy = join(dir, "docker-compose.yml"); + if (fs.existsSync(legacy)) { + notify?.( + `${legacy} is an earlier setup's copy: its ../ paths reach the folder above ${dir}, so it is not used. It ran as compose project "${project}" (its volume ${project}_valkey-data), which the command below keeps with -p ${project}. ${dest} is the one to run; move any edits of yours there, then remove it`, + "warning", + ); + } + // ONE Valkey for the worker and the receiver (PR #475's review). `up`, earlier in this wizard, started + // `pi-dispatch-valkey`, which is what the worker dials; compose's own `valkey` would then fail to bind that port, + // or, started, be a second Valkey the receiver (dialling `valkey:6379`) enqueues into while the worker drains the + // other. So while up's container EXISTS and is this deployment's (round 2: an override already on disk from an + // earlier pass is no reason to leave a second Valkey running; round 3: another deployment's is never touched), compose's valkey takes over up's VOLUME through an + // override (written once, create-only, then named in every command for this folder, `up`'s included): the images + // are pulled first, while up's Valkey still serves; then up's container is stopped (SIGTERM: the AOF is written out) + // and removed, and compose's valkey starts on the same data. Both are published on VALKEY_URL's port + // (PI_VALKEY_PORT). A compose that fails after the removal puts the override back as it found it and names the way + // back: `pi-dispatch up` starts pi-dispatch-valkey on its volume again. + // Rejected: pointing the containerised receiver at the host's Valkey, which is bound to 127.0.0.1 and so + // unreachable from a bridge network on Linux; and letting `up` skip its Valkey, which would ask the edge question + // before `up` and leave the worker without a queue until this step. + const override = join(dir, COMPOSE_VALKEY_OVERRIDE); + const hasOverride = fs.existsSync(override); + const port = composeValkeyPort(dir, fs); + // Round 3 of PR #475's review: up's container is handed over only when it is PROVABLY this deployment's (its label + // naming this folder, or, unlabelled, publishing this deployment's VALKEY_URL port), and nothing starts on its volume + // while a container that is not this deployment's mounts it; the rule is `composeHandoverPlan`, shared with a test on + // a real docker. Measured before it: this step stopped and removed ANOTHER deployment's pi-dispatch-valkey. + let real = dir; + try { + real = fs.realpathSync(dir); + } catch { + // Unresolvable: the folder as given. + } + const plan = await composeHandoverPlan({ dirs: [real, dir], port, override: hasOverride, query: (cmd: string, args: string[]) => dockerQueryFn(cmd, args) }); + if (plan.refused) { + notify?.(`the receiver container was not started: ${plan.refused}`, "error"); + return; + } + if (plan.note) notify?.(plan.note, "warning"); + // An unlabelled pi-dispatch-valkey-data that no container of this deployment serves (the volume gap): its queue + // cannot be attributed, so compose's valkey starts on it only after this question, which nothing answers for the + // operator (the wizard never forwards --yes), and the marker then records the adoption. + if (plan.adopt && !(await ui.confirm("Adopt pi-dispatch-valkey-data?", adoptVolumeQuestion(real)))) { + notify?.(`the receiver container was not started: ${unadoptedVolumeRefusal()}`, "error"); + return; + } + const handover = plan.handover; + const useOverride = hasOverride || handover; + const composeEnv = { ...env, [VALKEY_PORT_KEY]: String(port) }; + const base = composeArgs({ project, override: useOverride }); // `--profile receiver` is what makes the receiver container OPT-IN: the same compose file's plain // `up` is Valkey-only, which is what a worker-on-host deployment wants. - const args = ["compose", "-f", dest, "--profile", "receiver", "up", "-d"]; + // `--env-file` names the deployment's .env for compose's own interpolation (issue #468): the Valkey in this file takes + // VALKEY_PASSWORD from it, and without it a compose run elsewhere started that Valkey with no password. + // Relative to `dir`, the cwd it runs in: exactly the command the docs give. + const args = [...base, "--profile", "receiver", "up", "-d"]; + // The pull names the override only when it is ALREADY on disk: it runs before the override is written (the images do + // not depend on it), and compose refuses a -f that does not exist (measured on Fedora 44, compose 5.5.1: "compose + // file ... is invalid: ... no such file or directory", which stopped the hand-over at its first step). + const pull = [...composeArgs({ project, override: hasOverride }), "--profile", "receiver", "pull"]; + const portPrefix = port !== 6379 ? `${VALKEY_PORT_KEY}=${port} ` : ""; + const shown = `${portPrefix}docker ${args.join(" ")}`; + const shownPull = `${portPrefix}docker ${pull.join(" ")}`; + const writeLine = handover && !hasOverride ? `write ${override}:\n${VALKEY_HANDOVER_OVERRIDE.replace(/^/gm, " ")}` : ""; + const handoverLines = handover + ? `${shownPull} (the images, while up's Valkey still serves)\n ${writeLine ? `${writeLine}\n ` : ""}docker stop pi-dispatch-valkey (a job running right now is interrupted)\n docker rm pi-dispatch-valkey\n ` + : ""; const ok = await ui.confirm( "Start the receiver container", - `Run in ${dir}:\n docker ${args.join(" ")}\n\nthe receiver profile reads ${join(dir, ".env")} — WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`, + `Run in ${dir}:\n ${handoverLines}${shown}\n\nthe receiver profile reads ${join(dir, ".env")}: WEBHOOK_SECRET and the forge credentials must be in there, or the container refuses to boot.`, ); if (!ok) { - notify?.(`later: docker ${args.join(" ")} (in ${dir})`, "info"); + // The whole of what would have run, the override's contents included, so it can be done by hand. + notify?.(`later: ${handover ? `${shownPull}; ${writeLine ? `write ${override} with:\n${VALKEY_HANDOVER_OVERRIDE}` : ""}docker stop pi-dispatch-valkey && docker rm pi-dispatch-valkey; then ` : ""}${shown} (in ${dir})`, "info"); return; } - await runAttachedFn(ctx, { - title: `docker compose --profile receiver up -d — in ${dir}`, - argv0: "docker", - args, - cwd: dir, - env, - }); + // PI_VALKEY_PORT into .env where VALKEY_URL's port is not 6379 (round 3), so the docs' plain compose command in this + // folder publishes where the worker dials; a different value already there is named and never overwritten. + try { + const envPath = join(dir, ".env"); + if (fs.existsSync(envPath)) { + // Read through issue #471's one reader; a line it could not read counts as a value (never written over). + const read: any = deploymentServiceEnv({ env: {}, dir, keys: [VALKEY_PORT_KEY], fs, platform: "linux" }); + const found = read.fromFile[VALKEY_PORT_KEY] ?? (read.unread.length > 0 || read.hazardSkipped.length > 0 || read.untrusted ? "?" : undefined); + const decided: any = valkeyPortEnvDecision(found, port, { envPath }); + if (decided.conflict) notify?.(decided.conflict, "warning"); + else if (decided.write && updateEnvFile(envPath, VALKEY_PORT_KEY, decided.write, { fs, platform }).changed) notify?.(`wrote ${VALKEY_PORT_KEY}=${port} into ${envPath} (VALKEY_URL's port)`, "info"); + } + } catch (err: any) { + notify?.(`${VALKEY_PORT_KEY} could not be written into .env: ${err?.message ?? err}; the commands below pass it themselves`, "warning"); + } + const step = async (argv: string[], title: string, stepEnv = env) => { + const res = await runAttachedFn(ctx, { title: `${title} (in ${dir})`, argv0: "docker", args: argv, cwd: dir, env: stepEnv }); + return !(res?.error || res?.code !== 0); + }; + // After compose's valkey started on pi-dispatch-valkey-data: the queue's own pi-dispatch:owner, recorded where it + // is missing, checked where it is not; another folder's stops compose's valkey again at once. + const ownerMarker = async () => { + const r: any = await claimOwnerFn(`redis://127.0.0.1:${port}`, real, dir, env); + if (r?.error) { + notify?.(`compose's valkey started, but its ${OWNER_MARKER_KEY} could not be recorded or read (${r.error}), so whose queue it holds is unconfirmed; \`node ${cliPath} up\` (in ${dir}) checks it again`, "error"); + return; + } + if (r.owner !== real && r.owner !== dir) { + const stopped = await step([...base, "stop", "valkey"], `docker ${[...base, "stop", "valkey"].join(" ")}`, composeEnv); + notify?.(`${foreignMarkerRefusal(r.owner)}: compose's valkey was ${stopped ? "stopped again at once" : "NOT stopped (the stop failed): stop it now"}`, "error"); + return; + } + if (r.claimed) notify?.(`recorded ${OWNER_MARKER_KEY}=${real} in the queue on pi-dispatch-valkey-data`, "info"); + }; + if (handover) { + if (!(await step(pull, shownPull, composeEnv))) { + notify?.(`${shownPull} failed; up's Valkey was left running and nothing else was done`, "error"); + return; + } + let wrote = false; + if (!hasOverride) { + try { + fs.writeFileSync(override, VALKEY_HANDOVER_OVERRIDE, { flag: "wx" }); + wrote = true; + } catch (err: any) { + notify?.(`could not write ${override}: ${err?.message ?? err}; nothing was stopped, and the receiver was not started`, "error"); + return; + } + } + const putBack = () => { + if (!wrote) return ""; + try { + fs.unlinkSync(override); + return ` ${override} was removed again.`; + } catch { + return ` ${override} could not be removed: remove it before running \`pi-dispatch up\`.`; + } + }; + if (!(await step(["stop", "pi-dispatch-valkey"], "docker stop pi-dispatch-valkey"))) { + notify?.(`docker stop pi-dispatch-valkey failed; up's Valkey runs as it was, and the receiver was not started.${putBack()}`, "error"); + return; + } + if (!(await step(["rm", "pi-dispatch-valkey"], "docker rm pi-dispatch-valkey"))) { + notify?.(`docker rm pi-dispatch-valkey failed; up's Valkey is STOPPED. \`docker start pi-dispatch-valkey\` brings it back on its volume.${putBack()}`, "error"); + return; + } + if (!(await step(args, shown, composeEnv))) { + notify?.(`${shown} failed after up's Valkey was removed: NO Valkey runs now. Its volume, pi-dispatch-valkey-data, and the queue in it are kept: \`node ${cliPath} up\` (in ${dir}) starts pi-dispatch-valkey on it again.${putBack()} Compose's own output above says why it failed`, "error"); + return; + } + await ownerMarker(); + return; + } + if (!(await step(args, shown, composeEnv))) { + notify?.(`${shown} failed; compose's own output above says why (another container on 127.0.0.1:${port} is the usual one)`, "error"); + return; + } + if (useOverride) await ownerMarker(); return; } diff --git a/admin/test/deployment-pointer.test.mjs b/admin/test/deployment-pointer.test.mjs index 270b64d9..f7874dd2 100644 --- a/admin/test/deployment-pointer.test.mjs +++ b/admin/test/deployment-pointer.test.mjs @@ -287,3 +287,15 @@ test("pointerState names the pointed folder and only the keys the layering wrote resetForTests(); assert.deepEqual(pointerState(), { deploymentDir: null, owned: [] }); }); + +// Issue #468: the pointer names the deployment folder and carries no credential; the panel's Valkey clients take the +// password from that folder's .env through issue #471's reader (`panelValkeyContext`, read-model.test.mjs), and nothing +// is layered into the environment, where a model's shell would inherit it. +test("the pointer never layers VALKEY_PASSWORD into the env, and names the folder the panel reads it from (#468)", () => { + resetForTests(); + const env = envWith(); + applyDeploymentPointer(env, { fs: fakeFs({ [P]: pointerJson({}) }) }); + assert.equal(env.VALKEY_PASSWORD, undefined); + assert.equal(pointerState().deploymentDir, DIR); + resetForTests(); +}); diff --git a/admin/test/read-model.test.mjs b/admin/test/read-model.test.mjs index a903e395..1bc9fb8a 100644 --- a/admin/test/read-model.test.mjs +++ b/admin/test/read-model.test.mjs @@ -22,6 +22,8 @@ import { readStagedPackages, listRunIds, setQueuePaused, + killSwitchSet, + panelValkeyContext, writeSettings, writeTriggers, readPauseWindows, @@ -2387,3 +2389,63 @@ test("panelEnv: a pointed .env in a folder another account can change is not rea chmodSync(dir, 0o700); assert.equal(panelEnv({ env: {}, pointerDir: dir, platform: "linux" }).env.VALKEY_URL, "redis://198.51.100.7:6379"); }); + +// PR #475's review, round 3, over issue #471's rule: the panel's `/dispatch pause|resume` took this process's +// VALKEY_URL, ignored a disagreement with the deployment's .env, and printed the raw URL. It now has the CLI's rule, from +// the one resolver both import (a pause pauses both, a resume refuses, every URL shown without its userinfo), over +// `panelValkeyContext`: the pointer's folder only, read through issue #471's one reader and its trust rule. +test("killSwitchSet: the CLI's kill-switch rule for the panel, over the pointer's .env read by #471's reader (#475 round 3)", async () => { + const dir = realpathSync(tempDir("admin-kill-")); + writeFileSync(join(dir, ".env"), `VALKEY_URL=redis://127.0.0.1:16495\nVALKEY_PASSWORD=${"ab".repeat(20)}\n`, { mode: 0o600 }); + const calls = []; + const conns = []; + const setFn = async ({ url, paused, parseConnectionFn }) => (calls.push([url, paused]), conns.push(parseConnectionFn), { queues: ["pi-dispatch"] }); + const stateFn = () => ({ deploymentDir: dir, owned: [] }); + const env = { VALKEY_URL: "redis://:shellSecretNeverShown@127.0.0.1:16000" }; + const p = await killSwitchSet({ paused: true, env, setFn, stateFn }); + assert.deepEqual(calls, [["redis://:shellSecretNeverShown@127.0.0.1:16000", true], ["redis://127.0.0.1:16495", true]], "both, the pointer's folder's .env included"); + assert.deepEqual(p.results.map((r) => r.shown), ["redis://127.0.0.1:16000", "redis://127.0.0.1:16495"]); + assert.equal(p.disagreement, `VALKEY_URL is redis://127.0.0.1:16000 in this shell and redis://127.0.0.1:16495 in ${join(dir, ".env")} (what the service uses)`); + assert.ok(!JSON.stringify(p.results.map((r) => r.shown)).includes("shellSecretNeverShown")); + // Each client takes the pointed-at deployment's credential (its folder's .env), not the cwd's. + assert.equal(conns[1]("redis://127.0.0.1:16495").password, "ab".repeat(20)); + calls.length = 0; + const r = await killSwitchSet({ paused: false, env, setFn, stateFn }); + assert.deepEqual(calls, [], "a refused resume changes nothing"); + assert.match(r.refused, /resume would start jobs on one of them, so it resumes neither/); + assert.ok(!r.refused.includes("shellSecretNeverShown")); + // Agreement: one Valkey, resumed. + const one = await killSwitchSet({ paused: false, env: {}, setFn, stateFn }); + assert.deepEqual(calls, [["redis://127.0.0.1:16495", false]]); + assert.equal(one.disagreement, null); + // The pointer's own layered VALKEY_URL is the wizard's snapshot, which the file outranks: not a disagreement. + calls.length = 0; + await killSwitchSet({ paused: true, env: { VALKEY_URL: "redis://127.0.0.1:16000" }, setFn, stateFn: () => ({ deploymentDir: dir, owned: ["VALKEY_URL"] }) }); + assert.deepEqual(calls, [["redis://127.0.0.1:16495", true]]); +}); + +test("panelValkeyContext: the pointer's folder only, through #471's reader: nothing from a file another account can change, nothing with no pointer (#468 over #471)", () => { + const dir = realpathSync(tempDir("admin-ctx-")); + const PW = "cd".repeat(20); + writeFileSync(join(dir, ".env"), `VALKEY_URL=redis://127.0.0.1:16495\nVALKEY_PASSWORD=${PW}\nPI_VALKEY_SHARED=1\n`, { mode: 0o600 }); + const ok = panelValkeyContext({ env: {}, pointerDir: dir }); + assert.equal(ok.password.file, PW); + assert.equal(ok.url.file, "redis://127.0.0.1:16495"); + assert.equal(ok.shared, true); + assert.equal(ok.error, null); + assert.equal(ok.envPath, join(dir, ".env")); + // No pointer: pi's environment alone, even with a .env in the cwd. + const none = panelValkeyContext({ env: { VALKEY_PASSWORD: "exported-by-the-operator" }, pointerDir: null }); + assert.equal(none.password.file, null); + assert.equal(none.password.environment, "exported-by-the-operator"); + assert.equal(none.envPath, null); + // A .env another account can write: no value taken (not the password either), and why is the context's error. + chmodSync(join(dir, ".env"), 0o666); + const open = panelValkeyContext({ env: {}, pointerDir: dir }); + assert.equal(open.password.file, null); + assert.equal(open.url.file, null); + assert.match(open.error, /writable by every account/); + chmodSync(join(dir, ".env"), 0o600); + // What the pointer layered is not pi's own: the file is compared with an operator export only. + assert.equal(panelValkeyContext({ env: { VALKEY_URL: "redis://127.0.0.1:1" }, pointerDir: dir, owned: ["VALKEY_URL"] }).url.environment, null); +}); diff --git a/admin/test/setup-wizard.test.mjs b/admin/test/setup-wizard.test.mjs index 36908487..66575013 100644 --- a/admin/test/setup-wizard.test.mjs +++ b/admin/test/setup-wizard.test.mjs @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import * as realFs from "node:fs"; -import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { mkdirSync, readFileSync, realpathSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tempDir } from "./helpers/temp-dir.mjs"; @@ -75,6 +75,10 @@ function wizardDeps(overrides = {}) { homedirFn: () => tempDir("admin-setup-home-"), detectFn: async () => ({ state: "none", detail: "canned detection" }), probeDockerFn: () => ({ ok: true }), + // No `up` Valkey to hand over, and nothing on its volume, unless a test says so (never this host's docker). + dockerQueryFn: fakeDocker(), + // No Valkey is dialled: the owner marker is this folder's unless a test says otherwise. + claimOwnerFn: async (_url, folder) => ({ owner: folder, claimed: false }), runAttachedFn: async (_ctx, opts) => { attached.push(opts); return { code: 0 }; @@ -865,7 +869,9 @@ test("wizard: the edge's compose answer copies the runtime's compose file CREATE const runtimeDir = plantRuntime(dir, mod.RUNTIME_VERSION); mkdirSync(join(runtimeDir, "deploy"), { recursive: true }); writeFileSync(join(runtimeDir, "deploy", "docker-compose.yml"), "# the runtime's own compose file\n"); - const dest = join(dir, "docker-compose.yml"); + writeFileSync(join(runtimeDir, "deploy", "egress-proxy.conf"), "# the runtime's own squid rules\n"); + // Into /deploy/, the layout the file's relative paths are written for (the follow-up to issue #468). + const dest = join(dir, "deploy", "docker-compose.yml"); const first = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [true])); // + the compose up confirm const rec = wizardDeps({ @@ -876,11 +882,19 @@ test("wizard: the edge's compose answer copies the runtime's compose file CREATE }); await mod.runSetupWizard({}, tuiCtx(first.ui, repo), first.ui.notify, rec.deps); assert.equal(readFileSync(dest, "utf8"), "# the runtime's own compose file\n", "copied out of the installed runtime"); + assert.equal(readFileSync(join(dir, "deploy", "egress-proxy.conf"), "utf8"), "# the runtime's own squid rules\n", "with the rules file its ./egress-proxy.conf names"); + assert.ok(!existsSync(join(dir, "docker-compose.yml")), "never at the folder's top, where its ../ paths leave the folder"); assert.equal(rec.attached.length, 1); assert.equal(rec.attached[0].argv0, "docker"); - assert.deepEqual(rec.attached[0].args, ["compose", "-f", dest, "--profile", "receiver", "up", "-d"], "the opt-in receiver profile"); + // The docs' own command, from the deployment folder: `--env-file .env` is where compose reads the Valkey's + // VALKEY_PASSWORD (issue #468), `-f deploy/docker-compose.yml` the file in the layout it is written for. + // `-p` keeps the project named after the folder, as the copy at its top was (PR #475's review). + const project = mod.composeProjectName(dir); + const args = ["compose", "-p", project, "--env-file", ".env", "-f", join("deploy", "docker-compose.yml"), "--profile", "receiver", "up", "-d"]; + assert.deepEqual(rec.attached[0].args, args, "the opt-in receiver profile"); assert.equal(rec.attached[0].cwd, dir); - assert.match(first.seen.confirm.at(-1).message, /--profile receiver up -d/, "the confirm shows the exact command"); + assert.ok(first.seen.confirm.at(-1).message.includes(`docker ${args.join(" ")}`), "the confirm shows the exact command"); + assert.equal(rec.attached[0].title, `docker ${args.join(" ")} (in ${dir})`, "the title is the argv that runs"); assert.ok(reachedFirstTrigger(first.seen)); // Second pass over the SAME dir: the operator may have edited that file, so it is never clobbered. @@ -890,8 +904,45 @@ test("wizard: the edge's compose answer copies the runtime's compose file CREATE await mod.runSetupWizard({}, tuiCtx(second.ui, repo), second.ui.notify, rec2.deps); assert.equal(readFileSync(dest, "utf8"), "# MINE — edited by the operator\n", "create-only: an existing compose file survives"); assert.ok(second.notes.some((n) => /already exists/.test(n.m) && /keeping yours/.test(n.m)), "and the skip is said out loud"); + assert.ok(second.notes.some((n) => n.m.startsWith(`later: docker ${args.join(" ")} (in ${dir})`)), "the skip hint is the argv that would have run"); assert.equal(rec2.attached.length, 0, "a declined up spawns nothing"); assert.ok(reachedFirstTrigger(second.seen)); + + // An earlier setup's copy at the folder's top is named as unused, never run and never removed. + writeFileSync(join(dir, "docker-compose.yml"), "# the old copy\n"); + const third = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [false])); + await mod.runSetupWizard({}, tuiCtx(third.ui, repo), third.ui.notify, wizardDeps().deps); + assert.ok(third.notes.some((n) => n.t === "warning" && /is an earlier setup's copy: its \.\.\/ paths reach the folder above/.test(n.m) && n.m.includes(`It ran as compose project "${project}" (its volume ${project}_valkey-data), which the command below keeps with -p ${project}`)), "names the project and the volume it keeps"); + assert.equal(readFileSync(join(dir, "docker-compose.yml"), "utf8"), "# the old copy\n"); +}); + +// The follow-up to issue #468: the SHIPPED compose file, copied where the wizard copies it, names only paths inside the +// deployment folder. Every relative path in it (env_file entries and bind-mount sources) is resolved against the written +// file's own directory, as compose resolves them. +test("wizard: every relative path in the compose file it writes resolves inside the deployment folder", async () => { + const repo = tempDir("admin-setup-repo-"); + const dir = emptyDir(); + const runtimeDir = plantRuntime(dir, mod.RUNTIME_VERSION); + mkdirSync(join(runtimeDir, "deploy"), { recursive: true }); + const shipped = join(fileURLToPath(new URL("../../worker/deploy/", import.meta.url))); + for (const f of ["docker-compose.yml", "egress-proxy.conf"]) writeFileSync(join(runtimeDir, "deploy", f), readFileSync(join(shipped, f))); + const { ui } = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [false])); + await mod.runSetupWizard({}, tuiCtx(ui, repo), ui.notify, wizardDeps().deps); + const written = join(dir, "deploy", "docker-compose.yml"); + const text = readFileSync(written, "utf8"); + const code = text.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n"); + const rel = []; + for (const m of code.matchAll(/^\s*env_file:\s*(\S+)\s*$/gm)) rel.push(m[1]); + for (const m of code.matchAll(/^\s*-\s*"?(\.{1,2}\/[^:"\s]+):/gm)) rel.push(m[1]); + assert.deepEqual(rel.sort(), ["../.env", "../egress-allowlist.conf", "../triggers.json", "./egress-proxy.conf"], "the scan reaches every relative path the shipped file names"); + const { resolve, dirname, sep } = await import("node:path"); + for (const p of rel) { + const at = resolve(dirname(written), p); + assert.ok(at.startsWith(dir + sep), `${p} resolves to ${at}, outside ${dir}`); + } + // And each lands on what the deployment holds under that name. + assert.ok(existsSync(resolve(dirname(written), "./egress-proxy.conf")), "the rules file beside it"); + assert.equal(resolve(dirname(written), "../.env"), join(dir, ".env"), "the .env `--env-file .env` names from the folder"); }); test("wizard: the edge's compose answer degrades when the runtime ships no compose file", async () => { @@ -1482,3 +1533,247 @@ test("E4: a .env whose venue line sits inside a quoted value systemd continues i assert.equal(attached.length, 0); assert.ok(notes.some((n) => n.t === "error" && /lies inside the quoted value that opens on line 1/.test(n.m))); }); + +// PR #475's review: compose names a project after the directory of its first file, so the copy in /deploy/ ran as +// project "deploy" for every deployment, leaving the old project (and its volume) behind and colliding across folders. +// `-p` is the folder's own name, normalised as compose normalises a directory name (measured with compose 2.31: +// "My Deploy.v2_x" became project "mydeployv2_x"), which is what the old layout's copy ran as. +test("wizard: the compose project name is the one the old layout's copy ran as (the folder's name, as compose normalises it)", () => { + for (const [dir, name] of [["/srv/pi-dispatch", "pi-dispatch"], ["/home/op/My Deploy.v2_x", "mydeployv2_x"], ["/srv/Prod_Queue", "prod_queue"], ["/srv/-lead", "lead"], ["/srv/..", "pi-dispatch"], ["/srv/ÜBER", "ber"]]) { + assert.equal(mod.composeProjectName(dir), name, dir); + } + assert.notEqual(mod.composeProjectName("/srv/a/deploy-parent"), "deploy", "never the deploy/ directory's name"); +}); + +// PR #475's review: `up` (earlier in the wizard) runs pi-dispatch-valkey, which the worker drains; compose's own valkey +// could not bind that port, or would be a second queue the receiver fills. The compose answer hands up's VOLUME to +// compose's valkey, so both use one Valkey and one queue; round 2: images first, every exit checked, VALKEY_URL's port. +function composeFolder({ envText = "" } = {}) { + const dir = emptyDir(); + const rt = plantRuntime(dir, mod.RUNTIME_VERSION); + mkdirSync(join(rt, "deploy"), { recursive: true }); + for (const f of ["docker-compose.yml", "egress-proxy.conf"]) writeFileSync(join(rt, "deploy", f), readFileSync(join(fileURLToPath(new URL("../../worker/deploy/", import.meta.url)), f))); + if (envText) writeFileSync(join(dir, ".env"), envText); + return dir; +} +/** + * The docker the hand-over asks (PR #475's review, round 3): `up` is "ours" (pi-dispatch-valkey labelled with `dir`'s real + * path), "legacy" (unlabelled, on `port`), "theirs" (another deployment's label), or absent; `volume` names what + * `docker ps -a --filter volume=` lists, `records` any other container's inspect answer. + */ +function fakeDocker({ dir = null, up = null, port = 6379, volume = null, records = {}, asked = [], volumeLabel = "mine" } = {}) { + const rec = (labels, p = port) => ({ code: 0, stdout: JSON.stringify([{ Name: "/pi-dispatch-valkey", Config: { Labels: labels }, HostConfig: { PortBindings: { "6379/tcp": [{ HostIp: "127.0.0.1", HostPort: String(p) }] } } }]), stderr: "" }); + return (cmd, args) => { + asked.push([cmd, ...args].join(" ")); + if (args[0] === "volume") { + // pi-dispatch-valkey-data's label (the volume gap): "mine" (this folder's, as up creates it), null (unlabelled), a + // folder, or "absent". + if (volumeLabel === "absent") return { code: 1, stdout: "", stderr: "Error: no such volume\n" }; + const label = volumeLabel === "mine" ? (dir ? realpathSync(dir) : null) : volumeLabel; + return { code: 0, stdout: JSON.stringify([{ Name: "pi-dispatch-valkey-data", Labels: label ? { "com.pi-dispatch.deployment": label } : {} }]), stderr: "" }; + } + if (args[0] === "ps") return { code: 0, stdout: (volume ?? (up ? ["pi-dispatch-valkey"] : [])).map((n) => `${n}\n`).join(""), stderr: "" }; + const name = args.at(-1); + if (name === "pi-dispatch-valkey" && up === "ours") return rec({ "com.pi-dispatch.deployment": realpathSync(dir) }); + if (name === "pi-dispatch-valkey" && up === "legacy") return rec({}); + if (name === "pi-dispatch-valkey" && up === "theirs") return rec({ "com.pi-dispatch.deployment": "/srv/other" }, 16496); + if (records[name]) return records[name]; + return { code: 1, stdout: "", stderr: `Error: No such container: ${name}\n` }; + }; +} +const recorder = (over = {}, codeOf = () => 0) => { + const rec = wizardDeps({ ...over, runAttachedFn: async (_ctx, opts) => (rec.attached.push(opts), { code: codeOf(opts) }) }); + return rec; +}; + +test("wizard: the compose answer hands up's Valkey over to compose's (one volume, one queue): pull first, then stop, rm, compose", async () => { + const repo = tempDir("admin-setup-repo-"); + const dir = composeFolder(); + const project = mod.composeProjectName(dir); + const override = join(dir, "deploy", "docker-compose.valkey.yml"); + const base = ["compose", "-p", project, "--env-file", ".env", "-f", "deploy/docker-compose.yml", "-f", "deploy/docker-compose.valkey.yml", "--profile", "receiver"]; + const first = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [true])); + const rec = recorder({ dockerQueryFn: fakeDocker({ dir, up: "ours" }) }); + await mod.runSetupWizard({}, tuiCtx(first.ui, repo), first.ui.notify, rec.deps); + const confirm = first.seen.confirm.at(-1).message; + assert.ok(confirm.includes(mod.VALKEY_HANDOVER_OVERRIDE.split("\n")[5]), "the confirm shows the override's contents"); + assert.match(confirm, /docker stop pi-dispatch-valkey {3}\(a job running right now is interrupted\)\n {2}docker rm pi-dispatch-valkey/); + const pullBase = ["compose", "-p", project, "--env-file", ".env", "-f", "deploy/docker-compose.yml", "--profile", "receiver"]; + assert.deepEqual(rec.attached.map((a) => [a.argv0, ...a.args]), [["docker", ...pullBase, "pull"], ["docker", "stop", "pi-dispatch-valkey"], ["docker", "rm", "pi-dispatch-valkey"], ["docker", ...base, "up", "-d"]], "the images while up's Valkey still serves (without the override, not yet written: compose refuses a missing -f, measured), then stop (its AOF written), rm, compose on the same volume"); + assert.equal(rec.attached[0].env.PI_VALKEY_PORT, "6379", "compose's valkey on VALKEY_URL's port"); + assert.equal(readFileSync(override, "utf8"), mod.VALKEY_HANDOVER_OVERRIDE); + assert.match(mod.VALKEY_HANDOVER_OVERRIDE, /^ {2}valkey:\n {4}volumes:\n {6}- pi-dispatch-valkey-data:\/data$/m); + assert.match(mod.VALKEY_HANDOVER_OVERRIDE, /^ {2}pi-dispatch-valkey-data:\n {4}external: true$/m); + // A later pass with no up container: the override stays in the command, nothing stopped, never rewritten. + writeFileSync(override, "# the operator's own edit\n"); + const again = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [true])); + const rec2 = recorder({ dockerQueryFn: fakeDocker({ dir }) }); + await mod.runSetupWizard({}, tuiCtx(again.ui, repo), again.ui.notify, rec2.deps); + assert.deepEqual(rec2.attached.map((a) => [a.argv0, ...a.args]), [["docker", ...base, "up", "-d"]]); + // Round 2: up's container back beside an existing override (up re-created it, measured) is handed over again, the + // override left as it is: whether up's Valkey runs decides it, never the override's presence. + const third = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [true])); + const rec3 = recorder({ dockerQueryFn: fakeDocker({ dir, up: "ours" }) }); + await mod.runSetupWizard({}, tuiCtx(third.ui, repo), third.ui.notify, rec3.deps); + assert.deepEqual(rec3.attached.map((a) => a.args.at(-1)), ["pull", "pi-dispatch-valkey", "pi-dispatch-valkey", "-d"]); + assert.equal(readFileSync(override, "utf8"), "# the operator's own edit\n", "an existing override is never rewritten"); +}); + +test("wizard: a hand-over publishes on VALKEY_URL's port, and a failed step says what runs and the way back (#475 round 2)", async () => { + const repo = tempDir("admin-setup-repo-"); + // VALKEY_URL on 16495: compose's valkey is published there too, so the queue never moves off the worker's port. + const dir = composeFolder({ envText: "VALKEY_URL=redis://127.0.0.1:16495\n" }); + const ui = wizardUi(edgeAnswers(dir, EDGE_COMPOSE, [true])); + const rec = recorder({ dockerQueryFn: fakeDocker({ dir, up: "ours" }) }); + await mod.runSetupWizard({}, tuiCtx(ui.ui, repo), ui.ui.notify, rec.deps); + assert.ok(rec.attached.every((a) => a.args[0] !== "compose" || a.env.PI_VALKEY_PORT === "16495"), "every compose step on 16495"); + assert.match(ui.seen.confirm.at(-1).message, /PI_VALKEY_PORT=16495 docker compose -p /); + // The pull fails: up's Valkey untouched, nothing written. + const d2 = composeFolder(); + const u2 = wizardUi(edgeAnswers(d2, EDGE_COMPOSE, [true])); + const r2 = recorder({ dockerQueryFn: fakeDocker({ dir: d2, up: "ours" }) }, (o) => (o.args.at(-1) === "pull" ? 1 : 0)); + await mod.runSetupWizard({}, tuiCtx(u2.ui, repo), u2.ui.notify, r2.deps); + assert.deepEqual(r2.attached.map((a) => a.args.at(-1)), ["pull"]); + assert.ok(!existsSync(join(d2, "deploy", "docker-compose.valkey.yml"))); + assert.ok(u2.notes.some((n) => n.t === "error" && /pull failed; up's Valkey was left running and nothing else was done/.test(n.m))); + // Compose fails after the removal: the override is put back as it was found, and the way back is named. + const d3 = composeFolder(); + const u3 = wizardUi(edgeAnswers(d3, EDGE_COMPOSE, [true])); + const r3 = recorder({ dockerQueryFn: fakeDocker({ dir: d3, up: "ours" }) }, (o) => (o.args.at(-1) === "-d" ? 1 : 0)); + await mod.runSetupWizard({}, tuiCtx(u3.ui, repo), u3.ui.notify, r3.deps); + assert.ok(!existsSync(join(d3, "deploy", "docker-compose.valkey.yml")), "removed again, so `up` starts pi-dispatch-valkey, not compose's"); + assert.ok(u3.notes.some((n) => n.t === "error" && /failed after up's Valkey was removed: NO Valkey runs now\. Its volume, pi-dispatch-valkey-data, and the queue in it are kept: `node .* up` \(in .*\) starts pi-dispatch-valkey on it again\./.test(n.m))); + // A failed stop starts nothing more and puts the override back. + const d4 = composeFolder(); + const u4 = wizardUi(edgeAnswers(d4, EDGE_COMPOSE, [true])); + const r4 = recorder({ dockerQueryFn: fakeDocker({ dir: d4, up: "ours" }) }, (o) => (o.args[0] === "stop" ? 1 : 0)); + await mod.runSetupWizard({}, tuiCtx(u4.ui, repo), u4.ui.notify, r4.deps); + assert.deepEqual(r4.attached.map((a) => a.args.at(-1) === "pull" ? "pull" : a.args[0]), ["pull", "stop"]); + assert.ok(!existsSync(join(d4, "deploy", "docker-compose.valkey.yml"))); + // Declined: nothing runs, and the hint carries the whole of it, the override's contents included. + const d5 = composeFolder(); + const u5 = wizardUi(edgeAnswers(d5, EDGE_COMPOSE, [false])); + const r5 = recorder({ dockerQueryFn: fakeDocker({ dir: d5, up: "ours" }) }); + await mod.runSetupWizard({}, tuiCtx(u5.ui, repo), u5.ui.notify, r5.deps); + assert.equal(r5.attached.length, 0); + assert.ok(u5.notes.some((n) => n.m.startsWith("later: ") && n.m.includes(mod.VALKEY_HANDOVER_OVERRIDE) && /docker stop pi-dispatch-valkey && docker rm pi-dispatch-valkey; then /.test(n.m))); +}); + +// PR #475's review, round 3 (measured on Fedora: the hand-over, re-run in a folder whose override was on disk while +// pi-dispatch-valkey was ANOTHER deployment's, stopped and removed that Valkey and cut its worker off). The hand-over acts +// only on a pi-dispatch-valkey that is provably this deployment's, and never starts compose's valkey on the volume while +// a container that is not this deployment's mounts it. +test("wizard: the hand-over never stops, removes or mounts beside another deployment's Valkey (#475 round 3)", async () => { + const repo = tempDir("admin-setup-repo-"); + const override = (d) => join(d, "deploy", "docker-compose.valkey.yml"); + // The Fedora case: the override from an earlier hand-over, pi-dispatch-valkey another deployment's on the volume. + const d1 = composeFolder(); + mkdirSync(join(d1, "deploy"), { recursive: true }); + writeFileSync(override(d1), mod.VALKEY_HANDOVER_OVERRIDE); + const u1 = wizardUi(edgeAnswers(d1, EDGE_COMPOSE, [true])); + const r1 = recorder({ dockerQueryFn: fakeDocker({ dir: d1, up: "theirs" }) }); + await mod.runSetupWizard({}, tuiCtx(u1.ui, repo), u1.ui.notify, r1.deps); + assert.equal(r1.attached.length, 0, "nothing pulled, stopped, removed or started"); + assert.ok(u1.notes.some((n) => n.t === "error" && /^the receiver container was not started: pi-dispatch-valkey-data is mounted by pi-dispatch-valkey \(the deployment in \/srv\/other\), which is not this deployment's Valkey: a second Valkey on the same AOF would corrupt both queues/.test(n.m)), JSON.stringify(u1.notes)); + // Without the override, compose's valkey keeps its own volume: the receiver starts, the other Valkey is named and left. + const d2 = composeFolder(); + const u2 = wizardUi(edgeAnswers(d2, EDGE_COMPOSE, [true])); + const r2 = recorder({ dockerQueryFn: fakeDocker({ dir: d2, up: "theirs" }) }); + await mod.runSetupWizard({}, tuiCtx(u2.ui, repo), u2.ui.notify, r2.deps); + assert.deepEqual(r2.attached.map((a) => a.args.at(-1)), ["-d"], "compose up only: no pull-for-hand-over, no stop, no rm"); + assert.ok(!r2.attached.some((a) => a.args.includes("deploy/docker-compose.valkey.yml"))); + assert.ok(!existsSync(override(d2)), "no override written"); + assert.ok(u2.notes.some((n) => n.t === "warning" && /pi-dispatch-valkey is not this deployment's Valkey \(the deployment in \/srv\/other\), so it is never stopped, removed or reused from here; it is left running/.test(n.m))); + // Legacy, unlabelled: ours only on this deployment's VALKEY_URL port. + for (const [port, handed] of [[6379, true], [16496, false]]) { + const d = composeFolder(); + const u = wizardUi(edgeAnswers(d, EDGE_COMPOSE, [true])); + const r = recorder({ dockerQueryFn: fakeDocker({ dir: d, up: "legacy", port }) }); + await mod.runSetupWizard({}, tuiCtx(u.ui, repo), u.ui.notify, r.deps); + assert.equal(r.attached.some((a) => a.args[0] === "stop"), handed, `legacy on ${port}`); + } + // Ours, but another deployment's container also mounts the volume: refused before anything runs. + const d3 = composeFolder(); + const u3 = wizardUi(edgeAnswers(d3, EDGE_COMPOSE, [true])); + const other = { code: 0, stdout: JSON.stringify([{ Name: "/b-valkey-1", Config: { Labels: { "com.docker.compose.project.working_dir": "/srv/b/deploy" } } }]), stderr: "" }; + const r3 = recorder({ dockerQueryFn: fakeDocker({ dir: d3, up: "ours", volume: ["pi-dispatch-valkey", "b-valkey-1"], records: { "b-valkey-1": other } }) }); + await mod.runSetupWizard({}, tuiCtx(u3.ui, repo), u3.ui.notify, r3.deps); + assert.equal(r3.attached.length, 0); + assert.ok(u3.notes.some((n) => n.t === "error" && /mounted by b-valkey-1 \(the compose project in \/srv\/b\/deploy\)/.test(n.m))); + // docker not answering is a refusal, never a guess. + const d4 = composeFolder(); + const u4 = wizardUi(edgeAnswers(d4, EDGE_COMPOSE, [true])); + const r4 = recorder({ dockerQueryFn: () => ({ code: null, stdout: "", stderr: "" }) }); + await mod.runSetupWizard({}, tuiCtx(u4.ui, repo), u4.ui.notify, r4.deps); + assert.equal(r4.attached.length, 0); + assert.ok(u4.notes.some((n) => n.t === "error" && /whether pi-dispatch-valkey is this deployment's could not be read/.test(n.m))); +}); + +// PR #475's review, round 3: the docs' plain compose command in the folder must publish where the worker dials. +test("wizard: the compose answer writes PI_VALKEY_PORT into .env when VALKEY_URL's port is not 6379, never over a different value (#475 round 3)", async () => { + const repo = tempDir("admin-setup-repo-"); + const d1 = composeFolder({ envText: "VALKEY_URL=redis://127.0.0.1:16495\n" }); + const u1 = wizardUi(edgeAnswers(d1, EDGE_COMPOSE, [true])); + await mod.runSetupWizard({}, tuiCtx(u1.ui, repo), u1.ui.notify, recorder().deps); + assert.match(readFileSync(join(d1, ".env"), "utf8"), /^PI_VALKEY_PORT=16495$/m); + const d2 = composeFolder({ envText: "VALKEY_URL=redis://127.0.0.1:16495\nPI_VALKEY_PORT=16000\n" }); + const u2 = wizardUi(edgeAnswers(d2, EDGE_COMPOSE, [true])); + await mod.runSetupWizard({}, tuiCtx(u2.ui, repo), u2.ui.notify, recorder().deps); + assert.equal(readFileSync(join(d2, ".env"), "utf8"), "VALKEY_URL=redis://127.0.0.1:16495\nPI_VALKEY_PORT=16000\n", "never overwritten"); + assert.ok(u2.notes.some((n) => n.t === "warning" && /PI_VALKEY_PORT is 16000 in .*\.env, and VALKEY_URL's port is 16495/.test(n.m))); + const d3 = composeFolder({ envText: "VALKEY_URL=redis://127.0.0.1:6379\n" }); + const u3 = wizardUi(edgeAnswers(d3, EDGE_COMPOSE, [true])); + await mod.runSetupWizard({}, tuiCtx(u3.ui, repo), u3.ui.notify, recorder().deps); + assert.doesNotMatch(readFileSync(join(d3, ".env"), "utf8"), /PI_VALKEY_PORT/); +}); + +// PR #475's review, the volume gap: the hand-over's volume keeps the rule `up` has. Another folder's label refuses; an +// unlabelled volume no container of this deployment serves is adopted only after its own question; every compose start +// on it checks the queue's pi-dispatch:owner marker, and another folder's stops compose's valkey again. +test("wizard: pi-dispatch-valkey-data is used only when it is this folder's, an unlabelled one only after its own question, and the owner marker is checked (#475 volume gap)", async () => { + const repo = tempDir("admin-setup-repo-"); + const withOverride = () => { + const d = composeFolder(); + mkdirSync(join(d, "deploy"), { recursive: true }); + writeFileSync(join(d, "deploy", "docker-compose.valkey.yml"), mod.VALKEY_HANDOVER_OVERRIDE); + return d; + }; + // Another folder's label: refused, nothing run. + const d1 = withOverride(); + const u1 = wizardUi(edgeAnswers(d1, EDGE_COMPOSE, [true])); + const r1 = recorder({ dockerQueryFn: fakeDocker({ dir: d1, volumeLabel: "/srv/a" }) }); + await mod.runSetupWizard({}, tuiCtx(u1.ui, repo), u1.ui.notify, r1.deps); + assert.equal(r1.attached.length, 0); + assert.ok(u1.notes.some((n) => n.t === "error" && /pi-dispatch-valkey-data belongs to the deployment in \/srv\/a \(its label\), so this deployment never uses it/.test(n.m))); + // Unlabelled, declined: its own question names the risk; nothing run. + const d2 = withOverride(); + const u2 = wizardUi(edgeAnswers(d2, EDGE_COMPOSE, [false])); + const r2 = recorder({ dockerQueryFn: fakeDocker({ dir: d2, volumeLabel: null }) }); + await mod.runSetupWizard({}, tuiCtx(u2.ui, repo), u2.ui.notify, r2.deps); + assert.equal(r2.attached.length, 0); + assert.match(u2.seen.confirm.at(-1).message, /this volume holds a queue pi-dispatch cannot attribute to a folder/); + assert.ok(u2.notes.some((n) => n.t === "error" && /has no owner label and was not adopted/.test(n.m))); + // Unlabelled, adopted, and the marker is another folder's: compose's valkey is stopped again at once. + const d3 = withOverride(); + const u3 = wizardUi(edgeAnswers(d3, EDGE_COMPOSE, [true, true])); + const r3 = recorder({ dockerQueryFn: fakeDocker({ dir: d3, volumeLabel: null }), claimOwnerFn: async () => ({ owner: "/srv/a", claimed: false }) }); + await mod.runSetupWizard({}, tuiCtx(u3.ui, repo), u3.ui.notify, r3.deps); + assert.deepEqual(r3.attached.map((a) => a.args.slice(-2).join(" ")), ["up -d", "stop valkey"]); + assert.ok(u3.notes.some((n) => n.t === "error" && /the queue on pi-dispatch-valkey-data is the deployment's in \/srv\/a \(pi-dispatch:owner inside it\), not this one's: compose's valkey was stopped again at once/.test(n.m))); + // This folder's label: no question, the marker recorded after the start. + const d4 = withOverride(); + const u4 = wizardUi(edgeAnswers(d4, EDGE_COMPOSE, [true])); + const claims = []; + const r4 = recorder({ dockerQueryFn: fakeDocker({ dir: d4 }), claimOwnerFn: async (url, folder) => (claims.push([url, folder]), { owner: folder, claimed: true }) }); + await mod.runSetupWizard({}, tuiCtx(u4.ui, repo), u4.ui.notify, r4.deps); + assert.deepEqual(r4.attached.map((a) => a.args.at(-1)), ["-d"]); + assert.deepEqual(claims, [["redis://127.0.0.1:6379", realpathSync(d4)]]); + assert.ok(u4.notes.some((n) => /recorded pi-dispatch:owner=/.test(n.m))); + // The hand-over of this deployment's own pi-dispatch-valkey serving an unlabelled (pre-label) volume: no question. + const d5 = composeFolder(); + const u5 = wizardUi(edgeAnswers(d5, EDGE_COMPOSE, [true])); + const r5 = recorder({ dockerQueryFn: fakeDocker({ dir: d5, up: "ours", volumeLabel: null }) }); + await mod.runSetupWizard({}, tuiCtx(u5.ui, repo), u5.ui.notify, r5.deps); + assert.deepEqual(r5.attached.map((a) => a.args.at(-1)), ["pull", "pi-dispatch-valkey", "pi-dispatch-valkey", "-d"]); + assert.ok(!u5.seen.confirm.some((c) => /cannot attribute to a folder/.test(c.message)), "no adoption question"); +}); diff --git a/admin/test/wiring.test.mjs b/admin/test/wiring.test.mjs index 072f7864..85581ec2 100644 --- a/admin/test/wiring.test.mjs +++ b/admin/test/wiring.test.mjs @@ -1344,7 +1344,10 @@ test("index.ts resolves paths and the worker name only through deploymentEnv (#4 const src = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8").replace(/^\s*(\/\/|\*).*$/gm, ""); const calls = [...src.matchAll(/\bresolvePaths\(/g)].map((m) => src.slice(m.index, m.index + 30)); assert.deepEqual(calls.filter((c) => !c.startsWith("resolvePaths(deploymentEnv())")), [], "every resolvePaths call takes the resolved environment"); - assert.ok(calls.length > 20, "and there are still the call sites there were"); + // Two fewer since the rebase over issue #468: the pause and resume tools resolve their Valkeys through `killSwitchSet` + // (the CLI's rule over the pointer's `.env`, read by this issue's one reader), not through resolvePaths. + assert.ok(calls.length > 18, "and there are still the call sites there were"); + assert.equal([...src.matchAll(/await killSwitchSet\(\{ paused/g)].length, 3, "the two tools and /dispatch pause|resume"); assert.doesNotMatch(src, /process\.env\.PI_WORKER_NAME/); assert.match(src, /enqueueDispatchRun\(\{ folder, flow, task, aiInvoked: false, env: deploymentEnv\(\) \}\)/); assert.match(src, /aiInvoked: true,\n\s*env: deploymentEnv\(\),/); diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 85d93824..1b6374c3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -8,9 +8,13 @@ # stays first-class. The `receiver` profile keeps the container OPT-IN: a plain `up` stays Valkey-only, # so worker-on-host deployments that already run the receiver on the host gain no second copy. # -# docker compose -f deploy/docker-compose.yml up -d # start Valkey (default) -# docker compose -f deploy/docker-compose.yml --profile receiver up -d # Valkey + receiver container -# pi-dispatch worker # drain the queue (host) +# docker compose --env-file .env -f deploy/docker-compose.yml up -d # start Valkey (default) +# docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d # Valkey + receiver container +# pi-dispatch worker # drain the queue (host) +# +# `--env-file .env` (run from the deployment folder) is how compose reads VALKEY_PASSWORD for the Valkey below (issue +# #468). Without it compose looks for a .env beside THIS file, finds none, and starts Valkey with no password, which +# any local account can then read and feed; doctor warns about exactly that. # # On rootful Podman, run the same commands with the real docker CLI pointed at Podman's socket through a docker # context (docs/podman.md). Measured with the egress profile on Podman 5.8.2; podman-docker is not the supported route. @@ -27,16 +31,29 @@ services: valkey: image: valkey/valkey:8 - # AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). - command: valkey-server --appendonly yes - # Bound to localhost only -- the queue is not a public surface. + # The password (issue #468), from the deployment's .env through `--env-file .env`, into the container's ENVIRONMENT + # and never onto a command line: the image's PID 1 is tini, which keeps its whole argv for the life of the container, + # readable by every account on the host in /proc (measured). Empty or unset starts Valkey without one, as before. + environment: + VALKEY_PASSWORD: ${VALKEY_PASSWORD:-} + # AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). The password reaches valkey-server as + # configuration on stdin (`valkey-server -`), written by the shell's own echo builtin to a 0600 temp file that is + # opened and deleted before the image's entrypoint runs. `$$` is compose's escape for a literal `$`: the shell in the + # container expands these, never compose. The same script as the Quadlet unit and `pi-dispatch up`'s docker run. + command: ["sh", "-c", 'set -eu; if [ -n "$${VALKEY_PASSWORD:-}" ]; then umask 077; f=$$(mktemp); echo "requirepass $$VALKEY_PASSWORD" >"$$f"; exec 0<"$$f"; rm -f "$$f"; set -- -; else set --; fi; unset VALKEY_PASSWORD; exec docker-entrypoint.sh valkey-server "$$@" --appendonly yes'] + # Bound to localhost only -- the queue is not a public surface. On VALKEY_URL's port: PI_VALKEY_PORT, which + # `pi-dispatch up` and the setup wizard set from VALKEY_URL when they run this file, and which a deployment on a port + # other than 6379 puts in its .env for a compose command run by hand (PR #475's review: the port was 6379 whatever + # VALKEY_URL said, so the queue could move away from the worker). ports: - - "127.0.0.1:6379:6379" + - "127.0.0.1:${PI_VALKEY_PORT:-6379}:6379" volumes: - valkey-data:/data restart: unless-stopped healthcheck: - test: ["CMD", "valkey-cli", "ping"] + # Healthy only when Valkey answers PONG to this deployment's password (valkey-cli reads REDISCLI_AUTH from its + # environment); a bare `valkey-cli ping` exits 0 on NOAUTH too. + test: ["CMD-SHELL", 'if [ -n "$${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG'] interval: 10s timeout: 3s retries: 5 @@ -51,7 +68,7 @@ services: profiles: ["receiver"] image: ghcr.io/edgehero/pi-dispatch-receiver:latest # The operator's .env at the repo root, same file the host services read (WEBHOOK_SECRET, github - # auth, forge blocks). Compose refuses to start the profile when it is missing -- correctly: a + # auth, forge blocks, and VALKEY_PASSWORD, which the receiver sends to the Valkey above). Compose refuses to start the profile when it is missing -- correctly: a # receiver without WEBHOOK_SECRET refuses to boot anyway, so fail at the clearer message. env_file: ../.env # `environment` OVERRIDES env_file, deliberately: a host .env says redis://127.0.0.1:6379, which @@ -83,7 +100,7 @@ services: # above: a plain `up` stays Valkey-only, and a deployment that has not turned PI_EGRESS on never needs # this service at all. # - # docker compose -f deploy/docker-compose.yml --profile egress up -d + # docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d # # It sits on ONE network here, the upstream one. The networks that matter are created by the WORKER, one # per job, `--internal`, and this container is attached to each for the life of that job and detached diff --git a/deploy/pi-dispatch-valkey.container b/deploy/pi-dispatch-valkey.container index d41c4e49..2cf9c246 100644 --- a/deploy/pi-dispatch-valkey.container +++ b/deploy/pi-dispatch-valkey.container @@ -3,7 +3,8 @@ # under this account's own rootless Podman instead of a docker daemon. # # Installed by `pi-dispatch service install` (user scope, with podman in PI_BACKENDS) and by `pi-dispatch up`, which -# copy it unchanged into ~/.config/containers/systemd/. Podman's systemd generator turns it into +# copy it into ~/.config/containers/systemd/ unchanged but for a VALKEY_URL port other than 6379, with the password +# file it reads beside it. Podman's systemd generator turns it into # pi-dispatch-valkey.service. Never `systemctl --user enable` it: a generated unit refuses that ("transient or # generated"); the [Install] section below is read by the generator itself, which links it into default.target # (measured on Podman 5.8.1: with linger on it came up 25 s after a reboot with nobody logged in). @@ -24,9 +25,19 @@ Network=pi-dispatch-valkey.network PublishPort=127.0.0.1:6379:6379 # The same named volume `pi-dispatch up` creates on docker, so the wait-list survives a restart and a reboot. Volume=pi-dispatch-valkey-data:/data -# AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). -Exec=valkey-server --appendonly yes -HealthCmd=valkey-cli ping +# The password (issue #468): VALKEY_PASSWORD from a 0600 file `service install` and `up` write from the deployment's +# .env, into the container's ENVIRONMENT. Never `--requirepass` on a command line: the image's PID 1 is tini, which keeps +# its whole argv for the life of the container, and any account on the host reads it in /proc//cmdline (measured +# on Podman 5.8.1 and 4.9.3). Quadlet also loads this file into the unit's own environment, where systemd would expand a +# bare $VALKEY_PASSWORD in the command below into the password itself: every $ below is written $$ for that reason. +EnvironmentFile=%h/.config/pi-dispatch/valkey.env +# AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). The password reaches valkey-server as +# configuration on stdin (`valkey-server -`), written by the shell's own echo builtin to a 0600 temp file that is opened +# and deleted before the image's entrypoint runs; with VALKEY_PASSWORD empty it starts with none, as before. +Exec=sh -c 'set -eu; if [ -n "$${VALKEY_PASSWORD:-}" ]; then umask 077; f=$$(mktemp); echo "requirepass $$VALKEY_PASSWORD" >"$$f"; exec 0<"$$f"; rm -f "$$f"; set -- -; else set --; fi; unset VALKEY_PASSWORD; exec docker-entrypoint.sh valkey-server "$$@" --appendonly yes' +# Healthy only when Valkey answers PONG to this deployment's password (valkey-cli reads REDISCLI_AUTH from its +# environment); a bare `valkey-cli ping` exits 0 on NOAUTH too. +HealthCmd=if [ -n "$${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG HealthInterval=10s HealthTimeout=3s HealthRetries=5 diff --git a/deploy/worker.service b/deploy/worker.service index 1a7a866e..e6c624cb 100644 --- a/deploy/worker.service +++ b/deploy/worker.service @@ -34,7 +34,7 @@ Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker) After=network-online.target docker.service Wants=network-online.target -# Valkey must be reachable (docker compose -f deploy/docker-compose.yml up -d), but it is a separate +# Valkey must be reachable (docker compose --env-file .env -f deploy/docker-compose.yml up -d), but it is a separate # unit/container -- not ordered here since it may be remote. # Crash-loop bound: at most StartLimitBurst restarts within StartLimitIntervalSec, then systemd stops # trying. A restart loop against a paid provider is a bill, not just log noise. Pairs with diff --git a/docs/azure-devops.md b/docs/azure-devops.md index dc9291a4..50373bd9 100644 --- a/docs/azure-devops.md +++ b/docs/azure-devops.md @@ -70,7 +70,7 @@ endpoint) answers 404 rather than 401, because an endpoint that answers is one y Add a `github` trigger later and both become required again, as they should. Then start it: `pi-dispatch-receiver` from your deployment folder, where `serve` is the default command, or -the container profile instead (`docker compose -f deploy/docker-compose.yml --profile receiver up -d`); the +the container profile instead (`docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d`); the README lays out the choice. The third way the README offers, `pi-dispatch-receiver poll`, cannot serve this forge: the poller reads api.github.com and has no Azure path at all. diff --git a/docs/backup.md b/docs/backup.md index fd5f5a50..700619a5 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -92,9 +92,13 @@ all, so a copy taken mid-run can catch a partial one. Stopping first is what buy For `deploy/docker-compose.yml`, the service has no fixed container name and Compose prefixes the volume with the project name, so ask it: ```bash - docker compose -f deploy/docker-compose.yml stop valkey + docker compose --env-file .env -f deploy/docker-compose.yml stop valkey docker volume ls --filter name=valkey-data # the prefixed name is what you tar ``` + In a folder `/dispatch setup` laid out, add `-p ` after `compose` (and + `-f deploy/docker-compose.valkey.yml` when setup wrote that file): without it compose names the project `deploy` + and stops nothing of this folder's. Where that file exists, the Valkey's volume is `pi-dispatch-valkey-data`, + as for `pi-dispatch up` above. 5. **Copy the files with `cp -a`.** The session store is listed separately and on purpose: it is the most PII bearing thing here, so it is never swept up by a wildcard. If yours lives under `~/.pi-dispatch/sessions`, which is what `docs/sessions.md` suggests, then copying that whole diff --git a/docs/egress.md b/docs/egress.md index 267caacc..00cf9e94 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -11,10 +11,16 @@ and a job whose policy cannot serve it is **refused before it costs anything**. What you have to do, once: ```bash -docker compose -f deploy/docker-compose.yml --profile egress up -d +docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d pi-dispatch doctor ``` +In a folder `/dispatch setup` laid out, name the folder's project and, when setup wrote it, its Valkey override, so +compose addresses the containers it already runs: +`docker compose -p --env-file .env -f deploy/docker-compose.yml -f deploy/docker-compose.valkey.yml --profile egress up -d` +(the project name rule, and its edge cases, are in [podman.md](podman.md), step 7). `pi-dispatch up` prints the +command for its own folder. + **If you are upgrading**, that is the step. Until the proxy is up, every job is refused pre-spend naming it and naming that command: loud, free (no budget slot, no tokens), and reversible in one line with `PI_EGRESS=0` if you want the old posture back. `pi-dispatch up` offers to start it (on the rootless Podman venue, diff --git a/docs/forgejo.md b/docs/forgejo.md index 97c9636b..5616eae6 100644 --- a/docs/forgejo.md +++ b/docs/forgejo.md @@ -73,7 +73,7 @@ believe is armed. Add a `github` trigger later and both become required again, a **5. Start it.** `pi-dispatch-receiver` from your deployment folder; `serve` is the default command, so there is nothing to type after the name. A container profile is the alternative -(`docker compose -f deploy/docker-compose.yml --profile receiver up -d`), and the README lays out the +(`docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d`), and the README lays out the choice. The third way the README offers, `pi-dispatch-receiver poll`, cannot serve this forge: the poller reads api.github.com and has no Forgejo path at all. diff --git a/docs/gitlab.md b/docs/gitlab.md index 64e1ec1a..ca911989 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -79,7 +79,7 @@ secret are set when your triggers name GitLab, plus `WEBHOOK_SECRET` for any for **6. Start it.** `pi-dispatch-receiver` from your deployment folder; `serve` is the default command, so there is nothing to type after the name. A container profile is the alternative -(`docker compose -f deploy/docker-compose.yml --profile receiver up -d`), and the README lays out the +(`docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d`), and the README lays out the choice. The third way the README offers, `pi-dispatch-receiver poll`, cannot serve this forge: the poller reads api.github.com and has no GitLab path at all. diff --git a/docs/podman.md b/docs/podman.md index 066996a3..fed59f66 100644 --- a/docs/podman.md +++ b/docs/podman.md @@ -193,11 +193,33 @@ and `pi-dispatch up` was not. 6. **Load the job image and name it as Podman stores it.** `docker pull ghcr.io/edgehero/pi-job:latest`, then set `PI_JOB_IMAGE` to the name `docker images` shows for it. 7. **Start the stack the same way you would on Docker.** It is the same compose file: - `docker compose -f deploy/docker-compose.yml up -d` for Valkey, and `--profile egress` as well if you use the + `docker compose --env-file .env -f deploy/docker-compose.yml up -d` for Valkey, and `--profile egress` as well if you use the egress policy. Podman serves it through the same Docker API, and compose needs no adaptation (measured with v2.33.0 in the lab and 5.5.1 on Fedora 44). Its config mounts carry `:ro,z` for an SELinux host (see SELinux below), which does nothing where SELinux is off. `podman compose` is not a second implementation: it executes whatever compose provider it finds, which on a host set up this way is that same binary, and it says so on stderr. + `--env-file .env` is how compose reads the deployment's `VALKEY_PASSWORD` (issue #468) into the Valkey's + environment. Measured on Fedora 44 through root's Podman socket with compose 5.5.1: a first `up -d` from a `.env` + without the key started Valkey with no password as before; the same command after the key was added recreated the + container, which then answered `NOAUTH` to a client with no password and kept the key written before (the volume + is kept), and no command line on the host carried the password meanwhile. + In a folder `/dispatch setup` laid out (PR #475's review), the file sits in `deploy/` of that folder and every + command names the folder's project, `docker compose -p ...`, since compose otherwise names the + project after `deploy/` and every such folder would share one. The name is the folder's, as compose normalises a + directory name: lower case, only letters, digits, `_` and `-`, no leading `_` or `-` ("My Deploy.v2" is + `mydeployv2`). Two edge cases: a folder whose name has none of those characters (all non-ASCII, "日本") gets + `pi-dispatch`, where compose itself would refuse the folder's name; and a `COMPOSE_PROJECT_NAME` in the folder's + `.env` named the project of an earlier setup's copy, which `-p` overrides, so set `-p` to that value if you + relied on it. Where `pi-dispatch up` had already started the deployment's Valkey, setup also writes + `deploy/docker-compose.valkey.yml`, which gives compose's Valkey that same volume (`pi-dispatch-valkey-data`); the + commands then carry `-f deploy/docker-compose.valkey.yml` too, and `up` itself starts compose's Valkey there. The + Valkey is published on `PI_VALKEY_PORT` (VALKEY_URL's port, 6379 unset), which `up` and setup write into `.env` + when it is not 6379. Neither ever stops, removes or starts beside a Valkey that is not this folder's: `up` labels + its `pi-dispatch-valkey` with the folder, and a container on `pi-dispatch-valkey-data` that another deployment + started makes both refuse, naming it (stop that deployment's Valkey first, or give this folder a Valkey of its own: + compose without the override keeps its own volume). The volume itself carries the folder that created it, and + the queue inside records it too (`pi-dispatch:owner`): another folder's is never used, and a volume from before the + label is used only after `up` asks (even under `--yes`), since its queue cannot be attributed to a folder. One file for both daemons is not one file for two stacks: it fixes the proxy's container name, the egress network's name and Valkey's published port, so one host runs one of these stacks (true on Docker too). 8. **Run `pi-dispatch up`, then `pi-dispatch doctor --live`.** doctor names the runtime (`local: the daemon is Podman @@ -1037,6 +1059,44 @@ an unprivileged account (uid 1234), on 2026-09-25. Run everything below as the w `.env` is read with the same reader as the service's other keys: a line systemd reads differently (a lone CR, a NUL, invalid UTF-8) is named, and then only this account's own Valkey is used. `--force` does not take it: it replaces changed files, and taking another account's queue must never ride along with that. + A shared Valkey also takes its owner's password (below). + - **The password** (issue #468). The owner rule stops an accident; it does not stop another account that + connects on purpose, since every account on the host shares loopback, and a Valkey without a password let it + read the queued jobs (their task text, their repositories), enqueue work this account's worker runs with this + account's provider key, or delete the queue. So the Valkey this project starts has a password of its own, + `VALKEY_PASSWORD` in the deployment's `.env`. `pi-dispatch init` writes a new one (64 hex characters) into the + `.env` it creates, which it creates readable by the account alone (mode 0600); `service install` and `up` add + one to a deployment that has none, never over a value, and narrow the file to its owner. The value is never + printed: doctor and the worker's log say only whether one is set. Both commands copy it into + `~/.config/pi-dispatch/valkey.env` (0600, the one key), which the Quadlet unit reads + (`EnvironmentFile=%h/.config/pi-dispatch/valkey.env`); the container gets it in its ENVIRONMENT, and its start + command hands it to `valkey-server` as configuration on stdin (`valkey-server -`), written by the shell's own + `echo` to a 0600 temp file that is opened and deleted before the image's entrypoint runs. Never as + `--requirepass`: the image's PID 1 is `tini`, which keeps its whole command line for the life of the container, + and every account on the host read `--requirepass ` in `/proc//cmdline` (measured on Podman 5.8.1 + and 4.9.3; the image's `VALKEY_EXTRA_FLAGS` appends to that same command line). With the value empty or unset + the same command starts Valkey without one, as before. Copying the unit by hand? Create that file first + (`install -m 600 /dev/null ~/.config/pi-dispatch/valkey.env`, then one line `VALKEY_PASSWORD=`): + the unit does not start without it. Every client sends the password through the one connection module: the + worker and the receiver from their environment (the service's loader puts the `.env` there), a CLI command run + in the deployment folder from its `.env`, the admin panel from the `.env` of the deployment its pointer names + (the pointer itself never holds it). Another account connecting without it gets `NOAUTH`, and a client of this + deployment that lacks it, or sends the wrong one, is refused as a configuration error naming + `VALKEY_PASSWORD`: the worker exits 2, `pi-dispatch run` and the receiver say so, doctor prints a ✗. + - **The upgrade** of a deployment installed before the password: `pi-dispatch service install --force` as the + account. It writes a password into `.env`, the password file and the new unit, restarts the Valkey with it + (the queue in the `pi-dispatch-valkey-data` volume is kept: the stop is a SIGTERM, on which Valkey writes its AOF + out), then restarts the worker and receiver units that were running, since they read the password only at + start. A job running at that moment is interrupted, so pause first (`pi-dispatch pause`, wait for active jobs, + then `resume`). `up` on this venue leaves a running Valkey alone, as it always has, so doctor names + `service install --force` as the step, and until then warns that the Valkey answers a client that sends no + password. + - **A shared Valkey** (`PI_VALKEY_SHARED=1`): put `VALKEY_PASSWORD=` in the deployment's + `.env`; its owner gives it. `service install` and `up` generate none for a shared Valkey, since one made here + would reach no Valkey. Without it every command refuses, naming the missing password. + - An operator's own `VALKEY_URL` (another host, TLS, a managed Valkey with its password in the URL) is left as it + is: the URL's password wins, none is generated for it, and the `.env`'s `VALKEY_PASSWORD` is sent only to a + Valkey on this machine's loopback, never to one a shell's `VALKEY_URL` names elsewhere. A `VALKEY_URL` line in `.env` that the loaders may read differently is a ✗ of its own in doctor, which then contacts no Valkey at all (no reachability probe, no fleet read), since the default it would fall back to may be another @@ -1052,7 +1112,7 @@ an unprivileged account (uid 1234), on 2026-09-25. Run everything below as the w account's first, else root's where root may hold it), and refuses another account's Valkey on every venue. Only whether root's is refused depends on the deployment (the environment's `PI_BACKENDS`, else the `.env` in the folder the command runs in). The receiver judges before it listens and exits 2 on a refusal. The compose file is docker-only. Without `local` in `PI_BACKENDS`, doctor's fix for an unreachable Valkey points at `up` and - `service install`, and `doctor --fix` offers no `docker run` for it. + `service install`, and `doctor --fix` offers no `docker run` for it (on no venue since issue #468: its Valkey needs the deployment's password in a child's environment, and doctor hands no program anything from `.env`). 8. **`PI_BACKENDS=podman`** in `.env` (the setup wizard, `/dispatch setup`, writes it when you choose rootless Podman at its runtime step), then start the worker, and run `pi-dispatch doctor` and `pi-dispatch doctor --live` as the same account, from the deployment folder. doctor reads `PI_BACKENDS`, diff --git a/docs/secrets.md b/docs/secrets.md index 359462be..004be990 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -127,10 +127,18 @@ Everything else in `.env.example` is a dial, and dials are fine in plain sight. | The provider key | `ANTHROPIC_API_KEY` or your provider's own variable, per pi's table | | The forge credential | `GITHUB_PAT`, or the App trio (`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY` or `GITHUB_APP_PRIVATE_KEY_PATH`), or `GITLAB_TOKEN` / `FORGEJO_TOKEN` / `AZURE_TOKEN`. `GITHUB_PAT_VAR` renames the first of those, for a manager that insists on its own key name | | The webhook secrets | `WEBHOOK_SECRET`, `GITLAB_WEBHOOK_SECRET`, `FORGEJO_WEBHOOK_SECRET`, `AZURE_WEBHOOK_SECRET` | +| `VALKEY_PASSWORD` | the password of the Valkey pi-dispatch starts (issue #468); `init` writes it, `up` and `service install` add it when missing | | `VALKEY_URL` | only when it carries a password | -The receiver is its own process with its own unit, and it needs the webhook secret and `VALKEY_URL` the -same way. Everything on this page applies to it unchanged. +The receiver is its own process with its own unit, and it needs the webhook secret, `VALKEY_URL` and +`VALKEY_PASSWORD` the same way. Everything on this page applies to it unchanged. + +`VALKEY_PASSWORD` is a key of its own, never folded into `VALKEY_URL`: doctor prints that URL, the admin's +deployment pointer stores it, and refusals quote it, so a password inside it would travel to all three. A +manager-supplied environment works as for every other key here (the service's clients read it from their +environment first); the file's own copy is read only for a Valkey on this machine's loopback, so a shell whose +`VALKEY_URL` names another host never sends it there. The Valkey itself never receives it on a command line: +see [podman.md](podman.md), step 7, for why and how. Two notes on names, because both are variables an operator can hold wrongly for a long time. `GITHUB_PAT_VAR` says which variable the PAT actually lives in (default `GITHUB_PAT`); the name you give is read verbatim and diff --git a/launch/demo.md b/launch/demo.md index ada55e7f..f309c0fa 100644 --- a/launch/demo.md +++ b/launch/demo.md @@ -27,7 +27,7 @@ If the recording is for **onboarding** rather than for the README, lead with `/d guided wizard is the front door now, and a panel that opens already configured is the payoff shot. Run against a deployment with a little state (a couple of triggers, a finished run or two) so the panel isn't -empty — start the stack (`docker compose -f deploy/docker-compose.yml up -d`), queue one local job, let it +empty: start the stack (`docker compose --env-file .env -f deploy/docker-compose.yml up -d`), queue one local job, let it finish, then record. ## Option A — vhs (recommended for a crisp GIF) diff --git a/receiver/src/cli.mjs b/receiver/src/cli.mjs index 6be60cf8..2c0d5e17 100755 --- a/receiver/src/cli.mjs +++ b/receiver/src/cli.mjs @@ -13,7 +13,7 @@ * Thin by design, mirroring worker/src/cli.mjs: recognise the command, lazy-import the real work. */ -import { EXIT_POLICY } from "@edgehero/pi-dispatch/exit-code"; +import { EXIT_POLICY, installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code"; const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs @@ -76,6 +76,8 @@ export function entryExitCode(err) { // The error line mirrors start.mjs's own entry guard: `err.message` only -- never a secret or PII. // (start.mjs's guard keys on argv[1] ending in start.mjs, so importing it from here never double-boots.) if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) { + // An unhandled rejection is printed as its message alone (PR #475's review), never Node's print of the whole reason. + installRejectionPrinter(); main() .then((code) => { if (code) process.exitCode = code; diff --git a/receiver/src/start.mjs b/receiver/src/start.mjs index b1482b3b..5825975e 100644 --- a/receiver/src/start.mjs +++ b/receiver/src/start.mjs @@ -35,6 +35,7 @@ import { dirname, basename } from "node:path"; import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs"; import { makeReceiver } from "./receiver.mjs"; import { entryExitCode } from "./cli.mjs"; +import { installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code"; import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token"; import { resolveGitLabSelfId } from "@edgehero/pi-dispatch/gitlab-identity"; import { resolveForgejoSelfId } from "@edgehero/pi-dispatch/forgejo-identity"; @@ -367,6 +368,8 @@ function watchTriggers(env, cfg, log, triggersAtBoot) { // Entry point when run directly (main: src/start.mjs, no bin). Kept out of startReceiver so tests call // it directly. The error line carries only `err.message` -- never a secret or PII. if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("start.mjs")) { + // An unhandled rejection is printed as its message alone (PR #475's review), never Node's print of the whole reason. + installRejectionPrinter(); startReceiver(process.env).catch((err) => { process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`); // entryExitCode, NOT a bare 1. This file is what `receiver.service` execs -- cli.mjs is not on that diff --git a/specs/constitution.md b/specs/constitution.md index 69c4cb87..d751ef50 100644 --- a/specs/constitution.md +++ b/specs/constitution.md @@ -959,6 +959,7 @@ passing, on the record — issue #80.) | Date | Change | |---|---| +| 2026-09-28 | Issue #468. **No article changed.** **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: a Valkey that refuses this deployment's credential (NOAUTH, WRONGPASS) is a determinate refusal, a configError (exit 2, not restarted into the same answer) at the worker's boot and at every CLI and receiver start, while a Valkey that does not answer stays infrastructure, retried as before. **`CONST-TOKEN-SCOPED-PER-JOB` UNCHANGED, checked**: `VALKEY_PASSWORD` joins the worker-only secrets, refused in `PI_FORWARD_ENV` and as a trigger's `run.secrets` name, so no job container can hold the queue's password. **`CONST-ISOLATION-CONTAINER-PER-JOB` UNCHANGED, checked**: no job container's argv, mount or env changes; the Valkey container's command changes, and never carries the password on it (measured on both VMs). Round 3 of PR #475's review: the same three UNCHANGED, checked; `up` labels the Valkey container it starts and refuses to act on one that is not this deployment's, and `--valkey-url` refuses a password, neither touching a job container. Rebased over issue #471 (PR #474), whose rule is that doctor hands no program anything from `.env`: doctor starts NO Valkey any more (its `--fix` offer for an unreachable loopback Valkey is removed, since after this issue that `docker run` needed the deployment's VALKEY_PASSWORD in the docker CLI's environment), and the fix line names `pi-dispatch up`; doctor's own in-process AUTH check (its own client, no spawn) uses the resolved password; `VALKEY_PASSWORD` and `PI_VALKEY_PORT` join `SERVICE_ENV_KEYS`, the password in the secret set (named, never shown); the panel's Valkey clients and kill switch read the pointer's `.env` only, through #471's one reader (`readDeploymentEnv`), and the pointer module no longer aims a second reader. | | 2026-09-28 | Issue #471 (doctor and the panel resolve every service key through one resolver). **Every entry UNCHANGED, checked**: `CONST-TOKEN-SCOPED-PER-JOB` (the in-image gh probe carries the same token by the same value-less `-e` flags; and its token still comes from this shell's gh or this shell's PAT, as before #471, never from one only the `.env` holds, since no program doctor starts is handed anything from `.env`: every child gets this shell's own environment), `CONST-ISOLATION-CONTAINER-PER-JOB` and `CONST-EGRESS-POLICY-IN-THE-ARGV` (no probe argv moves; an image value the worker's `run.image` validator refuses is kept out of every argv), `CONST-MERGE-NEVER-AUTOMATIC` (no merge string added anywhere) and `CONST-PI-VERSION-PINNED` (no dependency or pin moves). The follow-up in the same PR keeps them so: `CONST-BUDGET-BEFORE-TOKENS` UNCHANGED, checked, and served (a refused `PI_JOB_IMAGE` now stops the worker at boot instead of failing each job after its budget slot). | | 2026-09-28 | Issue #448 (containers.conf keys that reach a job, rootful `local` and the rootless `podman` venue), with gate rounds 1 to 3 of PR #473 folded in. **No article changed.** **`CONST-ISOLATION-CONTAINER-PER-JOB` UNCHANGED, checked**: where a containers.conf would add to a job what the worker's argv does not set (a variable, groups, a host namespace, a seccomp profile, a label, a runtime or conmon, a PID 1, bounds, a resolver), no job runs, so the boundary stays the argv's. **`CONST-BUDGET-BEFORE-TOKENS` UNCHANGED, checked**: the refusal and the retry both come before the image preflight and every spend. **`CONST-RETRY-INFRA-ONLY` UNCHANGED, checked**: a determinate refusal returns; a moment's read failure, and a running service older than its configuration, which heal by themselves, throw: the first for the queue's retry, the second as a hold that spends no attempt and ends, after an hour, as an infrastructure failure naming the restart (gate round 2). | | 2026-09-27 | Issue #451. **`CONST-RETRY-INFRA-ONLY` statement UNCHANGED, checked; its #437 note AMENDED** in three places. (1) "A provider that answers 401 or 403 to the key" becomes "a provider that refused the credential", since a Google 400 `API_KEY_INVALID` is now read as a refusal. (2) The unproved-shape examples: "Google's raw-JSON form" is no longer one (the table's Google cells drive both google families' own `stream()` against the format the real endpoints send, and the rule parses that body); the examples now name a shape never measured against the real endpoint (Google's `PERMISSION_DENIED`) and a code that also names a transient case (Google's bare `UNAUTHENTICATED`, which can say "Authentication backend unavailable, try again later"; Google OAuth's `invalid_grant` under ADC, a clock-skewed assertion or a propagating service account). A first draft said the ADC codes were unproved because no loopback could reach the token endpoint; review refuted that (an external_account credential names its own token URL, and a pinned test now drives the family's `stream()` through it), so the ground given is the true one. (3) The check gains its second half as practised since #451: a shape joins only once measured against the real endpoint with a real bogus credential, because the costly mistake is the false refusal (`INT-RUNNER-EXIT-CODE-PROTOCOL`). | diff --git a/specs/design.md b/specs/design.md index 390c29bb..469be797 100644 --- a/specs/design.md +++ b/specs/design.md @@ -821,7 +821,9 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). **Create-only, contractually non-destructive**: `init` (idempotent scaffolds; an existing file is never touched). **Consented host mutations**: `up` and `doctor --fix` — each concrete action (a docker pull of the deployment's *own default* image, or on a deployment without `local` a podman pull into this - account's own store (issue #433), a loopback Valkey start (docker, and only while `local` is listed), an overlay + account's own store (issue #433), a loopback Valkey start (docker, and only while `local` is listed; `up` alone since + issue #468, whose Valkey needs the deployment's password in the docker CLI's environment, which doctor hands no + program, issue #471), an overlay `auth.json` delete, an `import-pi` restage) is shown verbatim and runs only on an explicit y/N accept, defaulting to No, including on non-TTY stdin. **Shown but not prompted**, a tier `up` alone occupies (issue #357): filling a key that has NO VALUE in a `.env` that already exists. Every such @@ -1424,8 +1426,23 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). written** → optional `setup github` hand-off → **trigger-edge choice** (install the webhook receiver as a service — a consented `npm install @edgehero/pi-dispatch-receiver@` then `service install --receiver`; or run it with docker compose — the compose file ships in the - runtime package and is copied create-only into the deployment dir before a consented - `--profile receiver up -d`; or show the polling command; or skip) → optional first trigger for the + runtime package and is copied create-only into the deployment dir's `deploy/` (with the proxy's + `egress-proxy.conf` beside it; issue #468's follow-up: copied to the dir's top, its `../.env` and + `../triggers.json` reached the folder ABOVE the deployment) before a consented + `docker compose -p --env-file .env -f deploy/docker-compose.yml --profile receiver up -d` run from the + dir (`-p` because compose names a project after its first file's directory, which is now `deploy/` in every + folder); where `up`'s container exists and is provably this deployment's (PR #475's review, round 3: up's + `com.pi-dispatch.deployment` label names this folder, or, unlabelled, it publishes VALKEY_URL's port; another + deployment's is named and never stopped or removed, measured: the hand-over removed one), and no container that is + not this deployment's mounts `pi-dispatch-valkey-data` nor carries another folder's label (else the step is refused, + naming it; an unlabelled volume that this deployment's own container does not serve is adopted only on its own + question, and the started Valkey's `pi-dispatch:owner` is checked), the images are pulled first, a create-only + `deploy/docker-compose.valkey.yml` gives compose's valkey that same volume, up's container is stopped and removed, + and compose starts on the same data, published on VALKEY_URL's port (`PI_VALKEY_PORT`), so worker and receiver + share one queue (a containerised receiver cannot reach a Valkey bound to the host's 127.0.0.1 on Linux); every exit + is checked, and a compose that fails after the removal puts the override back and names `up` as the way back; `up` + in such a folder starts compose's valkey rather than one of its own; a VALKEY_URL port other than 6379 is written + into `.env` as `PI_VALKEY_PORT`, never over a different value; or show the polling command; or skip) → optional first trigger for the repo the session sits in (cron pre-filled with the folder, flow picked from its `.pi/skills/` via the shared `SKILL_NAME_RE`, the in-place-edit warning printed, the `ai-trigger: allow` line **printed, never written** — repo files stay the operator's, and the gate reads committed HEAD @@ -1476,7 +1493,22 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). - **Decision**: The worker runs **on the host** (a Node process, `pi-dispatch worker` / `npm start`), not in a container. It launches job containers by shelling out to the real `docker` CLI. - `docker-compose` runs **only Valkey**; the worker is a host process alongside it. + `docker-compose` runs **only Valkey**; the worker is a host process alongside it. That Valkey takes the + deployment's `VALKEY_PASSWORD` (issue #468) from `docker compose --env-file .env` into its container's environment, + and its command hands it to `valkey-server` on stdin, never on a command line: the one start script + `DES-PODMAN-STACK-AS-QUADLET-UNITS` describes, with `$` written `$$` for compose's interpolation. Without + `--env-file` compose finds no `.env` beside the file and starts Valkey with no password, which doctor warns about. + The `pi-dispatch-valkey` that `up` starts instead carries a `com.pi-dispatch.deployment=` label (PR #475's review, round 3), and a container is acted on only when it is provably this + deployment's (that label, compose's own working-dir label, or, unlabelled, publishing VALKEY_URL's port); before any + start on `pi-dispatch-valkey-data`, `docker ps -a --filter volume=` is asked, and a container on it that is not this + deployment's refuses the start. The volume itself is created with the same label; another folder's volume is never + used, and an unlabelled one only after a question `--yes` does not answer. Because a docker volume label cannot be + added afterwards, the queue records its owner inside it, `pi-dispatch:owner` (`SET NX`), which every start on the + volume reads back, stopping what it just started when the key names another folder. Rejected: the container's name + as proof (two deployments on one host share it); a lock file (a second account's `up` cannot see it); recreating an + unlabelled volume to label it (a copy of a live queue); and `--yes` adopting one (a script would take another + deployment's waiting jobs unasked). - **Why**: This is the reversal of `DES-JOB-FILES-VIA-VOLUME-SUBPATH` (below, superseded), forced by two findings and the local-first target. **(1) The `docker` CLI translates host paths; the daemon does not.** On Docker Desktop the daemon is a @@ -5500,6 +5532,45 @@ a tunnel. and a `UID_MIN`..`UID_MAX` range to tell "another person" from "a system service", which the first version also did: an LDAP or SSSD account sits above `UID_MAX`, Lima's default user at 501 below `UID_MIN`, and a system service's Valkey is another tenant's queue all the same, so it is the opt-in's to allow, never a silent rule's. + - **The Valkey's password** (issue #468). The owner rule above stops an accident; every account on the host shares + loopback, and a Valkey with no password let any of them read, enqueue or delete another's jobs. So the Quadlet + Valkey starts with the deployment's `VALKEY_PASSWORD` (from `.env`; `service install` and `up` generate one into + it when it has none, never for a shared or operator-owned Valkey). The planner writes it to + `~/.config/pi-dispatch/valkey.env` (0600, the one key), planned like the proxy's rules copy: shown by its path, + written before the unit, a change (or a mode wider than 0600) restarts the Valkey, `service render` names it and + never prints it, and uninstall removes it with its unit. The unit reads it with + `EnvironmentFile=%h/.config/pi-dispatch/valkey.env` (Quadlet passes `%h` through to both lines it generates, and + systemd expands it; measured on 5.8.1 and 4.9.3), and its `Exec=` is a `sh -c` that hands the value to + `valkey-server` as configuration on stdin (`valkey-server -`, read from a 0600 temp file that is opened and then + deleted before the image's entrypoint runs, the value written by the shell's `echo` builtin so it is on no argv), + unsets it, and execs the image's own entrypoint, whose chown and drop to the `valkey` user run as before. Every `$` + in the unit is `$$`: Quadlet also loads the file into the unit's own environment, and systemd would expand a bare + `$VALKEY_PASSWORD` in `ExecStart` into the value, putting it on the podman command line. The health check sends + the password through `REDISCLI_AUTH` and requires `PONG` (a bare `valkey-cli ping` exits 0 on `NOAUTH`, + measured). The same script, one constant (`valkey-auth.mjs`), is the compose file's command and `up`'s `docker run` + (`-e VALKEY_PASSWORD`, the value in the CLI's own environment), and a test holds the three equal. Rejected: + `--requirepass` on the command (measured: the image's PID 1 is `tini`, which keeps its argv for the life of the + container, and another account read the password in `/proc//cmdline` on both VMs; Valkey rewriting its own + process title does not reach PID 1); the image's `VALKEY_EXTRA_FLAGS` (it appends to that same argv); a mounted + `valkey.conf` (the image drops to uid 999 before reading it, so a 0600 file of the account is unreadable, and a + world-readable one defeats the point); a Podman `Secret=` (a second store to keep in step with `.env`, a + `podman secret create` with the value on stdin in the plan, and compose has no rootless equivalent); `CONFIG SET + requirepass` after start (a window with no password, and lost on every restart); a password inside `VALKEY_URL` + (printed by doctor, stored in the admin's pointer, quoted in refusals). The upgrade of a Valkey that ran without + one is `service install --force`: the new unit and password file restart the Valkey (a SIGTERM stop, so its AOF + is written out and the volume keeps the queue), and the plan then `try-restart`s the worker and receiver units + the manager reports active, after it, since they read the password only at start; the install warns, as it does + for the proxy, that a running job is interrupted. `up` keeps its rule of starting only what does not run, so it + does not restart a running Valkey for a password; doctor names `service install --force`. The password never + reaches a log (PR #475's review): ioredis hangs a failed command, a failed AUTH's password included, on the error + it emits, and a BullMQ Queue or Worker without an `error` listener printed that whole object with `console.error` + after a rotation (measured against Redis with `CONFIG SET requirepass` and `CLIENT KILL`). `connection.mjs` keeps + only the command's name on every error any client of the process emits, and every Queue and Worker the project + builds carries a listener that writes the message alone. Round 2 of that review found the hook on `emit` alone + missed two paths, both measured: ioredis' `silentEmit` skips `emit()` on a client with no listener, and the same + error object rejects every command waiting on the connection (`flushQueue`, `abortError`, a reply error). So the + scrub also wraps `silentEmit` and every `Command`'s `reject` (as `initPromise` makes it), the worker's shared client + gets a listener, and the entry points print an unhandled rejection as its message alone. - **All or nothing for files** (issue #464). A `service install` that failed part way (a root-owned `~/.config`) left the stack files it had written and said nothing of them. Every file is now written before any command runs, the worker unit inside the applier's `beforeRuns` hook, each write journalled with what was there before it; a @@ -6107,3 +6178,4 @@ a tunnel. | 2026-09-28 | Issue #464. **`DES-PODMAN-STACK-AS-QUADLET-UNITS` AMENDED**: the "Which parts" bullet gains whose listener counts (`decideValkey`, shared by `service install` and `up`): the queue's port from `VALKEY_URL`, a listener there taken to be the Valkey only when its socket is this account's (measured on Fedora 44 and Ubuntu 24.04: a rootless published port is a socket of the account's uid, readable by all), with `VALKEY_URL=redis://127.0.0.1:` as the way out and the Quadlet Valkey published on that port. Gate round 1 replaced the first rule (IPv4 only, root and a `UID_MIN`..`UID_MAX` range deciding "system", `install --force` taking another account's): one function, `judgeValkeyListeners`, used by `service install`, `up` and doctor, resolves `VALKEY_URL`'s host as the client does and judges every answering address on `tcp` and `tcp6` (IPv4-mapped included; a `localhost` URL reached another account's `[::1]` Valkey, measured on Fedora 44); only the account's uid and its `/etc/subuid` range are its own (a `--network host` container's socket is a subordinate uid, measured); root, system and other uids are refused by name, not forceably; `PI_VALKEY_SHARED=1` in `.env` is the named opt-in; an `[::1]` URL with nothing listening is an error, since the Quadlet Valkey publishes on `127.0.0.1` only; `parseConnection` strips an IPv6 literal's brackets (it kept them, so BullMQ could never connect to one); gate round 2: every Valkey client in the repo judged and pinned by the connector connection.mjs builds, with a bolt test on client construction, the worker's own judgement at boot with every client pinned to the judged address and its boot line naming it, unspecified addresses as loopback, own-first choice with another account's listener elsewhere reported, `PI_VALKEY_SHARED` from `.env` only, `getsubids` ranges, a bounded lookup, doctor talking to no refused Valkey and to the pinned address otherwise, and `rediss:` given TLS in `parseConnection`; gate round 3: one owner rule for every client on Linux whatever its cwd or venue (another account's listener refused everywhere, root's and an unnamed owner's only where the venue is podman without local), `PI_VALKEY_SHARED` never from the environment, the `.env` read through the hardened reader with hazards named, a failed lookup retryable, a failed judgement handed to ioredis as a destroyed socket so a Valkey restart no longer ends the client (measured on 5.11.1), the receiver judging before it listens, and the bolt test widened to string literals and `createRequire`; doctor's ✗ for a `VALKEY_URL` line it cannot read, after which it contacts no Valkey (it probed, read the fleet of and judged the default 6379 instead, measured naming another account there); an installed Quadlet Valkey kept only while the listener is this account's; a new "All or nothing for files" bullet: every file written before any command, journalled, put back on a failed write, and the remaining files named after a failed command, with only the worker unit put back (gate round 1: that partial rollback no longer says that no file of the run remains), and `service uninstall` removing files whose units were never loaded (a `stop` exiting 5), asking the manager unit by unit and refusing only while one runs; `up`'s Quadlet step journals and puts back the same way (a follow-up in the same round), and `up` names the pi-dispatch-valkey container only when it publishes the port judged. Rejected alternatives recorded in the bullet. **UNCHANGED, checked**: the keeper and proxy bullets, the unit names, and `up`'s consent flow. | | 2026-09-28 | Issue #448 (containers.conf keys that reach a job, rootful `local` and the rootless `podman` venue), with gate rounds 1 to 3 of PR #473 folded in. **`DES-PODMAN-THROUGH-ITS-DOCKER-API` AMENDED**: a new decision bullet, rejected entries and a residual. Measured with the worker's own argv on Fedora 44 (rootful Podman 5.8.1) and Ubuntu 24.04 (4.9.3), each key alone in a drop-in, the service fresh for each, both networks: the keys that reached a local job across a boundary the argv sets, or left its container uncreatable, are refused as `podman-conf-widens-job` (`PODMAN_ROOTFUL_WIDENING_KEYS`, twenty-four), at boot where `local` is the default venue, per job pre-spend otherwise, in doctor and in a local sandbox; the keys the argv or the API request overrode are documented inert (`PODMAN_ROOTFUL_INERT_KEYS`), and the two that reached the job and moved no boundary are documented harmless (`PODMAN_HARMLESS_KEYS`: `tz`, `no_hosts`). The vendor's own `default_sysctls` block, uncommented in both stock files, is accepted exactly as it ships (`STOCK_CONF_BLOCKS`), because refusing every stock host would be worse and the exception is exact. The chain is the service's, as containers/common v0.67.0 reads it and as measured: the vendor and `/etc` files, `/etc/containers/containers.conf.d` (not `/usr/share`'s drop-ins nor either `containers.rootful.conf.d`, measured unread), root's own conf, every `--module` the unit passes (measured through `LOGGING=`), and every `CONTAINERS_CONF` or `CONTAINERS_CONF_OVERRIDE` file named in the unit, an `EnvironmentFile=` (wildcards expanded) or the manager's environment (measured). `podman.service` is trusted only for the socket `podman.socket` listens on (a second rootful API service was measured applying its own override). What cannot be read refuses and withholds `mountSet`, except root's own config home, which is named (a `0600` drop-in in `/etc` was measured reaching jobs while named). The running service (the rootful twin of #450): a chain file, drop-in directory, module, environment or unit file whose CHANGE time is later than the running service's start, or a chain file seen during that start and gone since (a per-worker memory), holds jobs back until it restarts, as a RETRY (boot exit 1, ⚠ in doctor), never a final refusal; no chain file's parent directory is watched (a `sed -i registries.conf` held every job back); a change time ahead of the clock is said as the clock's. `runtimeAddsNoMounts` reads the same chain under the same rules, and `podman.service` is read once per boot or job for both. **`DES-PODMAN-NATIVE-ROOTLESS-BACKEND` AMENDED**: `PODMAN_WIDENING_KEYS` grows from six to twenty-three, the seventeen new keys measured reaching a job with the venue's own argv on rootless Podman 5.8.1 and 4.9.3 alike (`PODMAN_ROOTLESS_INERT_KEYS` documented inert under the argv's pins, `PODMAN_HARMLESS_KEYS` documented harmless, the vendor's `default_sysctls` block accepted); the chain gains `/etc/containers/containers.rootless.conf` (measured read by 5.8.1) and loses `/usr/share`'s two drop-in directories (measured unread by both); the remedy resets the rootless network only for `PODMAN_NETWORK_HELPER_KEYS`, and a per-container key's says the next job runs once it is gone; and (gate round 2) a directory named `*.conf` in a drop-in directory is skipped, as Podman skips it, where `confFilesIn` refused it with `EISDIR`. **`DES-CONTAINER-BACKEND-REGISTRY` AMENDED**: `local` hands back `podmanConfRefused` too, marked `rootful`, with `retry` for what heals by itself, which the processor throws as a hold (`PodmanRestartHold`) that `makeProcessor` moves back to the delayed set every minute without spending an attempt, for up to an hour, then fails with its own reason token and comment; and the `runtimeAddsNoMounts` sentence and residual name the chain (the residual now only the hidden `--default-mounts-file` flag). Gate round 2 in the same entries: every existing chain file is remembered before any refusal returns, in the widening check and the mounts observation (a file refused for a key, then deleted, let a job through on Ubuntu, measured); the socket compare resolves both paths; a `--module` that names no file refuses as unjudgeable; a directory named `*.conf` is skipped; doctor's ✓ claims no deletion it cannot see; and what else moves a change time (a `chmod`, a swap file, a unit update) is stated as a hold until restart. Gate round 3: the hold's hour restarts after a gap in its checks (a paused queue), an expired hold is recorded with its own token, and one job's two checks share one read of each path (`onceFs`), the remaining linear cost named with its numbers. **`DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST` UNCHANGED, checked**: the job user is decided and refused first, and the new refusal comes after it. **`DES-PODMAN-STACK-AS-QUADLET-UNITS` and `DES-EGRESS-DENY-ON-A-DEDICATED-NETWORK` UNCHANGED, checked**: neither reads a containers.conf. | | 2026-09-28 | Issue #471. **`DES-PODMAN-STACK-AS-QUADLET-UNITS` AMENDED**, the "Where the answer comes from" bullet: the precedence `up` and doctor gave the three venue keys (this shell's value where it sets one, else the file's, a disagreement refused or a ✗) is now doctor's rule for every service key it judges, in one module the admin panel shares (`worker/src/service-env.mjs`), with the choice it rests on stated: doctor cannot tell a service (which never sees its shell) from a worker started by hand (which sees nothing else) and does not guess from the units it can read, since a unit's presence says nothing about which worker a run is asked about; agreeing sources need no answer, and differing ones fail naming both. Rejected: the file winning whenever a unit is installed (a by-hand worker beside a stopped unit would then be judged by a file it never reads), and the shell winning in silence (the defect). **UNCHANGED, checked**: `DES-SERVICE-ENV-SETUP-SEAM` (`PI_ENV_SETUP` stays the unit's, read back from it, and this shell's; never the file's), `DES-ADMIN-VIA-PI-EXTENSION` (the sandbox key's posture still comes from the panel's own process, `OQ-038`) and `DES-CLI-SURFACE` (no command or flag added). **Code evidence**: worker/src/service-env.mjs -> resolveServiceEnv, serviceEnvFileOf, serviceEnvLoader, deploymentServiceEnv; worker/src/doctor.mjs -> runDoctor, collectChecks, resolveDoctorEnv, serviceEnvLines, jobImageOf, SERVICE_ENV_KEYS, WORKER_SERVICE_KEYS, RECEIVER_SERVICE_KEYS, STEERING_SERVICE_KEYS, DOCTOR_SHELL_KEYS; admin/src/read-model.mjs -> panelEnv, PANEL_SERVICE_KEYS; admin/src/index.ts -> deploymentEnv; admin/src/deployment-pointer.mjs -> pointerState. Follow-ups in the same PR, ending at PR #474's round cap. Its final rule: **no program doctor starts is handed anything from `.env`**; each child runs with this shell's own environment, as before #471. The CLI variables the service's own podman, docker and gh inherit from `.env` (`CLI_SERVICE_KEYS`) are resolved, a disagreement on one said, each the file sets one ⚠ (`cliNotHandedLines`, the value only for a path); where such a setting decides an IN-PROCESS judgement the file's value is used (the containers.conf chain check reads `CONTAINERS_CONF`, `CONTAINERS_CONF_OVERRIDE` and `XDG_CONFIG_HOME`, as the worker does, and fails on a refused chain, and #448's rootful readers `observeHost` and `observeRootfulConf` are handed the same resolved environment, `HOME` among the variables their chain reads; reading a file runs no code, and their `systemctl show` spawn inherits this shell's environment like every other); a PAT only the file holds means the in-image probe is not run. Rejected, in the order the PR tried them, each by a measured hole: handing each CLI the file's values of its own variables (a `.env` containers.conf ran its `conmon_path` under doctor's `podman info`); handing them only from a `.env` this account owns, a chain the check passes, and paths nobody else can write (a symlink another account flipped between check and use ran its docker plugin); resolving every path, judging the whole chain as spelled and resolved, and handing only this account's own `unix://` endpoints (a missing tail under root's sticky `/tmp` that another account created in time ran its plugin, and this account's own 0660 socket and the standard root sockets were held back as failures). A rule whose every refinement had a hole is replaced by one with nothing to refine. What stays from those rounds decides whether the file's values are used at all: a `.env` another account can change gives none (`envFileTrust`, `trustedChain` over its folder, its real folder and every directory above them, a root sticky directory the one exception), read through one descriptor (`readDeploymentEnv`, the panel's too), and the refusal names owner, mode and group truthfully (a user-private group said as one). The panel reads the pointer's `.env` only (a cwd scaffold a repository can commit was dropped). Residual stated: a deployment folder is trusted exactly as far as the service trusts it (its `.env`'s job image is run by doctor's canary and probes, pinned as a job's are). Code evidence adds worker/src/image-ref.mjs -> imageRefProblem (config.mjs -> jobImageFrom, triggers.mjs -> validateImageRef, image-preflight.mjs -> resolveJobImage, doctor.mjs -> jobImageOf); worker/src/service-env.mjs -> readDeploymentEnv, envFileTrust, trustedChain, accountNames; worker/src/doctor.mjs -> cliNotHandedLines, triggersPath, serviceUnitFor; worker/src/cli.mjs (run --image). | +| 2026-09-28 | Issue #468. **`DES-PODMAN-STACK-AS-QUADLET-UNITS` AMENDED**: a new bullet, the Valkey's password: `VALKEY_PASSWORD` from `.env` (generated by `service install` and `up` when missing) into a 0600 `~/.config/pi-dispatch/valkey.env` the unit reads with `EnvironmentFile=%h/...`, planned, restarted-for, named but never printed by `render`, removed by uninstall; the start script hands it to `valkey-server` on stdin and unsets it, every `$` doubled for systemd; the health check requires `PONG` with `REDISCLI_AUTH`; one script for the Quadlet unit, compose and `up`'s `docker run`, held equal by a test; the upgrade (`service install --force`) restarts the Valkey with its volume kept and then `try-restart`s the running worker and receiver. Rejected with measurements: `--requirepass` (tini, the image's PID 1, kept it readable in `/proc//cmdline` by another account on Podman 5.8.1 and 4.9.3), `VALKEY_EXTRA_FLAGS`, a mounted `valkey.conf`, a Podman secret, `CONFIG SET` after start, a password inside `VALKEY_URL`. **`DES-WORKER-ON-HOST` AMENDED**: compose's Valkey takes the password through `--env-file .env`, by the same script. **UNCHANGED, checked**: `DES-QUEUE-BULLMQ-OVER-CUSTOM` (the queue and AOF are as they were; a client authenticates, nothing else about BullMQ changes), `DES-PODMAN-THROUGH-ITS-DOCKER-API` (the compose route through the Podman socket, measured again on Fedora 44 with the password), `DES-SERVICE-ENV-SETUP-SEAM` (an `--env-setup` script may export `VALKEY_PASSWORD`, which every client reads from its environment first), and the owner rule's bullets of `DES-PODMAN-STACK-AS-QUADLET-UNITS`, which judge before any credential is sent. Follow-up in the same round: **`DES-FIRST-RUN-SETUP-WIZARD` AMENDED**, the compose answer copies the compose file into `/deploy/` with its `egress-proxy.conf` (at the dir's top its relative paths left the deployment folder; rewriting them on copy was rejected, since a rewritten copy cannot be diffed against the shipped file) and runs the docs' `--env-file .env -f deploy/docker-compose.yml` from the dir; a copy left at the top by an earlier setup is named as unused. PR #475's review: `-p ` keeps the project (and its volume) the old copy ran as, and a Valkey `up` started is handed to compose's valkey on the same volume, so the worker and the receiver share one queue; the "Valkey's password" bullet gains the error scrub (no failed command's arguments on any emitted error, a message-only listener on every Queue and Worker). Round 2: the scrub at `silentEmit` and every command's `reject` too, the rejection printer; the hand-over decided on up's container alone, images pulled before the stop, every exit checked with the way back named, compose's Valkey on `PI_VALKEY_PORT`, and `up` starting compose's Valkey in a handed-over folder. Round 3: the ownership rule in `DES-WORKER-ON-HOST` and `DES-FIRST-RUN-SETUP-WIZARD` (up's label, the volume asked before any start, nothing that fails it stopped, removed or reused; measured before on Fedora 44 through root's Podman socket: the hand-over removed another deployment's Valkey, and a second deployment's `up` ran a Valkey on the AOF another's compose Valkey appended to), `PI_VALKEY_PORT` written into `.env`, and the panel's kill switch through the CLI's resolver; then the volume gap: the volume's own label, adoption of an unlabelled one on a question `--yes` does not answer, and the `pi-dispatch:owner` marker checked at every start (measured again on Fedora 44: a second deployment's `up` on the first one's unmounted volume now refuses). Rebased over issue #471 (PR #474), whose rule is that doctor hands no program anything from `.env`: doctor starts NO Valkey any more (its `--fix` offer for an unreachable loopback Valkey is removed, since after this issue that `docker run` needed the deployment's VALKEY_PASSWORD in the docker CLI's environment), and the fix line names `pi-dispatch up`; doctor's own in-process AUTH check (its own client, no spawn) uses the resolved password; `VALKEY_PASSWORD` and `PI_VALKEY_PORT` join `SERVICE_ENV_KEYS`, the password in the secret set (named, never shown); the panel's Valkey clients and kill switch read the pointer's `.env` only, through #471's one reader (`readDeploymentEnv`), and the pointer module no longer aims a second reader. | diff --git a/specs/interfaces.md b/specs/interfaces.md index 30b3a570..f5388595 100644 --- a/specs/interfaces.md +++ b/specs/interfaces.md @@ -4909,6 +4909,13 @@ recorded repair is re-running `/dispatch setup` (or editing the pointer by hand) untrusted one is named in the notice and nothing is taken from it. Gate round 2: "untrusted" includes a `.env` whose folder, real folder or any directory above them another account can write, and the file is opened once and judged by that descriptor (`readDeploymentEnv`, the reader doctor uses). + Issue #468: the panel's Valkey clients take their `.env` facts (`VALKEY_PASSWORD`, `PI_VALKEY_SHARED`) from + that same read of the pointer's `deploymentDir` (`panelValkeyContext`, the default context of every Valkey client + in the panel), never another reader and never another folder; with no pointer, from pi's environment alone. The + panel's kill switch compares that file's `VALKEY_URL` with the operator's own export (the pointer's layered value + is the file's snapshot, which the file outranks): a pause pauses both on a disagreement, and a resume refuses. The + pointer still carries no credential, and the password is never layered into the process env, where a model's + shell tool would inherit it. - **Write protocol**: the wizard writes it behind a confirm showing the JSON verbatim; validated through the same normalizer (a rejected pointer is never written), atomic tmp + rename; hand-editable thereafter. @@ -5347,3 +5354,4 @@ onFailureTimeoutMs; worker/test/on-failure.test.mjs; worker/test/start-wiring.te | 2026-09-28 | Issue #464 and one ledger item. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**: `attempt` is stated, and changed, to the 1-based attempt number (`attemptsMade + 1`, since every record is written while the job is still processing and BullMQ increments `attemptsMade` only when an attempt finishes, bullmq 5.80.4); records written before this round carry the zero-based count, which the one reader, the panel's `attempt N` drill-in line, only shows; the worker's own `job_failed` log line, read after the increment, already said the 1-based number. No field is added, moved or removed. **`INT-SANDBOX-CONTRACT` AMENDED**, the retained layout's default: `PI_JOBS_DIR` defaults per account, `/pi-dispatch-/jobs` under a root created `0700` and refused when another account owns it, and a run retained under the old shared default is neither re-opened nor swept (doctor names this account's, with the move); gate round 1: a `PI_SANDBOX_DIR` that exists and belongs to another account is refused at boot (exit 2), and the retention step asks the owner again before it renames a run in, deleting the run instead. **`INT-LIVE-PROBE-CONTRACT` AMENDED**, one parenthesis: the fixture's jobs dir is the per-account default and is created `0700` when the probe is first to make it. **UNCHANGED, checked**: `INT-CONTAINER-RUNTIME-CONTRACT` (no mount, flag or label moves; a job dir is still a `0700` `mkdtemp`, only its parent moved) and `INT-EGRESS-POLICY-CONTRACT`. | | 2026-09-28 | Issue #448 (containers.conf keys that reach a job, rootful `local` and the rootless `podman` venue), with gate rounds 1 to 3 of PR #473 folded in. **`INT-CONTAINER-RUNTIME-CONTRACT` AMENDED**, three passages: a new bullet after the pinned namespaces (a local job on rootful Podman's Docker API gets the argv unchanged, and the keys of the service's own containers.conf measured reaching it, `PODMAN_ROOTFUL_WIDENING_KEYS`, are refused, the inert and harmless ones documented, the vendor's `default_sysctls` block accepted because refusing every stock host would be worse and the exception is exact; the chain is the service's, `/etc`'s drop-ins, root's own conf, `--module` files and every `CONTAINERS_CONF` file of the unit, its environment files and the manager; `podman.service` is trusted only for its own socket; what cannot be read refuses, except root's own config home, which is named; a running service older than a change time in its chain, or than a chain file deleted since, is a retry); the pinned-namespaces bullet's refused list is `PODMAN_WIDENING_KEYS`, twenty-three keys; and `runtimeAddsNoMounts` reads that same chain under the same rules. **`INT-SANDBOX-CONTRACT` AMENDED**: a local sandbox on rootful Podman is refused for what a local job is refused for (`podman-conf-widens-job`, `podman-conf-unread` for a moment's failure, and a service older than its chain refused with the restart, since a sandbox has no queue to retry through), and a podman sandbox for the twenty-three keys. **`INT-LIVE-PROBE-CONTRACT` AMENDED**, one sentence: no local probe runs where a local job is refused or held back on that configuration. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**, its `reason` enum: `podman-service-restart-hold-expired` (gate round 3), the record of a local job held for an hour while rootful Podman's service ran with an older containers.conf, the token its comment and the failure hook carry; a held-back job is deferred with no record, and `podman-conf-widens-job` is the existing token, no field moves. **`INT-EGRESS-POLICY-CONTRACT` UNCHANGED, checked**: the proxy, the job network and its rules are untouched. | | 2026-09-28 | Issue #471. **`INT-DEPLOYMENT-POINTER-CONTRACT` AMENDED**, the Application bullet and two Acceptance sentences: the deployment's own `.env` now sits between the operator's export and the pointer for every `resolvePaths` call (`panelEnv`, through the resolver doctor judges the service by), read only from the pointer's `deploymentDir` (gate round 1 dropped a cwd carrying init's scaffold, which a repository can commit, and requires the file to be this account's or root's and writable by nobody else), for the path, URL and number keys the panel reads and the worker name `dispatch_run` routes by, never a capability grant; a relative path in it is the deployment folder's, and a disagreement or an unreadable line is one notice naming keys. The pointer's own allowlist and write protocol are unchanged. **UNCHANGED, checked**: `INT-SANDBOX-CONTRACT` (the opener's posture, `PI_BACKENDS` and `PI_BACKEND_FLOOR` are still read from its own process, `OQ-038`), `INT-PAUSE-WINDOWS-FILE-CONTRACT` and `INT-SCOPED-LIMITS-FILE-CONTRACT` (doctor's two-subject reading of those two keys stays their own rule, outside the new resolver), `INT-LIVE-PROBE-CONTRACT` (the probes and their argv are unchanged; the image is the one the worker would take) and `INT-CONFIG-OVERLAY-CONTRACT` (the settings file the panel edits is still `settingsFilePath`'s, now of the resolved environment). Follow-up in the same PR: **`INT-TRIGGERS-FILE-CONTRACT` AMENDED**, the `run.image` bullet: one image rule (`image-ref.mjs`) for `run.image`, `PI_JOB_IMAGE` at boot and doctor, which now also refuses a control character; `PI_JOB_IMAGE` refused at boot (exit 2) where it booted before and handed the runtime a flag. The rest of that entry UNCHANGED, checked. | +| 2026-09-28 | Issue #468. **`INT-DEPLOYMENT-POINTER-CONTRACT` AMENDED**, the Application bullet: the pointer's `deploymentDir` is where the panel's Valkey clients read `VALKEY_PASSWORD` and `PI_VALKEY_SHARED` from (corrected on the rebase over issue #471: from the pointer's `.env` through #471's one reader, `panelValkeyContext`, whatever the operator exports), read from that `.env` at connect time and never copied into the env; the "never carries credentials" rule is unchanged and still enforced by the allowlist. Round 3 of PR #475's review: the panel's kill switch reads that folder's `.env` even while the operator exports their own `VALKEY_URL`, to find a disagreement, never layering anything from it. **UNCHANGED, checked**: `INT-CONTAINER-RUNTIME-CONTRACT` (`VALKEY_PASSWORD` is a worker-only key, refused in `PI_FORWARD_ENV` and as a `run.secrets` name, so no job container's env gains or can gain it), `INT-TRIGGERS-FILE-CONTRACT` (its reserved `run.secrets` names are derived from the worker-only set, which now holds `VALKEY_PASSWORD`; no field changes), `INT-HOST-REGISTRY-CONTRACT` and `INT-CANCEL-CHANNEL-CONTRACT` (the same keys in the same Valkey; only the connection authenticates). Rebased over issue #471 (PR #474), whose rule is that doctor hands no program anything from `.env`: doctor starts NO Valkey any more (its `--fix` offer for an unreachable loopback Valkey is removed, since after this issue that `docker run` needed the deployment's VALKEY_PASSWORD in the docker CLI's environment), and the fix line names `pi-dispatch up`; doctor's own in-process AUTH check (its own client, no spawn) uses the resolved password; `VALKEY_PASSWORD` and `PI_VALKEY_PORT` join `SERVICE_ENV_KEYS`, the password in the secret set (named, never shown); the panel's Valkey clients and kill switch read the pointer's `.env` only, through #471's one reader (`readDeploymentEnv`), and the pointer module no longer aims a second reader. | diff --git a/specs/requirements.md b/specs/requirements.md index 2eb0f5ec..e21448d9 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -365,7 +365,8 @@ and nothing about the box itself (`INT-CONTAINER-RUNTIME-CONTRACT`). ## REQ-OPERATOR-JOB-CANCEL - **Statement**: An operator shall be able to stop ONE job from a surface that needs no model: the CLI - (`pi-dispatch cancel `, VALKEY_URL-only like `pause`) and the panel (`x` on the ACTIVE row; the + (`pi-dispatch cancel `, VALKEY_URL-only like `pause`; on a shell/`.env` disagreement about VALKEY_URL it + refuses until `--valkey-url` names which, before or after the id, issue #468) and the panel (`x` on the ACTIVE row; the held drill-in behind `h`). An active job is aborted on whichever host owns it and its run record says `outcome: "policy", reason: "operator-cancel"`; a queued, delayed or held job is removed and records NOTHING, because it never ran. A cancel that no reachable worker acknowledges is a named refusal, @@ -2268,8 +2269,65 @@ and nothing about the box itself (`INT-CONTAINER-RUNTIME-CONTRACT`). with an exit status, that directory does not exist, and Podman's own database records a run root under it (the evidence that Podman ran there, which an account that never had a session lacks). Its fix is linger (measured to bring Podman back on 5.8.1 and 4.9.3); `podman system migrate` is named as NOT a fix, since it fails the same way (measured). + (5) The Valkey a deployment starts has a password of its own (issue #468): every account on a host shares + loopback, and rule (2) stops only an accident, not an account that connects on purpose to read, enqueue or delete + another's jobs. It is `VALKEY_PASSWORD` in `.env`, a key of its own and never part of `VALKEY_URL`, which doctor + prints, the admin's pointer stores and refusals quote. `init` writes a new one (32 random bytes, hex) into the `.env` + it creates, and creates that file with mode 0600; `service install` (where it installs the Quadlet Valkey) and `up` + (the Valkey it starts, on docker or as a Quadlet unit) add one to a `.env` that has none, never over a value, and + narrow that file to its owner. None is generated for a shared Valkey (`PI_VALKEY_SHARED=1`: it takes its owner's + password) or for a `VALKEY_URL` that names another host or carries its own credentials, which is left as it is. A + value outside base64url characters, or shorter than 16 or longer than 512, is refused by both, and by doctor. The + value is never printed or logged: a line says only whether one is set. The Valkey receives it in its container's + environment and reads it as configuration on stdin; never on a command line, where the image's PID 1 keeps it + readable by every account for the life of the container (measured on Podman 5.8.1 and 4.9.3); with none set it + starts without one, as before, so an older `.env` keeps working. Every client sends it through the one connection + function of rule (2): the URL's own password first, then `VALKEY_PASSWORD` from the environment, then from the + deployment `.env` (the one the admin's pointer names, for the panel) for a loopback host only. A Valkey that refuses + the credential (`NOAUTH`, `WRONGPASS`) is a configuration refusal naming the key and where it came from: the worker + exits 2 at boot, `run`, `pause`, `resume`, `status`, `cancel`, the receiver and its poller refuse at start, doctor + prints a ✗. A loopback Valkey that answers a client sending none is a doctor warning naming the upgrade: + `service install --force` on the podman venue, which restarts the Valkey with the new password (its volume, and + the queue, kept) and then the worker and receiver units that were running; `up` on docker, which offers to stop, + remove and run `pi-dispatch-valkey` again with its volume kept; compose's own recreate with `--env-file .env`. + `up`'s docker Valkey is probed and published on `VALKEY_URL`'s port, read as the service reads it (it was always + 6379); a `VALKEY_URL` naming another host adds none, and an IPv6 literal, which a Valkey published on `127.0.0.1` + cannot serve, is refused with the fix. + `VALKEY_PASSWORD` never reaches a job container: `PI_FORWARD_ENV` refuses it and a trigger's `run.secrets` may not + bind the name. Nor a log: no error a client emits, and no command it rejects, keeps the failed command's arguments + (a failed AUTH carried the password there, through a listener, a listener-less client and every waiting command + alike), every Queue, Worker and the worker's shared client writes an error as its message alone, and the worker, CLI + and receiver entry points print an unhandled rejection as its message alone and exit 1. Nor a terminal: every URL + the CLI prints goes through `urlShown`. The CLI verbs and `service restart --drain` read `VALKEY_URL` as they read the + password: this shell's, else the deployment `.env`'s, or the one `--valkey-url` names. When the shell's and the + `.env`'s disagree, `pause` pauses BOTH and says so (a stale export must not make "paused" true of the wrong Valkey), + `status` shows both, `resume` and `cancel` refuse until `--valkey-url` names which, and `run` and the drain use the + shell's and name the disagreement. The panel's `/dispatch pause|resume` (and its pause and resume tools) take the + same rule from the same resolver, the pointed-at deployment's `.env` against this process's `VALKEY_URL`, and print + every URL through `urlShown`. `--valkey-url` is a flag of the verb in any position, and one carrying a password (or + a user) is refused, naming `VALKEY_PASSWORD`, since a command line is readable by every account in `/proc`; one that + names neither the shell's nor the `.env`'s URL is used and said. The compose file publishes its Valkey on + `PI_VALKEY_PORT` (6379 unset); where VALKEY_URL's port is not 6379, `up` and the setup wizard write it into `.env` + (never over a different value, which they name, as doctor does) and pass it to every compose run, so no compose path + moves the queue off the worker's port; in a folder the wizard handed to compose, `up` starts compose's Valkey with + the folder's project and the override, never one of its own beside it. A Valkey container or volume is this + deployment's only when it is provably so: `up` labels the container it starts with the deployment + folder, a compose Valkey is the folder's by compose's own working-dir label, and an unlabelled `pi-dispatch-valkey` + from before the label only when it publishes this deployment's VALKEY_URL port. `up`, its password restart and the + wizard's hand-over never stop, remove, reuse or start beside a container that fails this, and nothing + starts a Valkey on `pi-dispatch-valkey-data` while a container that is not this deployment's mounts it (two Valkeys + on one AOF, measured); each refusal names the container and the way out, and `up` exits non-zero on it. The volume + has an owner of its own: `up` creates it labelled with the deployment folder, a volume labelled for + another folder is never used, and an unlabelled one (made before the label) is used only after a question that + `--yes` does not answer, naming that its queue cannot be attributed to a folder (doctor starts no Valkey at all: its fix line names `pi-dispatch up`; the + wizard asks, and the upgrade restart of this deployment's own container needs none). Since a volume label cannot be + added later, the queue records its owner inside it (`pi-dispatch:owner`, set once), and every start on the volume + checks it: another folder's stops what that start began, at once, and fails the step. `up`'s offer + to restart a Valkey with its password says that a running job is interrupted and that the worker gets NOAUTH until + it restarts. - **Every environment variable a loader reads is discoverable, or is marked as not being a key.** The - scaffold is the operator's whole view of what this system can be told: `init` copies `.env.example` + scaffold is the operator's whole view of what this system can be told: `init` copies `.env.example` (filling in only + `VALKEY_PASSWORD`, issue #468) verbatim, so a variable absent from it is a variable an operator has no way to learn exists, and the refusal that eventually names it is met as an error rather than as documentation. That is the same believed-on-while-off failure the clause above makes normative for `doctor`, one step earlier. So: @@ -2456,6 +2514,7 @@ instead of drifting. | Date | Change | |---|---| +| 2026-09-28 | Issue #468. **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED**: clause (5) of the shared-host rule, a per-deployment Valkey password. `VALKEY_PASSWORD` in `.env` (a key of its own, not inside `VALKEY_URL`); `init` generates it into the `.env` it creates at mode 0600, `service install` and `up` add a missing one to the Valkey they start (never over a value, the file narrowed to its owner), none for a shared or operator-owned Valkey; the Valkey gets it through its environment and stdin, never a command line (measured: the image's PID 1, tini, kept `--requirepass ` readable by another account in `/proc//cmdline` on Podman 5.8.1 and 4.9.3); every client sends it through the one connection function (URL userinfo, then environment, then the `.env` for a loopback host); a refused credential is a configuration refusal at every start and a ✗ in doctor; a Valkey without one is a doctor warning naming the upgrade step, which keeps the queue; the key is refused in `PI_FORWARD_ENV` and `run.secrets`; `up`'s docker Valkey is probed and published on `VALKEY_URL`'s port, not always 6379 (a follow-up in the same round); PR #475's review added the error scrub, the CLI's `VALKEY_URL` from the `.env`, and the restart offer's cost; its round 2 the scrub at every path (silentEmit, each command's reject), the rejection printer, `urlShown` for CLI URLs, the kill switch acting on both Valkeys of a disagreement (`--valkey-url` to name one), `PI_VALKEY_PORT`, and `up` starting compose's Valkey in a handed-over folder; its round 3 the ownership rule (a Valkey container or volume is this deployment's only by `up`'s label, compose's working dir, or a legacy container's port; nothing stops, removes, reuses or starts beside one that fails it, the volume asked before any start), `PI_VALKEY_PORT` written into `.env` and flagged by doctor on a disagreement, `--valkey-url` parsed as a flag and refused with a password, and the panel's pause and resume on the CLI's rule; and the volume gap it named (a volume with no container on it was used by a second deployment, measured): the volume labelled by its creator, another folder's never used, an unlabelled one adopted only on a question `--yes` does not answer, and the `pi-dispatch:owner` marker recorded and checked at every start. **`REQ-OPERATOR-JOB-CANCEL` AMENDED**: `cancel` refuses on a VALKEY_URL disagreement until `--valkey-url` names which, in either position (round 3). **`REQ-ADMIN-VIA-PI-EXTENSION` UNCHANGED, checked** (round 3): the pause and resume tools stay the only mutating model tools; which Valkey they act on is clause (5)'s rule. The discoverability bullet notes that `init` fills in that one key. `REQ-QUEUE-BURST-NO-DROP` UNCHANGED, checked: AOF stays on in all three carriers of the Valkey start, and the upgrade's restart keeps the volume (measured on both VMs). `REQ-TRIGGER-SECRETS` UNCHANGED, checked: `run.secrets` refuses the name through the reserved set it already derives from `WORKER_ONLY_SECRET_VARS`. Rebased over issue #471 (PR #474), whose rule is that doctor hands no program anything from `.env`: doctor starts NO Valkey any more (its `--fix` offer for an unreachable loopback Valkey is removed, since after this issue that `docker run` needed the deployment's VALKEY_PASSWORD in the docker CLI's environment), and the fix line names `pi-dispatch up`; doctor's own in-process AUTH check (its own client, no spawn) uses the resolved password; `VALKEY_PASSWORD` and `PI_VALKEY_PORT` join `SERVICE_ENV_KEYS`, the password in the secret set (named, never shown); the panel's Valkey clients and kill switch read the pointer's `.env` only, through #471's one reader (`readDeploymentEnv`), and the pointer module no longer aims a second reader. | | 2026-09-28 | Issue #471. **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED**, the doctor `.env` bullet and one Acceptance sentence. doctor judged about thirty keys from its own shell while the service read them from `.env`, so a ✓ could describe a setting the worker never used (and a `.env` value the worker refuses to boot on got no line). Every key the worker or the receiver reads that doctor judges now joins the allowlist and is resolved ONCE by one module shared with the admin panel, by the rule doctor already applied to the venue keys and `PI_WORKER_NAME`: this shell's value, else the file's plain line; a disagreement is a ✗ naming both values (a credential by name only, a URL without its credentials) and doctor judges this shell's; a line the loaders read differently is a ✗ naming the line, its value unused. doctor cannot tell a service from a worker started by hand from its shell and does not guess: agreeing sources need no answer, differing ones fail. A value that steers a spawn, a connection or a write is judged as the worker judges it first (the job image by the worker's empty-means-default rule and its `run.image` refusals, which also fixes doctor inspecting an image named "" where the worker takes the default; the Valkey by the owner rule; the in-image probe's PAT by the worker's own lookup; a `.env`-only session store offered at the prompt tier), a child process gets this shell's environment and never the file's values, and a forge URL is printed without its credentials. A bolt test fails when doctor reads a service key from its environment outside the resolver, generalising #464's `PI_WORKER_NAME` bolt. `PI_ENV_SETUP`, `XDG_DATA_HOME` and `DOCKER_CONTENT_TRUST` stay this shell's, as the issue asked. Follow-ups in the same PR: the worker now refuses to boot (exit 2) on a `PI_JOB_IMAGE` the shared image rule refuses (a leading dash booted before); a relative `PI_TRIGGERS_FILE` resolves against the deployment folder, as the worker's own read does; the session store's path and bounds reach the terminal escaped; the overlay comparison's host pi setup is confirmed as this shell's (the worker never reads it for the overlay; `import-pi` does, from this shell); and the podman, docker and gh variables a `.env` sets reach doctor's own spawns of that CLI and its conf-chain check, since the worker's inherit them, a disagreement on one reported like any key. PR #474 gate rounds 1 to 3, ending at the round cap: a `.env` another account can change (the file, its folder or any directory above them, read through one descriptor) gives doctor and the panel no value at all (a containers.conf it named made doctor's `podman info` run a program, measured), and the refusal names the owner, mode and group truthfully; no program doctor starts is handed anything from `.env` (three rounds of rules for handing CLI variables on safely each left a hole: a flipped link, a missing tail under `/tmp` created in time, a socket wrongly held back), each CLI variable the file sets is one ⚠, and the containers.conf chain check still judges the file's chain in-process; the service keys only this shell sets are named where a worker unit for the folder is installed; the panel reads the pointer's `.env` only (a cwd scaffold, which a repository can commit, was dropped); `run --image` and a queued job's image follow the one image rule; a refused `PI_JOB_IMAGE`'s substituted default no longer says it came from `.env`; and the residual (a deployment folder is trusted exactly as far as the service trusts it) is stated. The rest of the requirement UNCHANGED, checked: the `ENV_FILE_READABLE_KEYS` two-subject rule for `PI_PAUSE_WINDOWS_FILE` and `PI_SCOPED_LIMITS_FILE`, the consent contract and the `--fix` tiers other than that one store. | | 2026-09-28 | Issue #448 (containers.conf keys that reach a job, rootful `local` and the rootless `podman` venue), with gate rounds 1 to 3 of PR #473 folded in. **No requirement changed.** **`REQ-DEPLOYMENT-BOOTSTRAP` UNCHANGED, checked**: `up`, the wizard and `service install` read no containers.conf; doctor carries the new lines; a stock host's chain on either venue (the vendor's `default_sysctls` block and the empty `[engine.runtimes]` header included) passes, so a stock deployment boots as before. **`REQ-RESURRECTABLE-SANDBOX` UNCHANGED, checked**: a sandbox is refused for what a job is refused for (`INT-SANDBOX-CONTRACT`), which its Acceptance leaves to the contract. **`REQ-EGRESS-ALLOWLIST` UNCHANGED, checked**: the refused keys are refused whatever `PI_EGRESS` says, and the inert network keys change nothing either network reaches. | | 2026-09-28 | Issue #464. **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED**: a new clause, one account's deployment never takes another account's state on a shared host, with four rules: the per-account default jobs dir (`/pi-dispatch-/jobs`, the sandbox and graph defaults under the same root, created `0700`, another account's root or jobs dir refused at boot as exit 2 and before each job, the root judged by `lstat` before anything is created so a symlink squat is that refusal and not a raw `EACCES` (gate round 1), an explicit sandbox dir another account owns refused the same way and at each retention (gate round 1), and doctor's ✓/✗ line for it, judged with the `.env`'s `PI_JOBS_DIR`, `PI_SANDBOX_DIR` and `TMPDIR` and with no retained ✓ after a ✗, plus a ⚠ for this account's retained workspaces left under the old shared default, whose move names the root in its `mkdir -m 700`); `service install`, `up` and doctor on the podman venue taking a Valkey that answers `VALKEY_URL` only when it is this account's (gate round 1: every address the URL's host resolves to, IPv4 and IPv6, judged by the owner of its socket in `/proc/net/tcp` and `tcp6`; gate round 2: every Valkey client in the repo (the worker's, the CLI's, the receiver's, the admin's, doctor's) built through connection.mjs' judging connector with a bolt test refusing any other construction, judged by the worker itself at boot, exit 2 on a refusal, and every worker client pinned to the judged literal address with the TLS name kept, unspecified addresses as this host's loopback, this account's first answering address chosen and another's elsewhere named, subuid ranges from `getsubids` where installed, `PI_VALKEY_SHARED` from `.env` only, the lookup bounded, and doctor talking to no refused Valkey; this account's uid or a subordinate uid from `/etc/subuid` only, root, system and other accounts refused by name with no uid range deciding it; `PI_VALKEY_SHARED=1` in `.env` the one named opt-in; `install --force` no longer takes it; `up` exits non-zero on the refusal and reads `VALKEY_URL` from `.env`, stopping on a shell/.env disagreement; doctor ✗ by the same function; gate round 3: one rule for every client whatever its cwd or venue, another account's listener refused on the local venue too, root's (and an owner no row names) refused only where the deployment's venue is podman without local, `PI_VALKEY_SHARED` never from the environment even with no `.env`, the `.env` read as bytes through the hardened reader with any hazard named, an unresolved or unanswered lookup retryable rather than another host, a failed judgement retried by the client rather than ending it (a Valkey restart stopped the worker), the receiver judging before it listens and exiting 2 on a refusal, and the bolt test matching any string literal naming ioredis or bullmq and `createRequire`), with `VALKEY_URL`'s port as the way out and the Quadlet Valkey published there; `service install` writing every file before any command and putting back what it wrote when a write fails, naming what remains otherwise; and doctor naming rootless Podman whose `/run/user/` is gone after linger was switched off, only when Podman's database records a run root there (gate round 1: an account that never had a session was misnamed), with `podman system migrate` named as not a fix (measured). Acceptance gains one sentence per rule. `doctor`'s `SERVICE_ENV_KEYS` gains `PI_JOBS_DIR`, `PI_SANDBOX_DIR`, `TMPDIR`, `PI_VALKEY_SHARED` and `PI_WORKER_NAME` (resolved once, this shell's else the `.env`'s, and every reader uses that value: the fleet line and its routing warning, the per-host backend wording, and which cron triggers count as scheduled here), read under the existing clause's rules; a `VALKEY_URL` line doctor cannot read makes it contact no Valkey (it pinged the default 6379, another account's on a shared host). `.env.example` gains `PI_VALKEY_SHARED`, commented out. Follow-ups in the same round: the per-account root also holds the run history and the settings overlay on an account with no home (secured at boot and by the overlay write, doctor ✗ when another account owns it), and `up`'s Quadlet writes gained the same journal and put-back as `service install`. **`REQ-DURABLE-RUN-HISTORY` AMENDED**, one Acceptance sentence for that no-home fallback. Nothing else in either requirement changed, checked. | diff --git a/worker/.env.example b/worker/.env.example index 13a3dff1..30f8f016 100644 --- a/worker/.env.example +++ b/worker/.env.example @@ -59,6 +59,18 @@ PI_CONCURRENCY=3 # --- Infrastructure --- VALKEY_URL=redis://127.0.0.1:6379 +# The Valkey's password, per deployment: `pi-dispatch init` writes a new one here (and creates this file readable by you +# alone), and `up` / `service install` add one to a deployment that has none and restart its Valkey with it (the queue is +# kept). Every client sends it: the worker, the receiver, the CLI, the panel and doctor. Without it any local account can +# read, enqueue or delete this deployment's jobs. Hex or base64url only (A-Z a-z 0-9 - _), at least 16 characters. +# A key of its own, never inside VALKEY_URL, which doctor prints and the panel's pointer file stores; a VALKEY_URL with +# its own password (a managed Valkey) is left as it is, and that password wins. With PI_VALKEY_SHARED=1 below, set it to +# the password of the account whose Valkey it is. docker compose reads it with --env-file .env (docs/podman.md, README). +# VALKEY_PASSWORD= +# docker compose only: the port deploy/docker-compose.yml publishes its Valkey on, 127.0.0.1:. Default 6379. Keep it +# equal to VALKEY_URL's port: `pi-dispatch up` and /dispatch setup write it here when that is not 6379 (never over a value +# already set), and doctor flags one that disagrees. Not read by the worker, the receiver or the CLI, which dial VALKEY_URL. +# PI_VALKEY_PORT= # podman venue: the worker (at every start), `service install`, `up` and doctor take a Valkey that answers VALKEY_URL only when THIS account # (or one of its containers) holds it. One held by another account, by root (docker-proxy, a rootful container) or by a # system service is refused, naming its owner: give this account its own port instead (VALKEY_URL=redis://127.0.0.1:6380). @@ -269,7 +281,7 @@ PI_SCHEDULER_STALL_MAX=2 # --- Egress policy: what a job container may reach on the network (docs/egress.md) --- # ON by default. Every job runs on its own --internal Docker network whose only other member is an allowlist # proxy, with no route off this host, and a job whose policy cannot serve it is refused BEFORE it spends a slot. -# START THE PROXY: docker compose -f deploy/docker-compose.yml --profile egress up -d +# START THE PROXY: docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d # (on the rootless podman backend, `pi-dispatch up` or `pi-dispatch service install` starts it as a Quadlet unit instead) # Until you do, every job is refused pre-spend (loud, free, and naming that command). The hosts live in # egress-allowlist.conf next to this file (`pi-dispatch init` writes it, and never overwrites it). diff --git a/worker/deploy/docker-compose.yml b/worker/deploy/docker-compose.yml index 85d93824..1b6374c3 100644 --- a/worker/deploy/docker-compose.yml +++ b/worker/deploy/docker-compose.yml @@ -8,9 +8,13 @@ # stays first-class. The `receiver` profile keeps the container OPT-IN: a plain `up` stays Valkey-only, # so worker-on-host deployments that already run the receiver on the host gain no second copy. # -# docker compose -f deploy/docker-compose.yml up -d # start Valkey (default) -# docker compose -f deploy/docker-compose.yml --profile receiver up -d # Valkey + receiver container -# pi-dispatch worker # drain the queue (host) +# docker compose --env-file .env -f deploy/docker-compose.yml up -d # start Valkey (default) +# docker compose --env-file .env -f deploy/docker-compose.yml --profile receiver up -d # Valkey + receiver container +# pi-dispatch worker # drain the queue (host) +# +# `--env-file .env` (run from the deployment folder) is how compose reads VALKEY_PASSWORD for the Valkey below (issue +# #468). Without it compose looks for a .env beside THIS file, finds none, and starts Valkey with no password, which +# any local account can then read and feed; doctor warns about exactly that. # # On rootful Podman, run the same commands with the real docker CLI pointed at Podman's socket through a docker # context (docs/podman.md). Measured with the egress profile on Podman 5.8.2; podman-docker is not the supported route. @@ -27,16 +31,29 @@ services: valkey: image: valkey/valkey:8 - # AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). - command: valkey-server --appendonly yes - # Bound to localhost only -- the queue is not a public surface. + # The password (issue #468), from the deployment's .env through `--env-file .env`, into the container's ENVIRONMENT + # and never onto a command line: the image's PID 1 is tini, which keeps its whole argv for the life of the container, + # readable by every account on the host in /proc (measured). Empty or unset starts Valkey without one, as before. + environment: + VALKEY_PASSWORD: ${VALKEY_PASSWORD:-} + # AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). The password reaches valkey-server as + # configuration on stdin (`valkey-server -`), written by the shell's own echo builtin to a 0600 temp file that is + # opened and deleted before the image's entrypoint runs. `$$` is compose's escape for a literal `$`: the shell in the + # container expands these, never compose. The same script as the Quadlet unit and `pi-dispatch up`'s docker run. + command: ["sh", "-c", 'set -eu; if [ -n "$${VALKEY_PASSWORD:-}" ]; then umask 077; f=$$(mktemp); echo "requirepass $$VALKEY_PASSWORD" >"$$f"; exec 0<"$$f"; rm -f "$$f"; set -- -; else set --; fi; unset VALKEY_PASSWORD; exec docker-entrypoint.sh valkey-server "$$@" --appendonly yes'] + # Bound to localhost only -- the queue is not a public surface. On VALKEY_URL's port: PI_VALKEY_PORT, which + # `pi-dispatch up` and the setup wizard set from VALKEY_URL when they run this file, and which a deployment on a port + # other than 6379 puts in its .env for a compose command run by hand (PR #475's review: the port was 6379 whatever + # VALKEY_URL said, so the queue could move away from the worker). ports: - - "127.0.0.1:6379:6379" + - "127.0.0.1:${PI_VALKEY_PORT:-6379}:6379" volumes: - valkey-data:/data restart: unless-stopped healthcheck: - test: ["CMD", "valkey-cli", "ping"] + # Healthy only when Valkey answers PONG to this deployment's password (valkey-cli reads REDISCLI_AUTH from its + # environment); a bare `valkey-cli ping` exits 0 on NOAUTH too. + test: ["CMD-SHELL", 'if [ -n "$${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG'] interval: 10s timeout: 3s retries: 5 @@ -51,7 +68,7 @@ services: profiles: ["receiver"] image: ghcr.io/edgehero/pi-dispatch-receiver:latest # The operator's .env at the repo root, same file the host services read (WEBHOOK_SECRET, github - # auth, forge blocks). Compose refuses to start the profile when it is missing -- correctly: a + # auth, forge blocks, and VALKEY_PASSWORD, which the receiver sends to the Valkey above). Compose refuses to start the profile when it is missing -- correctly: a # receiver without WEBHOOK_SECRET refuses to boot anyway, so fail at the clearer message. env_file: ../.env # `environment` OVERRIDES env_file, deliberately: a host .env says redis://127.0.0.1:6379, which @@ -83,7 +100,7 @@ services: # above: a plain `up` stays Valkey-only, and a deployment that has not turned PI_EGRESS on never needs # this service at all. # - # docker compose -f deploy/docker-compose.yml --profile egress up -d + # docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d # # It sits on ONE network here, the upstream one. The networks that matter are created by the WORKER, one # per job, `--internal`, and this container is attached to each for the life of that job and detached diff --git a/worker/deploy/pi-dispatch-valkey.container b/worker/deploy/pi-dispatch-valkey.container index d41c4e49..2cf9c246 100644 --- a/worker/deploy/pi-dispatch-valkey.container +++ b/worker/deploy/pi-dispatch-valkey.container @@ -3,7 +3,8 @@ # under this account's own rootless Podman instead of a docker daemon. # # Installed by `pi-dispatch service install` (user scope, with podman in PI_BACKENDS) and by `pi-dispatch up`, which -# copy it unchanged into ~/.config/containers/systemd/. Podman's systemd generator turns it into +# copy it into ~/.config/containers/systemd/ unchanged but for a VALKEY_URL port other than 6379, with the password +# file it reads beside it. Podman's systemd generator turns it into # pi-dispatch-valkey.service. Never `systemctl --user enable` it: a generated unit refuses that ("transient or # generated"); the [Install] section below is read by the generator itself, which links it into default.target # (measured on Podman 5.8.1: with linger on it came up 25 s after a reboot with nobody logged in). @@ -24,9 +25,19 @@ Network=pi-dispatch-valkey.network PublishPort=127.0.0.1:6379:6379 # The same named volume `pi-dispatch up` creates on docker, so the wait-list survives a restart and a reboot. Volume=pi-dispatch-valkey-data:/data -# AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). -Exec=valkey-server --appendonly yes -HealthCmd=valkey-cli ping +# The password (issue #468): VALKEY_PASSWORD from a 0600 file `service install` and `up` write from the deployment's +# .env, into the container's ENVIRONMENT. Never `--requirepass` on a command line: the image's PID 1 is tini, which keeps +# its whole argv for the life of the container, and any account on the host reads it in /proc//cmdline (measured +# on Podman 5.8.1 and 4.9.3). Quadlet also loads this file into the unit's own environment, where systemd would expand a +# bare $VALKEY_PASSWORD in the command below into the password itself: every $ below is written $$ for that reason. +EnvironmentFile=%h/.config/pi-dispatch/valkey.env +# AOF on: the wait-list must survive a reboot (REQ-QUEUE-BURST-NO-DROP). The password reaches valkey-server as +# configuration on stdin (`valkey-server -`), written by the shell's own echo builtin to a 0600 temp file that is opened +# and deleted before the image's entrypoint runs; with VALKEY_PASSWORD empty it starts with none, as before. +Exec=sh -c 'set -eu; if [ -n "$${VALKEY_PASSWORD:-}" ]; then umask 077; f=$$(mktemp); echo "requirepass $$VALKEY_PASSWORD" >"$$f"; exec 0<"$$f"; rm -f "$$f"; set -- -; else set --; fi; unset VALKEY_PASSWORD; exec docker-entrypoint.sh valkey-server "$$@" --appendonly yes' +# Healthy only when Valkey answers PONG to this deployment's password (valkey-cli reads REDISCLI_AUTH from its +# environment); a bare `valkey-cli ping` exits 0 on NOAUTH too. +HealthCmd=if [ -n "$${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG HealthInterval=10s HealthTimeout=3s HealthRetries=5 diff --git a/worker/deploy/worker.service b/worker/deploy/worker.service index 1a7a866e..e6c624cb 100644 --- a/worker/deploy/worker.service +++ b/worker/deploy/worker.service @@ -34,7 +34,7 @@ Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker) After=network-online.target docker.service Wants=network-online.target -# Valkey must be reachable (docker compose -f deploy/docker-compose.yml up -d), but it is a separate +# Valkey must be reachable (docker compose --env-file .env -f deploy/docker-compose.yml up -d), but it is a separate # unit/container -- not ordered here since it may be remote. # Crash-loop bound: at most StartLimitBurst restarts within StartLimitIntervalSec, then systemd stops # trying. A restart loop against a paid provider is a bill, not just log noise. Pairs with diff --git a/worker/package.json b/worker/package.json index 00e84eaa..3229138f 100644 --- a/worker/package.json +++ b/worker/package.json @@ -54,6 +54,7 @@ "./queue": "./src/queue.mjs", "./capabilities": "./src/capabilities.mjs", "./connection": "./src/connection.mjs", + "./valkey-auth": "./src/valkey-auth.mjs", "./job-id": "./src/job-id.mjs", "./forges": "./src/forges.mjs", "./backend-local": "./src/backend-local.mjs", diff --git a/worker/src/cancel-cli.mjs b/worker/src/cancel-cli.mjs index d97dbc5c..295f9ffb 100644 --- a/worker/src/cancel-cli.mjs +++ b/worker/src/cancel-cli.mjs @@ -54,9 +54,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout. (redisFn ? async () => null : async (u) => { - const { judgeValkeyAtStart, valkeyClientContext } = await import("./connection.mjs"); + const { judgeValkeyAtStart, defaultValkeyContext } = await import("./connection.mjs"); try { - await judgeValkeyAtStart(u, valkeyClientContext(), { waitMs: 0 }); + await judgeValkeyAtStart(u, defaultValkeyContext(), { waitMs: 0 }); return null; } catch (error) { return error?.valkeyRefused ? error.message : null; @@ -151,7 +151,7 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout. ]); clearTimeout(bound); } - return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${url}: is it running? (docker compose up)\n ${error.message}`); + return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: is it running? (docker compose up)\n ${error.message}`); } finally { probe.disconnect?.(); for (const q of queues) await q.close().catch(() => {}); diff --git a/worker/src/cli.mjs b/worker/src/cli.mjs index 81da2c28..f79ef6fe 100755 --- a/worker/src/cli.mjs +++ b/worker/src/cli.mjs @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { parseArgs } from "node:util"; import { loadConfig } from "./config.mjs"; -import { EXIT_POLICY } from "./exit-code.mjs"; +import { EXIT_POLICY, installRejectionPrinter } from "./exit-code.mjs"; import { gitDirty } from "./git-dirty.mjs"; import { imageRefProblem } from "./image-ref.mjs"; @@ -139,7 +139,10 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr } const config = loadConfig(env); - const { parseConnection } = await import("./connection.mjs"); + const { cliValkeyUrl, parseConnection } = await import("./connection.mjs"); + // PR #475's review: VALKEY_URL as the password is read, this shell's else the deployment .env's (a disagreement + // named), not the shell's alone: from the folder of a Valkey on another port, `run` dialled 6379. + const valkeyUrl = cliValkeyUrl(env); const { makeQueue, enqueueLocalJob, hostQueueName } = await import("./queue.mjs"); // failFast: a one-shot enqueue must not hang forever if Valkey is down -- error clearly. // Onto THIS host's queue when the deployment declares a name (issue #57). The folder was checked @@ -148,9 +151,9 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr const hq = config.workerNameDeclared ? hostQueueName(config.workerName) : null; // Issue #464 (gate round 3): judged before anything is sent, so a refused Valkey is said as the refusal it is, from // any directory. Only a refusal stops here; nothing answering is the "could not reach" below. - const refused = await valkeyRefusal(config.valkeyUrl, env); + const refused = await valkeyRefusal(valkeyUrl, env); if (refused) return fail(refused); - const queue = makeQueue(parseConnection(config.valkeyUrl, { failFast: true }), { ...(hq ? { name: hq } : {}) }); + const queue = makeQueue(parseConnection(valkeyUrl, { failFast: true }), { ...(hq ? { name: hq } : {}) }); try { // Absent flags stay absent (undefined) so the value resolves at job start against the // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). @@ -167,7 +170,7 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr }); write(`queued ${jobId} — folder ${folder}\nrun \`pi-dispatch worker\` to process it.\n`); } catch (error) { - return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${config.valkeyUrl}: is it running? (docker compose up)\n ${error.message}`); + return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(valkeyUrl)}: is it running? (docker compose up)\n ${error.message}`); } finally { await queue.close().catch(() => {}); } @@ -176,101 +179,149 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr if (cmd === "pause" || cmd === "resume" || cmd === "status") { // The kill switch reads ONLY VALKEY_URL, not the full loadConfig -- it must work even when - // GitHub auth is misconfigured, so an operator can always stop the queue. - const url = env.VALKEY_URL ?? "redis://127.0.0.1:6379"; - const { parseConnection, makeRedisClient } = await import("./connection.mjs"); - const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs"); - const { readLiveHosts } = await import("./host-registry.mjs"); - // EVERY queue this deployment drains (issue #57), not just the shared one. This is the kill switch: - // pausing `pi-jobs` alone would stop forge deliveries while a named host's cron, chained children - // and manual runs kept spending -- and would print "paused" for having done it. That is the silent - // no-op the comment here already warned about for a mistyped name, arriving through a new door. - // - // Both reads fail OPEN -- between them an unreadable registry and an unreadable keyspace yield the - // shared queue alone, which is exactly what this command did before, so a Valkey blip can never make - // the kill switch refuse. But it fails open LOUDLY: a degraded read is NAMED in the output rather - // than left indistinguishable from a single-host success while a named host keeps spending. - // `readLiveHosts` RETURNS `{unreachable}` rather than rejecting, so `blind` is a branch on its - // value and the `.catch` below is only for a client that throws before it can answer. - const refused = await valkeyRefusal(url, env); - if (refused) return fail(refused); - const probe = makeRedisClient(url); - // Without this, a down Valkey dumps nine `[ioredis] Unhandled error event` traces before the one clean - // line -- the exact noise `defaultProbeValkey` exists to suppress. - probe.on?.("error", () => {}); - // Both reads, concurrently, sharing one budget. The registry answers WHO IS LIVE; BullMQ's own meta - // keys answer WHICH QUEUES EXIST, and for a kill switch the second is the question that matters. A - // host whose registry writes fail for ninety seconds loses its row while its worker keeps draining, - // and a resume that misses a queue leaves it paused forever with no surface able to name it. A meta - // key outlives its worker; a registry row does not. - const [fleet, existing] = await Promise.all([ - readLiveHosts(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((error) => ({ unreachable: error?.message ?? String(error) })), - discoverHostQueues(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }), - ]); - probe.disconnect?.(); - const blind = fleet?.unreachable ?? null; - const names = unionQueueNames(fleetQueueNames(fleet?.hosts), existing); - // The registry being unreadable no longer means we saw one queue: the keyspace scan may well have - // found them. Report the count we ACTED on, and name the degraded read separately. - const span = `${names.length > 1 ? ` [${names.length} queues]` : ""}${blind ? ` [registry unreadable: ${blind}]` : ""}`; - const queues = []; - try { - // Constructed INSIDE the try: `makeQueue` can throw on a malformed peer-written name, and a throw - // at index k > 0 would otherwise leak the k connections already opened. - for (const name of names) queues.push(makeQueue(parseConnection(url, { failFast: true }), { name })); - if (cmd === "pause" || cmd === "resume") { - const done = []; - try { - for (const q of queues) { - await (cmd === "pause" ? q.pause() : q.resume()); - done.push(q.name); - } - } catch (error) { - // A mid-loop failure leaves the deployment HALF switched. Naming what did change is the whole - // difference between an operator who knows to finish the job and one who reads "unreachable" - // as "nothing happened" and walks away from a fleet with one host still spending. - return fail(`could not ${cmd} the whole deployment at ${url}\n ${done.length > 0 ? `${cmd}d: ${done.join(", ")}` : "nothing changed"}\n failed at: ${names[done.length]}\n ${error.message}`); - } - write(cmd === "pause" ? `paused — worker will stop taking new jobs (jobs still enqueue)${span}\n` : `resumed${span}\n`); - } else { - // "paused" is included in the counts because jobs enqueued while paused land in the - // `paused` list, not `wait` -- omitting it would report backlog 0 in the exact state - // the pause switch creates. `pausedState` (the boolean) is named apart from the - // `paused` count `getJobCounts` returns, so the two do not collide in the output. - const states = await Promise.all(queues.map((q) => q.isPaused())); - const per = await Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))); - const counts = per.reduce((acc, c) => { - for (const [k, v] of Object.entries(c ?? {})) acc[k] = (acc[k] ?? 0) + (Number(v) || 0); - return acc; - }, {}); - // Summed counts with a boolean from ONE queue would report a half-paused deployment as fully - // one or fully the other. `pausedPartial` is the third state, and the dangerous direction is - // the one it makes visible: pause ran while a host was invisible, so that host still spends. - const pausedState = states.every(Boolean); - const pausedPartial = !pausedState && states.some(Boolean); - const out = { pausedState, ...(pausedPartial ? { pausedPartial, pausedQueues: names.filter((_, i) => states[i]) } : {}), ...counts, ...(blind ? { fleet: blind } : {}) }; - write(`${JSON.stringify(out)}\n`); - } - } catch (error) { - return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${url}: is it running? (docker compose up)\n ${error.message}`); - } finally { - for (const q of queues) await q.close().catch(() => {}); + // GitHub auth is misconfigured, so an operator can always stop the queue. Which VALKEY_URL (PR #475's review, + // rounds 1 and 2): `--valkey-url ` when the operator names one; else this shell's and the deployment .env's + // (the one resolver, `valkeyUrlFor`). When those two DISAGREE, a stale export must not make "paused" true of the + // wrong Valkey (measured: `pause` paused the shell's while the service's kept taking jobs), so the kill switch + // keeps its promise the safe way: `pause` pauses BOTH and says so, `status` shows both, and `resume`, which would + // START spending, refuses until the operator names which. Every URL is printed through `urlShown`: a password in + // one never reaches the terminal. + const { urlShown } = await import("./connection.mjs"); + const picked = await killSwitchUrls(argv.slice(1), env); + if (picked.error) return fail(picked.error); + if (picked.positionals.length > 0) return fail(`pi-dispatch ${cmd} takes no argument but --valkey-url (got ${picked.positionals.map((p) => JSON.stringify(p)).join(" ")})`); + if (picked.urls.length > 1) { + if (cmd === "resume") return fail(`${picked.disagreement}: resume would start jobs on one of them, so it names neither. Say which: pi-dispatch resume --valkey-url `); + process.stderr.write(`warning: ${picked.disagreement}: ${cmd === "pause" ? "pausing both" : "showing both"}\n`); } - return 0; + let code = 0; + for (const url of picked.urls) { + const label = picked.urls.length > 1 ? `[${urlShown(url)}] ` : ""; + code = Math.max(code, await killSwitch(cmd, url, { env, write, label, urlShown, valkeyRefusal })); + } + return code; } if (cmd === "cancel") { // The kill switch's doctrine (issue #287): VALKEY_URL only, never loadConfig, so one misbehaving - // job can be stopped even when everything else about the deployment is misconfigured. - const url = env.VALKEY_URL ?? "redis://127.0.0.1:6379"; + // job can be stopped even when everything else about the deployment is misconfigured. On a shell/.env + // disagreement it refuses until the operator names which (PR #475's review): a job id belongs to one Valkey. + const picked = await killSwitchUrls(argv.slice(1), env); + if (picked.error) return fail(picked.error); + if (picked.positionals.length > 1) return fail(`pi-dispatch cancel takes one job id (got ${picked.positionals.map((p) => JSON.stringify(p)).join(" ")})`); + const jobId = picked.positionals[0]; + if (picked.urls.length > 1) return fail(`${picked.disagreement}: a job lives in one of them. Say which: pi-dispatch cancel ${jobId ?? ""} --valkey-url `); const { runCancel } = await import("./cancel-cli.mjs"); - return runCancel(argv[1], url, { write }); + return runCancel(jobId, picked.urls[0], { write }); } write(`${USAGE}\n`); return cmd ? 1 : 0; } +/** + * The Valkey(s) a kill-switch verb acts on: the one rule of `killSwitchValkeyUrls` (valkey-endpoint.mjs), shared with the + * panel. The verb's own flags are parsed with parseArgs over what follows the verb (PR #475's review, round 3: a hand + * scan took `--valkey-url` for the job id in `cancel --valkey-url URL j1`). `{ urls, disagreement, positionals }` or + * `{ error }`; a note (a URL that matches neither side) is written to stderr. + */ +async function killSwitchUrls(args, env) { + let parsed; + try { + parsed = parseArgs({ args, allowPositionals: true, options: { "valkey-url": { type: "string" } } }); + } catch (error) { + return { error: error.message }; + } + const { killSwitchValkeyUrls } = await import("./connection.mjs"); + const picked = killSwitchValkeyUrls({ env, flagUrl: parsed.values["valkey-url"] ?? null }); + if (picked.error) return picked; + if (picked.note) process.stderr.write(`warning: ${picked.note}\n`); + return { ...picked, positionals: parsed.positionals }; +} + +/** One Valkey's pause, resume or status: every queue the deployment drains there (issue #57). Returns the exit code. */ +async function killSwitch(cmd, url, { env, write, label, urlShown, valkeyRefusal }) { + const { parseConnection, makeRedisClient } = await import("./connection.mjs"); + const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs"); + const { readLiveHosts } = await import("./host-registry.mjs"); + // EVERY queue this deployment drains (issue #57), not just the shared one. This is the kill switch: + // pausing `pi-jobs` alone would stop forge deliveries while a named host's cron, chained children + // and manual runs kept spending -- and would print "paused" for having done it. That is the silent + // no-op the comment here already warned about for a mistyped name, arriving through a new door. + // + // Both reads fail OPEN -- between them an unreadable registry and an unreadable keyspace yield the + // shared queue alone, which is exactly what this command did before, so a Valkey blip can never make + // the kill switch refuse. But it fails open LOUDLY: a degraded read is NAMED in the output rather + // than left indistinguishable from a single-host success while a named host keeps spending. + // `readLiveHosts` RETURNS `{unreachable}` rather than rejecting, so `blind` is a branch on its + // value and the `.catch` below is only for a client that throws before it can answer. + const refused = await valkeyRefusal(url, env); + if (refused) return fail(refused); + const probe = makeRedisClient(url); + // Without this, a down Valkey dumps nine `[ioredis] Unhandled error event` traces before the one clean + // line -- the exact noise `defaultProbeValkey` exists to suppress. + probe.on?.("error", () => {}); + // Both reads, concurrently, sharing one budget. The registry answers WHO IS LIVE; BullMQ's own meta + // keys answer WHICH QUEUES EXIST, and for a kill switch the second is the question that matters. A + // host whose registry writes fail for ninety seconds loses its row while its worker keeps draining, + // and a resume that misses a queue leaves it paused forever with no surface able to name it. A meta + // key outlives its worker; a registry row does not. + const [fleet, existing] = await Promise.all([ + readLiveHosts(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((error) => ({ unreachable: error?.message ?? String(error) })), + discoverHostQueues(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }), + ]); + probe.disconnect?.(); + const blind = fleet?.unreachable ?? null; + const names = unionQueueNames(fleetQueueNames(fleet?.hosts), existing); + // The registry being unreadable no longer means we saw one queue: the keyspace scan may well have + // found them. Report the count we ACTED on, and name the degraded read separately. + const span = `${names.length > 1 ? ` [${names.length} queues]` : ""}${blind ? ` [registry unreadable: ${blind}]` : ""}`; + const queues = []; + try { + // Constructed INSIDE the try: `makeQueue` can throw on a malformed peer-written name, and a throw + // at index k > 0 would otherwise leak the k connections already opened. + for (const name of names) queues.push(makeQueue(parseConnection(url, { failFast: true }), { name })); + if (cmd === "pause" || cmd === "resume") { + const done = []; + try { + for (const q of queues) { + await (cmd === "pause" ? q.pause() : q.resume()); + done.push(q.name); + } + } catch (error) { + // A mid-loop failure leaves the deployment HALF switched. Naming what did change is the whole + // difference between an operator who knows to finish the job and one who reads "unreachable" + // as "nothing happened" and walks away from a fleet with one host still spending. + return fail(`could not ${cmd} the whole deployment at ${urlShown(url)}\n ${done.length > 0 ? `${cmd}d: ${done.join(", ")}` : "nothing changed"}\n failed at: ${names[done.length]}\n ${error.message}`); + } + write(`${label}${cmd === "pause" ? `paused: worker will stop taking new jobs (jobs still enqueue)${span}` : `resumed${span}`}\n`); + } else { + // "paused" is included in the counts because jobs enqueued while paused land in the + // `paused` list, not `wait` -- omitting it would report backlog 0 in the exact state + // the pause switch creates. `pausedState` (the boolean) is named apart from the + // `paused` count `getJobCounts` returns, so the two do not collide in the output. + const states = await Promise.all(queues.map((q) => q.isPaused())); + const per = await Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))); + const counts = per.reduce((acc, c) => { + for (const [k, v] of Object.entries(c ?? {})) acc[k] = (acc[k] ?? 0) + (Number(v) || 0); + return acc; + }, {}); + // Summed counts with a boolean from ONE queue would report a half-paused deployment as fully + // one or fully the other. `pausedPartial` is the third state, and the dangerous direction is + // the one it makes visible: pause ran while a host was invisible, so that host still spends. + const pausedState = states.every(Boolean); + const pausedPartial = !pausedState && states.some(Boolean); + const out = { ...(label ? { valkey: urlShown(url) } : {}), pausedState, ...(pausedPartial ? { pausedPartial, pausedQueues: names.filter((_, i) => states[i]) } : {}), ...counts, ...(blind ? { fleet: blind } : {}) }; + write(`${JSON.stringify(out)}\n`); + } + } catch (error) { + return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${urlShown(url)}: is it running? (docker compose up)\n ${error.message}`); + } finally { + for (const q of queues) await q.close().catch(() => {}); + } + return 0; +} + function fail(message) { process.stderr.write(`error: ${message}\n`); return 1; @@ -287,6 +338,9 @@ export function entryExitCode(err) { // Entry point when run as a bin. Kept out of the exported main so tests can call main() directly. if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) { + // A promise nobody handled is printed as its message alone (PR #475's review): Node's own print shows the whole + // reason, which for a Valkey client's error could carry what it sent. + installRejectionPrinter(); main() .then((code) => { if (code) process.exitCode = code; diff --git a/worker/src/config.mjs b/worker/src/config.mjs index a6255981..8df3ed27 100644 --- a/worker/src/config.mjs +++ b/worker/src/config.mjs @@ -125,10 +125,21 @@ function commaList(raw) { * `GITHUB_APP_PRIVATE_KEY_PATH` is deliberately NOT here: a path string with no mount behind it is inert * inside a container, and refusing harmless things is how a refusal stops being read. * + * `VALKEY_PASSWORD` (issue #468) is the queue's password. Whoever holds it can read every queued job (its task text, + * its repository), enqueue work this deployment's worker runs with its provider key and forge credentials, and delete + * the queue: the very thing the password was added to keep from another account on the host, and so no more a job + * container's than theirs. + * * Kept separate from `MINTED_TOKEN_VARS`, which is defined as "every name any forge's mint can write" * and derived from the forge table. This is not that, and folding it in would make that definition a lie. */ -export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY"]); +export const WORKER_ONLY_SECRET_VARS = new Set(["GITHUB_APP_PRIVATE_KEY", "VALKEY_PASSWORD"]); + +/** Why each of `WORKER_ONLY_SECRET_VARS` stays in the worker, for the refusal that names it. */ +const WORKER_ONLY_WHY = Object.freeze({ + GITHUB_APP_PRIVATE_KEY: "the App's signing key mints tokens for every repository the App is installed on", + VALKEY_PASSWORD: "the queue's password lets whoever holds it read, enqueue and delete this deployment's jobs", +}); /** * The proxy variables the egress policy writes into the closed container env (REQ-EGRESS-ALLOWLIST). @@ -151,7 +162,7 @@ function forwardEnvList(raw, egressArmed = false) { const workerOnly = names.filter((n) => WORKER_ONLY_SECRET_VARS.has(n)); if (workerOnly.length > 0) { throw configError( - `PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- the App's signing key mints tokens for every repository the App is installed on, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`, + `PI_FORWARD_ENV must not forward ${workerOnly.join(", ")} -- ${workerOnly.map((n) => WORKER_ONLY_WHY[n]).join("; ")}, and a job container is the last place it belongs (CONST-TOKEN-SCOPED-PER-JOB)`, ); } const egress = egressArmed ? names.filter((n) => EGRESS_ENV_VARS.has(n)) : []; diff --git a/worker/src/connection.mjs b/worker/src/connection.mjs index d7c68e55..a6a62b6a 100644 --- a/worker/src/connection.mjs +++ b/worker/src/connection.mjs @@ -1,10 +1,86 @@ -import { AbstractConnector, Redis } from "ioredis"; +import { AbstractConnector, Command, Redis } from "ioredis"; import { Socket, connect as netConnect } from "node:net"; import { connect as tlsConnect } from "node:tls"; -import { judgeValkeyAtStart, judgedEndpoint, valkeyClientContext } from "./valkey-endpoint.mjs"; +import { valkeyAuthRefusal } from "./valkey-auth.mjs"; +import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor } from "./valkey-endpoint.mjs"; -// Re-exported for the receiver and the admin, which import this module from the package (issue #464). -export { judgeValkeyAtStart, valkeyClientContext }; +// Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468). +export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor }; + +/** + * The VALKEY_URL a CLI command uses from `cwd` (`valkeyUrlFor`), with a disagreement or an unreadable `.env` written as + * one warning line through `warn`. The one resolver every CLI verb and `service restart --drain` share. + */ +export function cliValkeyUrl(env, { cwd, warn = (line) => process.stderr.write(line) } = {}) { + const resolved = valkeyUrlFor(valkeyClientContext({ env, ...(cwd ? { cwd } : {}) })); + if (resolved.note) warn(`warning: ${resolved.note}\n`); + return resolved.url; +} + +/** + * An error a Valkey client reports, without the command it failed on (issue #468, PR #475's review). ioredis hangs + * `command: { name, args }` on every reply error, and for a failed AUTH the args ARE the password: after a rotation, + * a client still sending the old one emitted `WRONGPASS` with it attached, and BullMQ, whose Queue and Worker had no + * `error` listener, fell back to `console.error(err)`, which prints the whole object, password included (measured + * against a real Redis with CONFIG SET requirepass and CLIENT KILL). The args also carry job data (task text) for any + * other failed command. So only the command's NAME is kept. Mutates and returns `err`; anything else passes through. + */ +export function scrubValkeyError(err) { + if (err && typeof err === "object" && err.command && typeof err.command === "object") { + try { + err.command = { name: err.command.name }; + } catch { + // A frozen error: nothing this client made. + } + } + return err; +} + +/** + * WHERE the scrub runs (PR #475's review, round 2 corrected the first version of this comment, which claimed the emit + * hook alone covered every path). ioredis hands an error to three kinds of consumer, and a failed AUTH reaches all + * three with the password in `command.args`: + * - a listener, through `emit("error")`; + * - nobody, through `silentEmit("error")`, which SKIPS `emit()` when the client has no `error` listener (the worker's + * shared client, a CLI probe): ioredis then prints only `error.stack`, but the same object was already handed + * - to every command waiting on the connection, through that command's `reject` (`flushQueue`, `abortError`, a + * reply error): an awaited or unhandled rejection that anything may print whole (measured by the review: two + * occurrences of the password from a listener-less client after a rotation). + * So the hook sits where each of them receives the object: `Redis.prototype.emit` and `silentEmit` for the events, and + * every `Command`'s `reject` (wrapped as `initPromise` makes it) for the rejections. BullMQ and this module share the + * one ioredis, so its clients are covered by the same hooks. + */ +const SCRUBS = Symbol.for("pi-dispatch.valkey-error-scrub"); +if (!Redis.prototype[SCRUBS]) { + const emit = Redis.prototype.emit; + Redis.prototype.emit = function emitScrubbed(event, ...args) { + if (event === "error") scrubValkeyError(args[0]); + return emit.call(this, event, ...args); + }; + const silentEmit = Redis.prototype.silentEmit; + Redis.prototype.silentEmit = function silentEmitScrubbed(event, arg, ...rest) { + if (event === "error") scrubValkeyError(arg); + return silentEmit.call(this, event, arg, ...rest); + }; + const initPromise = Command.prototype.initPromise; + Command.prototype.initPromise = function initPromiseScrubbed(...args) { + const out = initPromise.apply(this, args); + const reject = this.reject; + if (typeof reject === "function") this.reject = (err) => reject(scrubValkeyError(err)); + return out; + }; + Redis.prototype[SCRUBS] = true; +} + +/** + * The `error` listener every BullMQ Queue and Worker of the project carries (issue #468): without one, BullMQ prints + * the whole error object with `console.error`. This writes one line, the message only (scrubbed of any command), and + * `what` naming whose error it is. + */ +export function onValkeyError(emitter, what, write = (line) => process.stderr.write(line)) { + emitter.on?.("error", (err) => write(`[pi-dispatch] Valkey error (${what}): ${String(scrubValkeyError(err)?.message ?? err)}\n`)); + return emitter; +} /** * Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL. @@ -21,22 +97,28 @@ export { judgeValkeyAtStart, valkeyClientContext }; * error in a couple of seconds with a clear message, not hang forever. The long-running WORKER * uses the default (persistent) options -- it should ride out a Valkey restart, not give up. */ -export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null } = {}) { +export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false } = {}) { const u = new URL(url); + const where = context ?? defaultValkeyContext(); + // Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from + // the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the + // project (the worker, the CLI, the receiver, the admin panel, doctor) authenticates the same way. The URL's userinfo + // is percent-decoded: it was handed over raw before, so a password with `@` written `%40` never matched. + const { password } = valkeyPasswordFor(url, where, { withoutPassword }); return { // Issue #464 (gate round 2 follow-up): EVERY client made from these options connects through `JudgedConnector`, // which judges and pins the address (valkey-endpoint.mjs) before each connect; `host` below is only what ioredis // reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given. Connector: JudgedConnector, // `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given). - [JUDGE]: { url, context: context ?? valkeyClientContext(), ...(judge ? { judge } : {}) }, + [JUDGE]: { url, context: where, ...(judge ? { judge } : {}) }, // WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name // "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on // Fedora 44: ETIMEDOUT). The address is the part inside them. host: u.hostname.replace(/^\[(.*)\]$/, "$1") || "127.0.0.1", port: Number(u.port || 6379), - ...(u.password ? { password: u.password } : {}), - ...(u.username ? { username: u.username } : {}), + ...(password ? { password } : {}), + ...(u.username && !withoutPassword ? { username: decodedUserinfo(u.username) } : {}), ...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}), // TLS for `rediss:` (issue #464, gate round 2). Host and port alone dropped it, so a `rediss:` VALKEY_URL reached // BullMQ as plaintext. `servername` is the name a certificate is checked against when the worker connects to a @@ -58,8 +140,74 @@ export function parseConnection(url, { failFast = false, servername = null, cont * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and * `failFast` as ioredis and `parseConnection` read them. */ -export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null } = {}) { - return new Redis({ ...parseConnection(url, { failFast, servername, context, judge }), ...(lazyConnect ? { lazyConnect: true } : {}) }); +export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false } = {}) { + return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}) }); +} + +/** + * How the Valkey at `url` answers this client's credential (issue #468), through a fail-fast client built like every + * other: `{ state }`, one of + * "ok" PING answered PONG with what this client sends (a password or none); + * "noauth" it requires a password and this client sent none (NOAUTH); + * "wrongpass" it refused the password this client sent (WRONGPASS, or "invalid password"); + * "unreachable" anything else (nothing answers, a refused owner), with its `error` text. + * `withoutPassword` asks as a client that sends none would: "ok" then means this Valkey answers ANY local account. + * The password itself never leaves this function; the answer names only which state it is. Never throws. + */ +export async function valkeyAuthState(url, { context = null, servername = null, withoutPassword = false, timeoutMs = 5000, makeClient = makeRedisClient } = {}) { + const seen = []; + let client; + try { + client = makeClient(url, { failFast: true, lazyConnect: true, context, servername, withoutPassword }); + } catch (err) { + return { state: "unreachable", error: err?.message ?? String(err) }; + } + // ioredis reports an AUTH or ready-check refusal as an "error" event and then reconnects (bounded by failFast), while + // connect() itself may reject with only "Connection is closed.", so the events are kept and read with the reply. + client.on("error", (err) => seen.push(err?.message ?? String(err))); + let timer; + try { + const reply = await Promise.race([ + client.connect().then(() => client.ping()), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs); + }), + ]); + return reply === "PONG" ? { state: "ok" } : { state: "unreachable", error: `PING answered ${String(reply).slice(0, 40)}` }; + } catch (err) { + const all = [err?.message ?? String(err), ...seen].join("\n"); + if (/\bWRONGPASS\b|invalid password/i.test(all)) return { state: "wrongpass" }; + if (/\bNOAUTH\b/.test(all)) return { state: "noauth" }; + return { state: "unreachable", error: err?.message ?? String(err) }; + } finally { + clearTimeout(timer); + client.disconnect(); + } +} + +/** + * `judgeValkeyAtStart` of valkey-endpoint.mjs (the owner judgement, retried while nothing answers), then this client's + * credential asked once (issue #468): a Valkey that refuses it is a refusal (a configError, exit 2, like a refused + * owner), since a restart sends the same credential, and names VALKEY_PASSWORD without its value. Nothing answering + * stays what it was: the command's own connect says it could not reach Valkey. `checkAuth` is a seam, the real check + * unless the caller injected its own `judge` (a test standing in for the host, which has no Valkey to ask). + */ +export async function judgeValkeyAtStart(url, context, opts = {}) { + const endpoint = await judgeEndpointAtStart(url, context, opts); + const checkAuth = opts.checkAuth !== undefined ? opts.checkAuth : opts.judge ? null : valkeyAuthState; + if (checkAuth) { + const where = context ?? defaultValkeyContext(); + const { state } = await checkAuth(url, { context: where, servername: endpoint?.servername ?? null }); + const refusal = state === "noauth" || state === "wrongpass" ? authRefusalFor(state, url, where) : null; + if (refusal) throw Object.assign(new Error(refusal), { piDispatchConfig: true, valkeyRefused: true, valkeyAuth: state }); + } + return endpoint; +} + +/** The refusal sentence for an auth state, naming where the password would come from (never its value). */ +export function authRefusalFor(state, url, context) { + const { password, from } = valkeyPasswordFor(url, context); + return valkeyAuthRefusal(new Error(state === "noauth" ? "NOAUTH" : "WRONGPASS"), { passwordSet: Boolean(password), from, envPath: context?.envPath ?? "the deployment's .env" }); } /** @@ -135,3 +283,34 @@ export function isJudgedConnection(connection) { export function assertJudgedConnection(connection) { if (!isJudgedConnection(connection)) throw new TypeError("a Valkey connection must be built by parseConnection (connection.mjs), which judges and pins the address it dials (issue #464)"); } + +/** + * Record, then read back, which deployment folder the queue in a Valkey belongs to (PR #475's review, the volume gap): + * `SET pi-dispatch:owner NX`, then `GET`. Docker volume labels cannot be added after creation, so a volume made + * before the label (adopted with consent) carries its owner here, inside the data it describes, and every later start + * on it checks this key. Retried for `waitMs` while a Valkey that was just started comes up. `{ owner, claimed }` where + * `owner` is what the key holds (this folder, or another one's), or `{ error }` when it could not be read. + */ +export async function claimValkeyOwner(url, folder, { context = null, waitMs = 15000, makeClient = makeRedisClient, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) { + const { OWNER_MARKER_KEY } = await import("./valkey-auth.mjs"); + const until = Date.now() + waitMs; + let last = "no attempt"; + for (;;) { + let client; + try { + client = makeClient(url, { failFast: true, lazyConnect: true, context }); + client.on("error", () => {}); + await client.connect(); + const set = await client.set(OWNER_MARKER_KEY, folder, "NX"); + const owner = await client.get(OWNER_MARKER_KEY); + return { owner, claimed: set === "OK" }; + } catch (err) { + last = err?.message ?? String(err); + // NOAUTH and WRONGPASS will not change by waiting. + if (/\bNOAUTH\b|\bWRONGPASS\b/.test(last) || Date.now() >= until) return { error: last }; + } finally { + client?.disconnect(); + } + await sleep(500); + } +} diff --git a/worker/src/doctor.mjs b/worker/src/doctor.mjs index 157cd9ff..94e11703 100644 --- a/worker/src/doctor.mjs +++ b/worker/src/doctor.mjs @@ -52,7 +52,7 @@ import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync } from "node:fs"; import { lookup as dnsLookup } from "node:dns/promises"; import { homedir, networkInterfaces, release as osRelease, tmpdir, userInfo } from "node:os"; -import { NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_START, STACK_KEYS, STARTED_AT_FORMAT, VALKEY_SHARED_KEY, judgeNetnsKeeper, judgeValkeyListeners, netnsKeeperRemedy, pinnedValkeyUrl, podmanNeedsNetnsKeeper, probeTcpAddress, readSubuidRanges, proxyRestartAdvice, readLinger, readValkeyKeys, valkeySharedOn } from "./podman-stack.mjs"; +import { NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_START, STACK_KEYS, STARTED_AT_FORMAT, VALKEY_SHARED_KEY, judgeNetnsKeeper, judgeValkeyListeners, netnsKeeperRemedy, pinnedValkeyUrl, podmanNeedsNetnsKeeper, probeTcpAddress, readSubuidRanges, proxyRestartAdvice, readLinger, readValkeyKeys, valkeySharedOn, valkeyTarget } from "./podman-stack.mjs"; import { dirname, isAbsolute, join, delimiter, posix, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn as nodeSpawn } from "node:child_process"; @@ -82,6 +82,8 @@ import { endpointShown, makeDockerEndpointResolver, quotedShown } from "./backen import { DEFAULT_EGRESS_PROXY, STOPPED_PROXY_STATES, EGRESS_CANARY_NET_PREFIX, EGRESS_CANARY_PROBE_PREFIX, egressArmed, egressCanaryNetwork, egressCanaryProbe, egressEnv, egressProxyName, networkEndpoints, removeNetworkOrSay } from "./egress.mjs"; import { detachBlockedSentence, makeDetachGate, runtimeFromFacts } from "./netns-keeper.mjs"; import { runLiveProbes } from "./live-probes.mjs"; +import { VALKEY_PASSWORD_KEY, VALKEY_PASSWORD_HOWTO, VALKEY_PORT_KEY, isLoopbackHost, valkeyPasswordProblem, valkeyPortConflict } from "./valkey-auth.mjs"; +import { urlShown, valkeyContextFromResolution, valkeyPasswordFor } from "./valkey-endpoint.mjs"; import { SANDBOX_TOMBSTONE_STUCK_MS, isSandboxTombstone, sandboxTombstoneAge } from "./sandbox-store.mjs"; import { installedUnitPaths, readUnitSeam, readUnitUser } from "./service.mjs"; import { CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs"; @@ -133,11 +135,6 @@ const TRIGGER_IMAGE_ENTRYPOINT_FIX = "build your job image FROM this repo's imag // such defaults to pin (its CLI forwards only what `-e` names), so its argv is what it always was. const ghProbeArgs = (image, bin) => ["run", "--rm", "--pull=never", ...(bin === "podman" ? PODMAN_PINNED_FLAGS : []), "-e", "GH_TOKEN", "-e", "GITHUB_TOKEN", "--entrypoint", "gh", image, "auth", "status"]; -// The loopback Valkey, as one docker argv. Mirrors deploy/docker-compose.yml exactly: AOF on (the -// wait-list must survive a reboot, REQ-QUEUE-BURST-NO-DROP), bound to 127.0.0.1 only (the queue is not a -// public surface), restart unless-stopped, data on a named volume. Container and volume names are -// pi-dispatch-prefixed so compose's own `valkey`/`valkey-data` never collide with these. -const VALKEY_RUN = ["run", "-d", "--name", "pi-dispatch-valkey", "--restart", "unless-stopped", "-p", "127.0.0.1:6379:6379", "-v", "pi-dispatch-valkey-data:/data", "valkey/valkey:8", "valkey-server", "--appendonly", "yes"]; // Issue #471: `shellVars` is THIS shell's environment and is read in exactly the places a test pins (the venue and // service-key resolution, the agent dir default, and the environment handed to child processes). Every check judges @@ -151,6 +148,10 @@ export async function runDoctor(shellVars = process.env, deps = {}) { // in milliseconds; nothing else passes it. runTimeouts = RUN_TIMEOUTS, probeValkey = defaultProbeValkey, + // Issue #468: how the Valkey answers a credential, `{ state, passwordSet, from }` (connection.mjs' `valkeyAuthState`), + // in-process, through doctor's own client: no program is started and handed the password. Real only where the + // Valkey probe is, as the owner check: a test that injects `probeValkey` gets none unless it injects this too. + valkeyAuth, readHosts = defaultReadHosts, fileExists = existsSync, nodeVersion = process.versions.node, @@ -306,7 +307,17 @@ export async function runDoctor(shellVars = process.env, deps = {}) { ? (url, { shared, user, envPath, rootOk = false }) => judgeValkeyListeners({ url, probeTcp: probeTcpAddress, lookup: (host, opts) => dnsLookup(host, opts), fs: { readFileSync }, euid: process.geteuid?.(), user, shared, rootOk, ownerName: (uid) => ownerNameFromPasswd(passwd, uid), interfaces: networkInterfaces, envPath, subuids: readSubuidRanges({ user, euid: process.geteuid?.(), fs: { readFileSync } }) }) : null; - const seams = { cwd, out, spawn, probeValkey, readHosts, fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, venueChecks, userName, proxyFilesExist, ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) }; + const valkeyAuthSeam = + valkeyAuth !== undefined + ? valkeyAuth + : deps.probeValkey === undefined + ? async (url, { context, withoutPassword = false } = {}) => { + const { valkeyAuthState } = await import("./connection.mjs"); + const sent = valkeyPasswordFor(url, context); + return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from }; + } + : null; + const seams = { cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, venueChecks, userName, proxyFilesExist, ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) }; // Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the // same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is // handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env` @@ -499,7 +510,7 @@ export const CLI_SERVICE_KEYS = Object.freeze({ gh: Object.freeze(["GH_TOKEN", "GITHUB_TOKEN", "GH_CONFIG_DIR", "GH_HOST", "XDG_CONFIG_HOME", "HOME"]), }); const CLI_KEY_NAMES = [...new Set(Object.values(CLI_SERVICE_KEYS).flat())]; -export const SERVICE_ENV_KEYS = Object.freeze([...STACK_KEYS, "VALKEY_URL", VALKEY_SHARED_KEY, "PI_PROVIDER", ...GITHUB_SERVICE_KEYS, "PI_JOBS_DIR", "PI_SANDBOX_DIR", "TMPDIR", "PI_WORKER_NAME", ...WORKER_SERVICE_KEYS, ...RECEIVER_SERVICE_KEYS, ...CLI_KEY_NAMES]); +export const SERVICE_ENV_KEYS = Object.freeze([...STACK_KEYS, "VALKEY_URL", VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, VALKEY_PORT_KEY, "PI_PROVIDER", ...GITHUB_SERVICE_KEYS, "PI_JOBS_DIR", "PI_SANDBOX_DIR", "TMPDIR", "PI_WORKER_NAME", ...WORKER_SERVICE_KEYS, ...RECEIVER_SERVICE_KEYS, ...CLI_KEY_NAMES]); /** Issue #471 (gate round 1): what a service manager gives every service itself, so a `.env` need not carry it. */ const AMBIENT_SERVICE_KEYS = Object.freeze(["TMPDIR", "TEMP", "XDG_RUNTIME_DIR", "HOME"]); @@ -590,7 +601,7 @@ export const DOCTOR_SHELL_KEYS = Object.freeze({ /** Issue #471: the service keys whose values a disagreement line never prints (set or unset only); URLs print as * `urlShown` does. Every other key's two values are shown, quoted. */ -const SECRET_SERVICE_KEYS = new Set(["GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]); +const SECRET_SERVICE_KEYS = new Set([VALKEY_PASSWORD_KEY, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_PRIVATE_KEY", "WEBHOOK_SECRET", "GITLAB_TOKEN", "GITLAB_WEBHOOK_SECRET", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_TOKEN", "AZURE_WEBHOOK_SECRET"]); const URL_SERVICE_KEYS = new Set(["VALKEY_URL", "GITLAB_URL", "FORGEJO_URL", "AZURE_ORG_URL"]); /** `keys` narrowed to `SERVICE_ENV_KEYS` plus the provider's own key variables. */ @@ -1821,6 +1832,15 @@ export async function collectChecks(shellVars, seams) { const valkeyUnresolved = ownerVerdict?.unresolved ? `VALKEY_URL's host ${ownerVerdict.unresolved} did not resolve here (${ownerVerdict.why}), so whose Valkey it reaches cannot be judged; the worker waits for it and retries` : null; const valkeyRefused = Boolean(ownerVerdict?.refusal) || valkeyUnresolved !== null; const valkeyTalkUrl = ownerVerdict?.chosen ? pinnedValkeyUrl(valkeyUrl, ownerVerdict.chosen).url : valkeyUrl; + // Issue #468: how that Valkey answers this deployment's credential, asked only where doctor talks to it at all, by + // doctor's own in-process client (issue #471's rule: no program doctor starts is handed anything from `.env`). The + // credential is the resolved one: this shell's VALKEY_PASSWORD, else the file's (for a loopback Valkey only, as every + // client sends it, `valkeyPasswordFor`), never printed. + const fileValue = (key) => service.fromFile[key] ?? service.disagreements.find((d) => d.key === key)?.file; + // This shell's side where it set the key, the file's where the resolution took it from there (sent to a loopback + // Valkey only), as every client sends it (`valkeyContextFromResolution`). + const valkeyContext = valkeyContextFromResolution({ service, envPath: serviceEnvFile?.path ?? join(cwd, ".env"), shared: sharedInFile, platform }); + const valkeyAuthVerdict = !valkeyUnresolved && !valkeyRefused && !unreadValkey && seams.valkeyAuth ? await seams.valkeyAuth(valkeyTalkUrl, { context: valkeyContext }) : null; if (valkeyUnresolved) { checks.push({ ok: false, label: valkeyUnresolved, fix: "fix the name's resolution on this host, or write the address in VALKEY_URL" }); } else if (valkeyRefused) { @@ -1831,37 +1851,44 @@ export async function collectChecks(shellVars, seams) { label: `${unreadValkey}: doctor contacted no Valkey, since the default it would fall back to (${urlShown(valkeyUrl)}) is not what the service's worker is given, and on a shared host may be another account's`, fix: "write that line so every loader reads it the same (the reason above says how), then re-run doctor", }); + } else if (valkeyAuthVerdict && (valkeyAuthVerdict.state === "noauth" || valkeyAuthVerdict.state === "wrongpass")) { + // Issue #468: a Valkey that refuses this deployment's credential answers, so "not reachable" would be the wrong + // sentence: it is the password, named by its key and where it came from, never its value. + checks.push({ ok: false, label: `Valkey (${urlShown(valkeyUrl)}) answers, and ${valkeyAuthVerdict.state === "noauth" ? "requires a password this deployment does not send" : `refuses the ${VALKEY_PASSWORD_KEY} this deployment sends`}: the worker refuses to start on it (exit 2)`, fix: valkeyAuthFix(valkeyAuthVerdict.state, { localUsed, podmanUsed, envPath: join(cwd, ".env") }) }); } else checks.push({ ok: await probeValkey(valkeyTalkUrl), label: `Valkey reachable (${urlShown(valkeyUrl)})${fromFileNote(fileSays("VALKEY_URL"))}`, - // Issue #433: without `local`, docker is not this deployment's runtime and may not be installed at all, so neither the - // compose file nor a `docker run` is offered. The words hold before and after the setup commands learn Podman: the - // podman guide says how to start Valkey under the account, whichever command does it. - fix: localUsed ? "docker compose -f deploy/docker-compose.yml up -d" : "run `pi-dispatch up` (or `pi-dispatch service install`) as this account: on the podman venue both start Valkey as a Quadlet unit under its Podman (docs/podman.md, setup step 6); a Valkey you run yourself, a distribution package say, works too", - // Prompt tier, and only for a LOOPBACK url (the shipped default): starting a local container cannot - // make a remote VALKEY_URL reachable, so a pointed-elsewhere deployment keeps the plain fix line - // rather than an offer that would mask the real problem. The argv mirrors the compose file's - // semantics exactly (VALKEY_RUN above). Issue #433: and only with `local`. A `podman run` of the same argv - // was rejected: Podman has no daemon to bring a `--restart` container back after a reboot (podman-run(1) sends - // that to a systemd unit), so the fix would report success and leave a queue that is gone after the next boot. - ...(localUsed && /^redis:\/\/(127\.0\.0\.1|localhost)(:6379)?\/?$/.test(valkeyUrl) - ? { - fixAction: { - tier: "prompt", - describe: `docker ${VALKEY_RUN.join(" ")}`, - run: async ({ spawn }) => - // The PULL bound: this `docker run` fetches the valkey image on a host that does not have it. - (await runCmd(spawn, "docker", VALKEY_RUN, runTimeouts.pull)).code === 0 - ? { ok: true } - : { ok: false, note: "docker run failed (is a container named pi-dispatch-valkey already present? `docker start pi-dispatch-valkey`)" }, - }, - } - : {}), + // Issue #433: without `local`, docker is not this deployment's runtime and may not be installed at all. Issue #468 + // with issue #471's rule: doctor starts NO Valkey any more (its `--fix` offered a `docker run`, which since #468 needs + // the deployment's VALKEY_PASSWORD in the docker CLI's environment, and doctor hands no program anything from + // `.env`). It names the step: `pi-dispatch up`, which starts it with the password, labels its container and volume + // with this folder, and asks before it uses a volume it cannot attribute. The words hold on both venues. + fix: localUsed + ? "run `pi-dispatch up` in the deployment folder: it starts Valkey with the deployment's VALKEY_PASSWORD (or, in a folder /dispatch setup handed to compose, compose's own: `docker compose -p --env-file .env -f deploy/docker-compose.yml -f deploy/docker-compose.valkey.yml up -d valkey`)" + : "run `pi-dispatch up` (or `pi-dispatch service install`) as this account: on the podman venue both start Valkey as a Quadlet unit under its Podman (docs/podman.md, setup step 6); a Valkey you run yourself, a distribution package say, works too", }); + // Round 3 of PR #475's review: PI_VALKEY_PORT is the port compose publishes its Valkey on, and VALKEY_URL's is the one + // the worker dials; a value that disagrees (in .env, or in this shell, which wins over --env-file) moves the queue off + // the worker's port on the next compose run (measured). A loopback VALKEY_URL only: another host's port is not compose's. + { + const target = valkeyTarget(valkeyUrl); + const portDisagreement = service.disagreements.find((d) => d.key === VALKEY_PORT_KEY); + const sources = [ + ...(typeof env[VALKEY_PORT_KEY] === "string" && env[VALKEY_PORT_KEY].trim() !== "" ? [[env[VALKEY_PORT_KEY].trim(), fileSays(VALKEY_PORT_KEY).length > 0 ? join(cwd, ".env") : "this shell"]] : []), + ...(portDisagreement && portDisagreement.file.trim() !== "" ? [[portDisagreement.file.trim(), join(cwd, ".env")]] : []), + ]; + if (!target.error && isLoopbackHost(target.host)) { + for (const [value, where] of sources) { + if (value !== String(target.port)) checks.push({ ok: false, warn: true, label: valkeyPortConflict(value, target.port, where), fix: `${VALKEY_PORT_KEY}=${target.port} in ${join(cwd, ".env")} (and unset it in this shell if it is set there)` }); + } + } + } + if (ownerVerdict?.heldBy) { checks.push({ ok: true, label: `Valkey (${urlShown(valkeyUrl)}) answers from a listener of ${ownerVerdict.heldBy}${ownerVerdict.chosen ? `; the worker connects to ${ownerVerdict.chosen} only` : ""}` }); } + checks.push(...(await valkeyPasswordChecks({ verdict: valkeyAuthVerdict, context: valkeyContext, seams, valkeyUrl, valkeyTalkUrl, localUsed, podmanUsed, platform, statSeam, fileValue }))); for (const other of ownerVerdict?.elsewhere ?? []) { checks.push({ ok: false, warn: true, label: `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`, fix: "nothing to change for this deployment; a client that resolves the name itself (`pi-dispatch run` from a shell) may still reach it, so prefer VALKEY_URL=redis://127.0.0.1:" }); } @@ -4669,6 +4696,72 @@ function runCmd(spawn, cmd, args, timeoutMs = RUN_TIMEOUTS.cmd) { }); } +/** + * The step that gives a deployment's Valkey its password and restarts it with it (issue #468), per venue: the podman + * venue's Quadlet Valkey through `service install --force`, docker's through `up` (or compose's own recreate). Named, + * never run by doctor (issue #471's rule). + */ +export function valkeyPasswordUpgradeStep({ localUsed, podmanUsed }) { + if (podmanUsed && !localUsed) return "run `pi-dispatch service install --force` as this account: it writes a VALKEY_PASSWORD into .env, restarts the Quadlet Valkey with it (the queue in its volume is kept) and then the worker and the receiver"; + return "run `pi-dispatch up`: it writes a VALKEY_PASSWORD into .env and restarts pi-dispatch-valkey with it (its volume, and the queue, is kept); a compose-started Valkey is recreated with `docker compose --env-file .env -f deploy/docker-compose.yml up -d`. Then restart the worker and the receiver so they send it"; +} + +/** The fix for a Valkey that refuses this deployment's credential (issue #468). */ +function valkeyAuthFix(state, { localUsed, podmanUsed, envPath }) { + if (state === "noauth") return `put ${VALKEY_PASSWORD_KEY}= in ${envPath} (for a Valkey shared with PI_VALKEY_SHARED=1, the password of the account that runs it), then restart the worker and the receiver`; + return `${VALKEY_PASSWORD_KEY} in ${envPath} is not that Valkey's password. If you changed it, restart Valkey with it: ${valkeyPasswordUpgradeStep({ localUsed, podmanUsed })}`; +} + +/** + * Doctor's password lines (issue #468), after the reachability line: whether this deployment's Valkey requires a + * password, said by whether one is SET and never by its value. A loopback Valkey that answers a client sending none is a + * warning with the upgrade step (any local account can read and feed that queue); a remote one is the operator's own and + * is not judged. A `.env` holding VALKEY_PASSWORD that other accounts can read is a warning too, and a value the start + * script could not hand to Valkey is a failure. The file's value comes from issue #471's one resolution (`fileValue`). + */ +async function valkeyPasswordChecks({ verdict, context, seams, valkeyUrl, valkeyTalkUrl, localUsed, podmanUsed, platform, statSeam, fileValue }) { + const checks = []; + const inFile = fileValue(VALKEY_PASSWORD_KEY); + if (typeof inFile === "string" && inFile !== "" && seams.serviceEnvFile) { + const problem = valkeyPasswordProblem(inFile); + if (problem) checks.push({ ok: false, label: `${VALKEY_PASSWORD_KEY} in ${seams.serviceEnvFile.path} cannot be handed to Valkey: ${problem}`, fix: VALKEY_PASSWORD_HOWTO }); + if (platform !== "win32") { + let mode = null; + try { + mode = statSeam(seams.serviceEnvFile.path).mode; + } catch { + // Unreadable metadata: nothing is said about it. + } + if (typeof mode === "number" && (mode & 0o077) !== 0) { + checks.push({ ok: false, warn: true, label: `${seams.serviceEnvFile.path} holds ${VALKEY_PASSWORD_KEY} and is readable by ${(mode & 0o007) !== 0 ? "every account on this host" : "its group"} (mode ${(mode & 0o777).toString(8).padStart(3, "0")})`, fix: `chmod 600 ${seams.serviceEnvFile.path}` }); + } + } + } + if (!verdict || verdict.state !== "ok" || !seams.valkeyAuth) return checks; + let host = ""; + try { + host = new URL(valkeyUrl).hostname; + } catch { + return checks; + } + // A Valkey on another machine is the operator's own to secure; the threat this answers is another account on THIS one. + if (!isLoopbackHost(host)) return checks; + const open = await seams.valkeyAuth(valkeyTalkUrl, { context, withoutPassword: true }); + const upgrade = valkeyPasswordUpgradeStep({ localUsed, podmanUsed }); + if (open.state === "ok") { + checks.push( + verdict.passwordSet + ? { ok: false, warn: true, label: `Valkey (${urlShown(valkeyUrl)}) answers a client that sends no password, although ${VALKEY_PASSWORD_KEY} is set: it was started before the password existed, and any local account can read, enqueue or delete this deployment's jobs`, fix: upgrade } + : { ok: false, warn: true, label: `Valkey (${urlShown(valkeyUrl)}) has no password: any local account can read, enqueue or delete this deployment's jobs (${VALKEY_PASSWORD_KEY} is not set)`, fix: upgrade }, + ); + } else if (open.state === "noauth") { + // The source named (PR #475's review): the environment, the .env, or the URL's own userinfo, never the value. + const source = verdict.from === "VALKEY_URL" ? "the password in VALKEY_URL" : verdict.from ? `${VALKEY_PASSWORD_KEY} from ${verdict.from}` : VALKEY_PASSWORD_KEY; + checks.push({ ok: true, label: `Valkey (${urlShown(valkeyUrl)}) requires a password, and the one this deployment sends (${source}) is accepted (the value is not shown)` }); + } + return checks; +} + /** * Like runCmd but collects stdout+stderr into one combined string — gh moves its human output between * the two across versions, so callers get both. Resolves `{code, output}`; `code: null` when the command @@ -6188,23 +6281,9 @@ function userNameOf(seams) { } } -/** - * A URL as doctor may print it (issue #453, gate round 3 and the re-review): scheme, host, port and database only, - * never the userinfo (a password or a username that is a token), the query (`password=`, a token) or the fragment; - * `` without a host, and `` when it does not parse, since then nothing can say where a - * credential in it starts or ends. - */ -export function urlShown(url) { - let parsed; - try { - parsed = new URL(String(url)); - } catch { - return ""; - } - // scheme://host:port/db and nothing else (round-cap re-review): no userinfo at all, no query, no fragment. - if (!parsed.hostname) return ""; - return printable(`${parsed.protocol}//${parsed.host}${parsed.pathname && parsed.pathname !== "/" ? parsed.pathname : ""}`); -} +// `urlShown` (a URL as doctor may print it, never its userinfo, query or fragment) lives in valkey-endpoint.mjs since +// PR #475's review, so the CLI, the panel and doctor print every Valkey URL through the one function. +export { urlShown }; /** * PI_JOB_IMAGE as the worker takes it and as doctor may hand it to a runtime (issue #471): `{ image, refused }`. The diff --git a/worker/src/env-file.mjs b/worker/src/env-file.mjs index c7c52341..6c26b87c 100644 --- a/worker/src/env-file.mjs +++ b/worker/src/env-file.mjs @@ -875,7 +875,11 @@ export function envFileEditCheck(content, path, key, value, opts = {}) { * Mode: a .env at 0o600 (an operator who locked their secrets down) stays 0o600 — chmod on the tmp * BEFORE the rename, so no window exists where the secret-bearing file is wider than it was. Any * other mode is left to the platform default; this helper preserves a hardening choice, it does not - * impose one. + * impose one, except where the caller says `narrow` (issue #468: the Valkey password goes only into a + * `.env` no other account can read, so its writers narrow the file to the owner's bits). + * + * The tmp is CREATED at 0o600 (issue #468): it holds every secret of the file, and created at the + * umask it was world-readable for the moment between the write and the chmod. */ export function updateEnvFile(path, key, value, deps = {}) { // `platform` is derived HERE rather than passed by each caller, which is what the first version got @@ -883,7 +887,7 @@ export function updateEnvFile(path, key, value, deps = {}) { // and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key // that decides forge auth. A rendering rule that every writer of this file must remember is a rule one // of them will forget. - const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync }, overwrite = false, platform = process.platform } = deps; + const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync }, overwrite = false, platform = process.platform, narrow = false } = deps; // Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written. const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify }); if (plan.error) throw new Error(plan.error); @@ -922,18 +926,20 @@ export function updateEnvFile(path, key, value, deps = {}) { } } const tmp = `${target}.tmp`; - fs.writeFileSync(tmp, next); + fs.writeFileSync(tmp, next, { mode: 0o600 }); try { // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back // 0644, world-readable, on the one file this project says must never reach a scrollback. - fs.chmodSync(tmp, fs.statSync(target).mode & 0o7777); + // `narrow` keeps only the owner's bits of it. + const mode = fs.statSync(target).mode & 0o7777; + fs.chmodSync(tmp, narrow ? mode & 0o7700 : mode); } catch { - // The file vanished between read and write, or the fs cannot stat: leave the tmp's default - // mode rather than failing an edit that is otherwise sound. + // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was + // created with rather than failing an edit that is otherwise sound. } fs.renameSync(tmp, target); - return { changed: true }; + return { changed: true, ...(narrow ? { narrowed: true } : {}) }; } /** diff --git a/worker/src/exit-code.mjs b/worker/src/exit-code.mjs index ab9a5bd9..dd927b4b 100644 --- a/worker/src/exit-code.mjs +++ b/worker/src/exit-code.mjs @@ -86,3 +86,18 @@ export function decideWait(exitCode) { return { verdict: "hold", fault: true }; // EXIT_INFRA and every unrecognised code } } + +/** + * A process-level printer for a promise nobody handled (PR #475's review, round 2): Node prints such a rejection's + * reason WHOLE, and a Valkey client's error may carry what it sent (connection.mjs scrubs that at the source; this is + * the second line of defence for anything else). It prints the message alone and exits 1, which is what Node's own + * default does with an unhandled rejection: infra, restarted by the service manager, never a silent carry-on. Installed + * by the worker's CLI (every `pi-dispatch` verb, the worker among them) and the receiver's two entry points. + */ +export function installRejectionPrinter({ proc = process, write = (line) => process.stderr.write(line) } = {}) { + proc.on("unhandledRejection", (reason) => { + const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "a non-Error value"; + write(`error: an unhandled rejection: ${message}\n`); + proc.exit(EXIT_INFRA); + }); +} diff --git a/worker/src/index.mjs b/worker/src/index.mjs index 77630634..9b6859fd 100644 --- a/worker/src/index.mjs +++ b/worker/src/index.mjs @@ -1,5 +1,5 @@ import { DelayedError, UnrecoverableError, Worker } from "bullmq"; -import { assertJudgedConnection } from "./connection.mjs"; +import { assertJudgedConnection, onValkeyError } from "./connection.mjs"; import { jobContainerName } from "./backend-local.mjs"; import { scrubCredentials } from "./redact.mjs"; import { BACKEND_NOT_REGISTERED } from "./backend-registry.mjs"; @@ -927,6 +927,9 @@ export function createWorker({ connection, name, stopContainer, containerName, h ...(name ? { name } : {}), ...(limiter ? { limiter } : {}), }); + // Issue #468: an error of this worker is one line, its message, never BullMQ's console.error of the whole object + // (which carried a failed AUTH's password in `command.args` before connection.mjs scrubbed it). + onValkeyError(worker, `worker ${queueName}`); workers.push(worker); } diff --git a/worker/src/init.mjs b/worker/src/init.mjs index 994d44b5..08bfc011 100644 --- a/worker/src/init.mjs +++ b/worker/src/init.mjs @@ -12,6 +12,8 @@ import { fileURLToPath } from "node:url"; import { join } from "node:path"; import { parseBackendList, venuesOf } from "./backends.mjs"; import { deploymentVenueEnv } from "./deployment-venue.mjs"; +import { setEnvKeyIfEmpty } from "./env-file.mjs"; +import { VALKEY_PASSWORD_KEY, newValkeyPassword } from "./valkey-auth.mjs"; const EMPTY_TRIGGERS = `${JSON.stringify({ triggers: [] }, null, 2)}\n`; /** @@ -70,7 +72,7 @@ registry.npmjs.org * shell it runs in. */ export function runInit(cwd = process.cwd(), deps = {}) { - const { fs = { existsSync, copyFileSync, readFileSync, writeFileSync }, out = (s) => process.stdout.write(s), env = {}, platform = process.platform } = deps; + const { fs = { existsSync, copyFileSync, readFileSync, writeFileSync }, out = (s) => process.stdout.write(s), env = {}, platform = process.platform, newPassword = newValkeyPassword } = deps; const results = []; // .env from the example. Prefer the copy in cwd (the clone's repo root); fall back to the copy @@ -85,8 +87,12 @@ export function runInit(cwd = process.cwd(), deps = {}) { const source = fs.existsSync(cwdExample) ? cwdExample : fileURLToPath(new URL("../.env.example", import.meta.url)); - fs.copyFileSync(source, envPath); - results.push(["created", ".env", "from .env.example — set your provider key next"]); + // Issue #468: the new file carries this deployment's own Valkey password (the example's `# VALKEY_PASSWORD=` line + // filled in, never shown) and is created readable by this account alone: it holds that password and, soon, the + // provider key. `wx`: a file that appeared since the check above is never overwritten (init's contract). + const text = setEnvKeyIfEmpty(String(fs.readFileSync(source, "utf8")), VALKEY_PASSWORD_KEY, newPassword(), { platform }); + fs.writeFileSync(envPath, text, { mode: 0o600, flag: "wx" }); + results.push(["created", ".env", `from .env.example, mode 0600, with a generated ${VALKEY_PASSWORD_KEY} (value not shown): set your provider key next`]); } scaffold(fs, results, join(cwd, "triggers.json"), EMPTY_TRIGGERS, "empty triggers list"); @@ -143,7 +149,8 @@ export function nextSteps(venues = { localUsed: true, podmanUsed: false }, { pla Next: 1. docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest # the prebuilt job image (or build image/Dockerfile) - 2. docker compose -f deploy/docker-compose.yml up -d # the durable queue (Valkey) + 2. docker compose --env-file .env -f deploy/docker-compose.yml up -d + # the durable queue (Valkey, with the password init wrote) 3. edit .env # set ANTHROPIC_API_KEY (or your provider's key) 4. pi-dispatch doctor # verify Docker, Valkey, image, and key 5. pi-dispatch worker # drain the queue diff --git a/worker/src/podman-stack.mjs b/worker/src/podman-stack.mjs index 89f7ddc2..6126217c 100644 --- a/worker/src/podman-stack.mjs +++ b/worker/src/podman-stack.mjs @@ -26,6 +26,7 @@ import { execFileSync } from "node:child_process"; import { connect as netConnect } from "node:net"; import { dirname, join } from "node:path"; import { DEFAULT_EGRESS_PROXY, egressProxyName } from "./egress.mjs"; +import { VALKEY_PASSWORD_KEY, valkeyEnvFileText } from "./valkey-auth.mjs"; import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, envFileSystemdHazard, envFileValueLines, quotedRegions, readEnvAssignments } from "./env-file.mjs"; 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"; @@ -105,6 +106,19 @@ export function proxyConfCopyPath(home) { return join(home, ".config", "pi-dispatch", "egress-proxy.conf"); } +/** + * The file the Quadlet Valkey reads its password from (issue #468): `EnvironmentFile=%h/.config/pi-dispatch/valkey.env` + * in the shipped unit, so the template needs no rewrite, and systemd expands `%h` to this account's home in both lines + * Quadlet generates from it (measured on Podman 5.8.1 and 4.9.3). Mode 0600: it holds the password. Written, compared + * and restarted-for like the proxy's rules copy, never printed (`service render` names it only). + */ +export function valkeyEnvPath(home) { + return join(home, ".config", "pi-dispatch", "valkey.env"); +} + +/** The mode of `valkeyEnvPath`: this account alone may read it. */ +export const VALKEY_ENV_MODE = 0o600; + /** * Where the user's Quadlet files live. ALWAYS `~/.config/containers/systemd`, deliberately not `$XDG_CONFIG_HOME` * from this shell: the generator runs in the user MANAGER's environment, which usually has no XDG_CONFIG_HOME at @@ -638,7 +652,7 @@ export function probeTcpAddress(host, port, timeoutMs = 1500) { * what Podman 4.9 turns into a dead route out. On every Podman version, since on 5.x it is one idle * container (measured harmless on 5.8.1), and a rule with no version read in it cannot misread one. */ -export function stackComponents({ venues, env, includeValkey, armed, valkeyPort = DEFAULT_VALKEY_PORT }) { +export function stackComponents({ venues, env, includeValkey, armed, valkeyPort = DEFAULT_VALKEY_PORT, valkeyPassword = null }) { const notes = []; const valkey = !venues.localUsed && includeValkey; const keeper = armed === true; @@ -648,7 +662,8 @@ export function stackComponents({ venues, env, includeValkey, armed, valkeyPort if (name === DEFAULT_EGRESS_PROXY) proxy = true; 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`); } - return { valkey, proxy, keeper, notes, valkeyPort }; + // Issue #468: the password the Quadlet Valkey starts with (null for none, which starts it without one, as before). + return { valkey, proxy, keeper, notes, valkeyPort, valkeyPassword: valkey ? valkeyPassword : null }; } /** @@ -659,7 +674,7 @@ export function stackComponents({ venues, env, includeValkey, armed, valkeyPort * `state` is "new", "same" or "changed" against what is on disk, and `actions` is exactly what `applyStack` does, * in order: the caller shows these lines, and a test holds the two equal. */ -export function planStack({ components, templatesDir, deployDir, home, fs, readTemplate = (name) => fs.readFileSync(join(templatesDir, name), "utf8"), restartUnits = [] }) { +export function planStack({ components, templatesDir, deployDir, home, fs, readTemplate = (name) => fs.readFileSync(join(templatesDir, name), "utf8"), restartUnits = [], restartAfterValkey = [] }) { const dir = quadletDir(home); const picked = []; if (components.valkey) picked.push(QUADLET_FILES.valkeyNetwork, QUADLET_FILES.valkey); @@ -717,6 +732,27 @@ export function planStack({ components, templatesDir, deployDir, home, fs, readT } 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" }); } + if (components.valkey) { + // Issue #468: the Valkey's password file, placed before its unit so it exists when the unit starts (its + // EnvironmentFile= is not optional). Valkey reads the password only at start, so a changed file restarts it, as a + // changed unit file does; the volume, and the queue in it, is kept across that restart. A file readable by anyone + // else counts as changed, so the write puts its mode back to 0600. + const path = valkeyEnvPath(home); + const text = valkeyEnvFileText(components.valkeyPassword); + let state = "new"; + if (fs.existsSync(path)) { + let current = null; + let wide = false; + try { + current = String(fs.readFileSync(path, "utf8")); + wide = typeof fs.statSync === "function" && (fs.statSync(path).mode & 0o077) !== 0; + } catch { + // Unreadable reads as changed, as for the unit files. + } + state = current === text && !wide ? "same" : "changed"; + } + 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 }); + } const containers = files.filter((f) => f.path.endsWith(".container")); const start = containers.map((f) => f.unit); // A unit whose file changed is RESTARTED, not started: `start` on an active unit is a no-op, so a replaced file @@ -743,7 +779,27 @@ export function planStack({ components, templatesDir, deployDir, home, fs, readT if (fresh.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "start", ...fresh] }); if (restart.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "restart", ...restart] }); } - return { dir, files, start, restart, actions }; + // Issue #468: a Valkey whose password this plan changes (a first password for a deployment that had none, most often) + // is one every running client of it can no longer talk to, since the worker and the receiver read VALKEY_PASSWORD at + // start. `restartAfterValkey` names those the caller found running; they are restarted after it (`try-restart`: one + // that stopped meanwhile stays stopped). A worker restart lets its in-flight job finish first (its SIGTERM drain). + const secret = files.find((f) => f.kind === "secret"); + const clients = secret && secret.state !== "same" ? restartAfterValkey : []; + if (clients.length > 0) actions.push({ kind: "run", argv: ["systemctl", "--user", "try-restart", ...clients] }); + return { dir, files, start, restart, actions, clientsRestarted: clients }; +} + +/** + * What a plan that sets Valkey's password costs, said wherever one does (issue #468): the Valkey restart, and the running + * clients restarted after it. Null when the plan leaves the password file as it is. + */ +export function valkeyPasswordRestartWarning(plan) { + const secret = plan.files?.find((f) => f.kind === "secret"); + if (!secret || secret.state === "same") return null; + const valkeyMoves = (plan.restart ?? []).includes(QUADLET_FILES.valkey.unit); + if (!valkeyMoves) return null; + const clients = plan.clientsRestarted ?? []; + 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)`; } /** The measured cost of restarting the proxy, said wherever a plan restarts it. */ @@ -1035,12 +1091,13 @@ export function readServiceKeys(content, keys, { loader = "systemd", path = ".en } /** - * VALKEY_URL and PI_VALKEY_SHARED as a deployment's `.env` assigns them (issue #464), through `readServiceKeys`: - * `service install`, `up`, the worker and every client judge the Valkey the SERVICE will use, so they read these where - * the service does, and a line they cannot read is refused, naming what in it is the problem. + * VALKEY_URL, PI_VALKEY_SHARED and VALKEY_PASSWORD as a deployment's `.env` assigns them (issues #464 and #468), through + * `readServiceKeys`: `service install`, `up`, the worker and every client judge the Valkey the SERVICE will use and + * send the password the service will send, so they read these where the service does, and a line they cannot read is + * refused, naming what in it is the problem. */ export function readValkeyKeys(content, { loader = "systemd", path = ".env" } = {}) { - const read = readServiceKeys(content, ["VALKEY_URL", VALKEY_SHARED_KEY], { loader, path }); + const read = readServiceKeys(content, ["VALKEY_URL", VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY], { loader, path }); return read.error ? { error: `${read.error}, so which Valkey the worker uses is unknown` } : read; } @@ -1086,7 +1143,7 @@ export async function applyStack(plan, { fs, run, journal = null, beforeRuns = n if (action.kind === "write") { try { fs.mkdirSync(dirname(action.path), { recursive: true }); - journalWrite(fs, journal, action.path, byPath.get(action.path).text); + journalWrite(fs, journal, action.path, byPath.get(action.path).text, { mode: byPath.get(action.path).mode }); } catch (err) { return { ok: false, failed: describeAction(action), code: null, message: err?.message, ran }; } @@ -1108,9 +1165,20 @@ export async function applyStack(plan, { fs, run, journal = null, beforeRuns = n * Write `text` to `path`, first recording in `journal` (when given) what was there: `{ path, existed, previous }`. * Recorded before the write so a write that fails part way is put back too. */ -export function journalWrite(fs, journal, path, text) { +export function journalWrite(fs, journal, path, text, { mode } = {}) { + // Issue #468: a file with a `mode` (the Valkey's password file) is created with it, and an existing one is narrowed + // to it BEFORE the new text goes in, so the password is never in a file another account may read. + const write = () => { + if (mode === undefined) { + fs.writeFileSync(path, text); + return; + } + if (fs.existsSync(path)) fs.chmodSync(path, mode); + fs.writeFileSync(path, text, { mode }); + fs.chmodSync(path, mode); + }; if (!journal) { - fs.writeFileSync(path, text); + write(); return; } let existed = false; @@ -1122,7 +1190,7 @@ export function journalWrite(fs, journal, path, text) { const entry = { path, existed, previous }; journal.push(entry); try { - fs.writeFileSync(path, text); + write(); } catch (err) { // A write refused before it touched the file (EACCES, EROFS at open) left nothing to put back, and a rollback that // then tried to write the same path would fail the same way and report a file this run never changed. Dropped diff --git a/worker/src/processor.mjs b/worker/src/processor.mjs index 89144948..ae2462d8 100644 --- a/worker/src/processor.mjs +++ b/worker/src/processor.mjs @@ -117,7 +117,7 @@ function runtimeUnavailable(venue, gate) { */ function egressProxyFix(venue) { if (venue === PODMAN_BACKEND) return "Start it under the worker account's own rootless podman, on a named bridge network (docs/podman.md)"; - return "Start it with `docker compose -f deploy/docker-compose.yml --profile egress up -d`"; + return "Start it with `docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d`"; } export async function runJob(job, deps) { diff --git a/worker/src/queue.mjs b/worker/src/queue.mjs index 1fb9b78d..698d33f7 100644 --- a/worker/src/queue.mjs +++ b/worker/src/queue.mjs @@ -1,5 +1,5 @@ import { Queue } from "bullmq"; -import { assertJudgedConnection } from "./connection.mjs"; +import { assertJudgedConnection, onValkeyError } from "./connection.mjs"; import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId } from "./job-id.mjs"; import { targetSeparator } from "./forges.mjs"; import { PR_CLOSE_ACTIONS } from "./triggers.mjs"; @@ -71,7 +71,10 @@ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeli export function makeQueue(connection, { name = QUEUE } = {}) { // Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials. assertJudgedConnection(connection); - return new Queue(name, { connection }); + const queue = new Queue(name, { connection }); + // Issue #468: an error of this queue is one line, its message, never BullMQ's console.error of the whole object. + onValkeyError(queue, `queue ${name}`); + return queue; } /** diff --git a/worker/src/sandbox.mjs b/worker/src/sandbox.mjs index 20b79e96..3f2261ff 100644 --- a/worker/src/sandbox.mjs +++ b/worker/src/sandbox.mjs @@ -1161,7 +1161,7 @@ export async function openSandbox({ } // Where the proxy comes from differs by venue: the compose file is docker-only, and on podman the proxy is // started by hand under this account's podman (docs/podman.md). - const start = bin === "podman" ? "it runs under this account's rootless podman, started as docs/podman.md shows" : "`docker compose -f deploy/docker-compose.yml --profile egress up -d`"; + const start = bin === "podman" ? "it runs under this account's rootless podman, started as docs/podman.md shows" : "`docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d`"; return { refused: "egress-network-failed", message: `could not create the egress network ${network} -- is the proxy running? ${start}. The egress setting is read from this process's environment (PI_EGRESS, PI_EGRESS_PROXY); a deployment that sets them only in its .env must export them where you run this.`, diff --git a/worker/src/service.mjs b/worker/src/service.mjs index 7e172854..19de80a0 100644 --- a/worker/src/service.mjs +++ b/worker/src/service.mjs @@ -41,7 +41,7 @@ * the rest of the config is broken. */ import { spawn as nodeSpawn } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { lookup as dnsLookup } from "node:dns/promises"; import { connect as netConnect } from "node:net"; import { homedir, networkInterfaces, tmpdir, userInfo } from "node:os"; @@ -51,7 +51,9 @@ import { parseArgs } from "node:util"; import { parseBackendList, venuesOf } from "./backends.mjs"; import { sharedShellIgnored } from "./deployment-venue.mjs"; import { egressArmed, egressProxyName } from "./egress.mjs"; -import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, workerUnitDeps } from "./podman-stack.mjs"; +import { updateEnvFile } from "./env-file.mjs"; +import { VALKEY_HEALTH_SCRIPT, VALKEY_PASSWORD_KEY, VALKEY_START_SCRIPT, dollarsDoubled, newValkeyPassword, valkeyPasswordDecision } from "./valkey-auth.mjs"; +import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs"; // src/ is where this module lives in BOTH layouts (worker/src in a checkout, // node_modules/@edgehero/pi-dispatch/src under npm). Deploy templates resolve one level up from it @@ -82,7 +84,7 @@ function resolveReceiverStart() { */ // The two worker-template literals a podman render rewrites (issue #430), spelled once for the pin table and the render. const WORKER_DESCRIPTION = "Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker)"; -const WORKER_VALKEY_COMMENT = "# Valkey must be reachable (docker compose -f deploy/docker-compose.yml up -d), but it is a separate\n# unit/container -- not ordered here since it may be remote.\n"; +const WORKER_VALKEY_COMMENT = "# Valkey must be reachable (docker compose --env-file .env -f deploy/docker-compose.yml up -d), but it is a separate\n# unit/container -- not ordered here since it may be remote.\n"; export const TEMPLATE_PINS = { "worker.service": [ @@ -93,7 +95,7 @@ export const TEMPLATE_PINS = { "WantedBy=multi-user.target", // → default.target in user scope (multi-user.target never runs there) "\nWants=network-online.target\n", // the anchor the podman venue's Wants=/After= on its Quadlet units go after (issue #430), user scope only "Description=pi-dispatch worker (drains the job queue on the host; launches job containers via docker)", // → names rootless podman on a podman deployment (issue #430) - "# Valkey must be reachable (docker compose -f deploy/docker-compose.yml up -d), but it is a separate\n# unit/container -- not ordered here since it may be remote.\n", // → says it IS ordered, when the Quadlet Valkey is installed + "# Valkey must be reachable (docker compose --env-file .env -f deploy/docker-compose.yml up -d), but it is a separate\n# unit/container -- not ordered here since it may be remote.\n", // → says it IS ordered, when the Quadlet Valkey is installed // Byte-for-byte survivors — semantics the render must not lose: "RestartPreventExitStatus=2", // EXIT_POLICY is never restarted (a retry loop is a bill) "StartLimitIntervalSec=60", @@ -155,7 +157,12 @@ export const TEMPLATE_PINS = { "Network=pi-dispatch-valkey.network", "PublishPort=127.0.0.1:6379:6379", "Volume=pi-dispatch-valkey-data:/data", - "Exec=valkey-server --appendonly yes", + // Issue #468: the password from a 0600 file into the container's environment, then to valkey-server as + // configuration on stdin, never on an argv (tini keeps its argv as PID 1, readable by every account). The two lines + // are pinned to the ONE copy of each script in valkey-auth.mjs, `$` written `$$` for systemd. + "EnvironmentFile=%h/.config/pi-dispatch/valkey.env", + `Exec=sh -c '${dollarsDoubled(VALKEY_START_SCRIPT)}'`, + `HealthCmd=${dollarsDoubled(VALKEY_HEALTH_SCRIPT)}`, "WantedBy=default.target", ], "pi-dispatch-egress-out.network": ["NetworkName=pi-dispatch-egress-out"], @@ -336,7 +343,7 @@ export async function runService(argv = [], deps = {}) { // this command. An operator changes it by running the command as someone else, not by declaring it. user = env.USER || userInfo().username, tmp = tmpdir(), - fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync }, + fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, chmodSync, statSync, renameSync, realpathSync }, spawn = nodeSpawn, out = (s) => process.stdout.write(s), err = (s) => process.stderr.write(s), @@ -354,6 +361,8 @@ export async function runService(argv = [], deps = {}) { runSync = undefined, // PR #463: how a path resolves through symlinks, for the manager-environment rule (a symlinked home). realpath = (p) => realpathSync(p), + // Issue #468: how a new Valkey password is made (a test's seam; 32 random bytes, hex, never shown). + newPassword = newValkeyPassword, } = deps; let values, positionals; @@ -421,6 +430,7 @@ export async function runService(argv = [], deps = {}) { interfaces, runSync, realpath, + newPassword, which: values.receiver ? "receiver" : "worker", scope: platform === "linux" && values.system ? "system" : "user", force: values.force, @@ -848,8 +858,22 @@ async function podmanStackFor(ctx, venue, { readKeeper = false } = {}) { envPath: join(ctx.deployDir, ".env"), }); if (valkey.error) return { error: valkey.error }; - const components = stackComponents({ venues: venue.venues, env: venue.env, includeValkey: valkey.include, armed, valkeyPort: valkey.port }); - components.notes.push(...valkey.notes); + // Issue #468: the password the Quadlet Valkey starts with, when this install includes it: the deployment's own + // VALKEY_PASSWORD, or a new one this install writes into .env (never over a value), unless the Valkey is shared or + // the operator's own (`valkeyPasswordDecision`). Generated here, written by doInstall once nothing refuses; `render` + // writes nothing and shows the password file by its name alone. + let valkeyPassword = null; + let pendingPassword = null; + const passwordNotes = []; + if (valkey.include) { + const decided = valkeyPasswordDecision(venue.env, { envPath: join(ctx.deployDir, ".env") }); + if (decided.error) return { error: decided.error }; + if (decided.note) passwordNotes.push(decided.note); + if (decided.generate) pendingPassword = ctx.newPassword(); + valkeyPassword = decided.password ?? pendingPassword; + } + const components = stackComponents({ venues: venue.venues, env: venue.env, includeValkey: valkey.include, armed, valkeyPort: valkey.port, valkeyPassword }); + components.notes.push(...valkey.notes, ...passwordNotes); // Gate round 2: the service reads PI_VALKEY_SHARED from .env alone, so a shell export says nothing; named, not honoured. if (typeof ctx.env[VALKEY_SHARED_KEY] === "string") components.notes.push(sharedShellIgnored(ctx.env[VALKEY_SHARED_KEY], join(ctx.deployDir, ".env"))); // The keeper, read as `up` reads it (PR #463 final review), install only (render spawns nothing): a Quadlet keeper @@ -861,9 +885,22 @@ async function podmanStackFor(ctx, venue, { readKeeper = false } = {}) { const keeper = judgeNetnsKeeper(await runQuery(ctx, "podman", ["inspect", NETNS_KEEPER_FORMAT, NETNS_KEEPER])); if (!keeper.holds) restartUnits = [QUADLET_FILES.keeper.unit]; } - const plan = planStack({ components, templatesDir: ctx.templatesDir, deployDir: ctx.deployDir, home: ctx.home, fs: ctx.fs, restartUnits }); + let plan = planStack({ components, templatesDir: ctx.templatesDir, deployDir: ctx.deployDir, home: ctx.home, fs: ctx.fs, restartUnits }); if (plan.error) return { error: plan.error }; - return { components, plan, notes: components.notes, venues: venue.venues, proxy: egressProxyName(venue.env), valkeyRefusal: valkey.refusal }; + // Issue #468: a Valkey that already ran and now gets a new password (an upgrade from a deployment that had none) is one + // its running clients can no longer talk to, so the worker and the receiver units this account runs are restarted + // after it. Asked only then (install only; render spawns nothing), and only of units the manager says are active. + const secret = plan.files.find((f) => f.kind === "secret"); + const valkeyFile = plan.files.find((f) => f.unit === QUADLET_FILES.valkey.unit && f.path.endsWith(".container")); + if (readKeeper && secret && secret.state !== "same" && valkeyFile && valkeyFile.state !== "new") { + const active = []; + for (const unit of ["pi-dispatch-worker.service", "pi-dispatch-receiver.service"]) { + const res = await runQuery(ctx, "systemctl", ["--user", "is-active", unit]); + if (res.code === 0 && String(res.stdout ?? "").trim() === "active") active.push(unit); + } + if (active.length > 0) plan = planStack({ components, templatesDir: ctx.templatesDir, deployDir: ctx.deployDir, home: ctx.home, fs: ctx.fs, restartUnits, restartAfterValkey: active }); + } + return { components, plan, notes: components.notes, venues: venue.venues, proxy: egressProxyName(venue.env), valkeyRefusal: valkey.refusal, pendingPassword }; } async function doRender(ctx) { @@ -897,6 +934,8 @@ async function doRender(ctx) { // 4.7 KB of squid configuration under a "Quadlet" heading read as a unit file. for (const f of stack?.plan.files ?? []) { if (f.kind === "conf") ctx.out(`\n# → ${f.path} (the egress proxy's rules: install copies the package's egress-proxy.conf here, unchanged)\n`); + // Issue #468: the password file is NAMED, never printed: render output lands in scrollbacks and bug reports. + else if (f.kind === "secret") ctx.out(`\n# → ${f.path} (mode 0600: ${VALKEY_PASSWORD_KEY} for the Quadlet Valkey, ${f.text.includes(`${VALKEY_PASSWORD_KEY}=\n`) ? "empty, so it starts without a password" : "from .env or generated into it at install; the value is not shown"})\n`); else ctx.out(`\n# → ${f.path} (Quadlet, podman venue)\n${f.text}`); } for (const note of stack?.notes ?? []) ctx.out(`# note: ${note}\n`); @@ -1061,6 +1100,14 @@ async function installLinuxUser(ctx, paths, stack = null, foreign = []) { } }; const writtenList = () => [...new Set(journal.map((e) => e.path))].join(", ") || "none"; + if (stack?.pendingPassword) { + // Issue #468: the new Valkey password into .env first, journalled like every other file of this run, so a write + // that fails later puts .env back too. Never over a value (the writer's never-clobber), the file narrowed to this + // account alone, the value never shown. + const wrote = writeValkeyPassword(ctx, stack.pendingPassword, journal); + if (wrote.error) return fail(ctx.err, `${wrote.error}, so nothing was installed: ${describeRollBack(rollBackWrites(ctx.fs, journal))}`); + ctx.out(`generated ${VALKEY_PASSWORD_KEY} into ${wrote.path} (32 random bytes, hex; the value is not shown; the file is now readable by this account only)\n`); + } if (stack) { for (const note of stack.notes) ctx.out(`note: ${note}\n`); for (const f of foreign) { @@ -1068,6 +1115,8 @@ async function installLinuxUser(ctx, paths, stack = null, foreign = []) { } const restartWarning = proxyRestartWarning(stack.plan); if (restartWarning) ctx.out(`⚠ ${restartWarning}\n`); + const passwordWarning = valkeyPasswordRestartWarning(stack.plan); + if (passwordWarning) ctx.out(`⚠ ${passwordWarning}\n`); if (stack.plan.actions.length > 0) { // Shown, then done: the same lines `up` asks consent for, carried out by the same function. ctx.out(`podman venue (PI_BACKENDS in .env): its stack, as Quadlet units in ${stack.plan.dir}:\n`); @@ -1110,9 +1159,13 @@ async function installLinuxUser(ctx, paths, stack = null, foreign = []) { if (started.length > 0) ctx.out(`started ${started.join(" ")} (Quadlet units: never enabled, the generator reads their own [Install] section; a unit already running is left as it is)\n`); // Named by the file that changed (round 3 nit): the proxy also restarts for its rules copy alone. for (const unit of restarted) { - const changedFiles = stack.plan.files.filter((f) => f.state === "changed" && f.restarts === unit).map((f) => f.path); - ctx.out(`restarted ${unit}, because this install replaced ${changedFiles.join(" and ")}\n`); + // Issue #468: a file this install wrote NEW for a unit it restarts (the Valkey's first password file) is named too. + const changedFiles = stack.plan.files.filter((f) => f.state !== "same" && f.restarts === unit).map((f) => f.path); + // A unit restarted with its files unchanged (a keeper that did not hold, the proxy after it) says so rather than + // ending on "replaced " with nothing after it, as it did. + ctx.out(`restarted ${unit}, ${changedFiles.length > 0 ? `because this install replaced ${changedFiles.join(" and ")}` : "as the plan above says (its files are unchanged)"}\n`); } + if ((stack.plan.clientsRestarted ?? []).length > 0) ctx.out(`restarted ${stack.plan.clientsRestarted.join(" ")} after it, so they send the new ${VALKEY_PASSWORD_KEY}\n`); if (stack.plan.start.length > 0) ctx.out(`${paths.name} Wants= and is After= ${stack.plan.start.join(" ")}\n`); // PR #463 round 3: a keeper started or restarted beside a proxy this install does not own (PI_EGRESS_PROXY naming // the operator's own) leaves that proxy up since before the keeper; said, with its name. @@ -1130,6 +1183,29 @@ async function installLinuxUser(ctx, paths, stack = null, foreign = []) { return 0; } +/** + * Write a new VALKEY_PASSWORD into the deployment's `.env` (issue #468) through the project's one `.env` writer, never + * over a value, the file narrowed to this account alone (`narrow`). Recorded in `journal` with the bytes that were there, + * so a later failure of the same install puts it back. `{ path }` or `{ error }`; the value is never in either. + */ +function writeValkeyPassword(ctx, password, journal) { + const envPath = join(ctx.deployDir, ".env"); + let previous; + try { + previous = ctx.fs.readFileSync(envPath); + } catch (err) { + return { error: `${VALKEY_PASSWORD_KEY} could not be written into ${envPath}: it could not be read (${err?.message ?? err})` }; + } + try { + const res = updateEnvFile(envPath, VALKEY_PASSWORD_KEY, password, { fs: ctx.fs, platform: ctx.platform, narrow: true }); + if (!res.changed) return { error: `${VALKEY_PASSWORD_KEY} could not be written into ${envPath}: the file already assigns it (set while this install ran?). Re-run the install, which then uses that value` }; + } catch (err) { + return { error: `${VALKEY_PASSWORD_KEY} could not be written into ${envPath}: ${err?.message ?? err}` }; + } + journal.push({ path: envPath, existed: true, previous }); + return { path: envPath }; +} + async function installLinuxSystem(ctx, paths) { if (ctx.fs.existsSync(paths.installPath) && !ctx.force) { return fail(ctx.err, `${paths.installPath} already exists — pass --force to re-stage the render (the printed sudo commands would overwrite it)`); @@ -1263,9 +1339,12 @@ async function removeQuadlets(ctx, quadlets) { } } for (const q of quadlets) ctx.fs.unlinkSync(join(quadletDir(ctx.home), q.file)); - // The account-owned copy of the proxy's rules (E1) goes with its unit. + // The account-owned copy of the proxy's rules (E1) goes with its unit, and the Valkey's password file with its unit + // (issue #468; the password itself stays in the deployment's .env, where a re-install finds it). const conf = proxyConfCopyPath(ctx.home); if (ctx.fs.existsSync(conf)) ctx.fs.unlinkSync(conf); + const valkeyEnv = valkeyEnvPath(ctx.home); + if (quadlets.some((q) => q.file === QUADLET_FILES.valkey.file) && ctx.fs.existsSync(valkeyEnv)) ctx.fs.unlinkSync(valkeyEnv); const reload = await run(ctx, "systemctl", ["--user", "daemon-reload"]); if (reload !== 0) { return fail(ctx.err, `the Quadlet files are removed and their units stopped, but systemctl --user daemon-reload failed (${reload === null ? "systemctl not found" : `exit ${reload}`}): run it yourself so the manager forgets them`); @@ -1423,8 +1502,9 @@ async function doRestart(ctx, values) { // of the config (forge auth …) is broken, and failFast keeps a down Valkey an error in seconds // instead of a hung restart. Lazy imports for the same reason cli.mjs uses them: `service` // subcommands that never touch the queue must not load bullmq/ioredis. - const url = ctx.env.VALKEY_URL ?? "redis://127.0.0.1:6379"; - const { parseConnection } = await import("./connection.mjs"); + // This shell's VALKEY_URL, else the deployment .env's (PR #475's review), as every CLI verb reads it. + const { cliValkeyUrl, parseConnection } = await import("./connection.mjs"); + const url = cliValkeyUrl(ctx.env, { cwd: ctx.deployDir, warn: (line) => ctx.err(line) }); const { makeQueue } = await import("./queue.mjs"); queue = makeQueue(parseConnection(url, { failFast: true })); } diff --git a/worker/src/start.mjs b/worker/src/start.mjs index d4da5d2a..ea17f866 100644 --- a/worker/src/start.mjs +++ b/worker/src/start.mjs @@ -5,7 +5,7 @@ import { dirname, basename, join } from "node:path"; import { configError, ensureJobsDir, ensureSandboxDir, ensureUnderAccountRoot, loadConfig } from "./config.mjs"; import { passwdNameFrom, probeTcpAddress, readSubuidRanges, resolveWorkerValkey } from "./podman-stack.mjs"; import { valkeyClientContext } from "./valkey-endpoint.mjs"; -import { makeRedisClient, parseConnection } from "./connection.mjs"; +import { authRefusalFor, makeRedisClient, onValkeyError, parseConnection, valkeyAuthState, valkeyPasswordFor } from "./connection.mjs"; import { reconcileGated, reloadSchedules } from "./cron.mjs"; import { makeGitHubAuth } from "./get-token.mjs"; import { InfraRetry, NETNS_KEEPER_NOT_HOLDING } from "./processor.mjs"; @@ -443,6 +443,8 @@ export async function startWorker( // Every client below connects through connection.mjs' JudgedConnector, which judges the (pinned) address again on // each connect, as this boot judged it: root refused as the boot decided it, PI_VALKEY_SHARED from the deployment .env. const valkeyContext = workerValkeyContext(valkey, env); + // Issue #468: whether this worker sends a password, and never the password: the only thing a log line may say of it. + log("valkey_password", { set: Boolean(valkeyPasswordFor(valkey.url, valkeyContext).password) }); const valkeyConn = (opts = {}) => parseConnection(valkey.url, { ...opts, servername: valkey.servername, context: valkeyContext }); // DES-CRON-VIA-BULLMQ-SCHEDULER: load and validate the triggers file with the operator present and @@ -887,6 +889,9 @@ export async function startWorker( // One raw Redis client, shared by the budget (via the worker) and the scheduler stall guard, so it is // hoisted out of the createWorkerFn arg object. const redis = makeRedisClient(valkey.url, { servername: valkey.servername, context: valkeyContext }); + // Its errors as one message-only line (PR #475's review, round 2), like every Queue and Worker's: without a listener + // ioredis printed a stack per reconnect attempt. + onValkeyError(redis, "shared client"); // This host's own stale scope claims, gated on the reaper having having enumerated: the // reaper is what establishes that this machine holds no `pi-job-*` containers, so a claim naming this @@ -2038,7 +2043,7 @@ async function defaultJudgeValkey({ url, venues, env }) { } catch { // A uid with no passwd entry: its subordinate ranges are read by uid. } - return resolveWorkerValkey({ + const valkey = await resolveWorkerValkey({ url, venues, platform: process.platform, @@ -2055,4 +2060,19 @@ async function defaultJudgeValkey({ url, venues, env }) { subuids: readSubuidRanges({ user, euid, fs }), configError, }); + await refuseValkeyAuth(valkey, env); + return valkey; +} + +/** + * Issue #468: the worker's credential asked once at boot, on the judged address, before anything is built on it. A + * Valkey that requires a password this worker does not send (NOAUTH), or refuses the one it sends (WRONGPASS), is a + * configError (exit 2, not restarted into the same answer), naming VALKEY_PASSWORD and never its value: left to the + * clients, it was an endless stream of NOAUTH errors from a worker that looked alive. Nothing answering is left to the + * clients' own retries, as before. Exported for its test; `authState` is the seam. + */ +export async function refuseValkeyAuth(valkey, env, { cwd = process.cwd(), authState = valkeyAuthState } = {}) { + const context = workerValkeyContext(valkey, env, { cwd }); + const { state } = await authState(valkey.url, { context, servername: valkey.servername ?? null }); + if (state === "noauth" || state === "wrongpass") throw configError(authRefusalFor(state, valkey.url, context)); } diff --git a/worker/src/up.mjs b/worker/src/up.mjs index 3d2786d4..edabdd3c 100644 --- a/worker/src/up.mjs +++ b/worker/src/up.mjs @@ -41,10 +41,11 @@ import { PODMAN_BOOT_REFUSING_CAUSES, makePodmanInfoReader, decidePodmanJobUser, import { venuesOf } from "./backends.mjs"; import { logsDirPath, settingsFilePath } from "./config.mjs"; import { DEFAULT_EGRESS_PROXY, egressArmed as egressArmedFn, egressProxyName } from "./egress.mjs"; -import { envKeyIsBlank, envValueShown, updateEnvFile } from "./env-file.mjs"; +import { envKeyIsBlank, envValueShown, readEnvAssignments, updateEnvFile } from "./env-file.mjs"; +import { COMPOSE_VALKEY_OVERRIDE, OWNER_MARKER_KEY, VALKEY_PASSWORD_KEY, VALKEY_PORT_KEY, adoptVolumeQuestion, composeArgs, composeProjectName, foreignContainerSentence, foreignMarkerRefusal, foreignVolumeLabelRefusal, foreignVolumeRefusal, foreignVolumeUsers, isLoopbackHost, newValkeyPassword, unadoptedVolumeRefusal, valkeyContainerOwner, valkeyDockerRunArgs, valkeyPasswordDecision, valkeyPortEnvDecision, valkeyVolumeCreateArgs, valkeyVolumeOwner } from "./valkey-auth.mjs"; import { deploymentValkeyEnv, deploymentVenueEnv } from "./deployment-venue.mjs"; import { EGRESS_PROXY_IMAGE, PROXY_STATE_FORMAT, jobNetworksOf, parseProxyState, shippedProxyDrift } from "./egress-proxy-state.mjs"; -import { DEFAULT_VALKEY_PORT, NETNS_KEEPER, NETNS_KEEPER_FORMAT, QUADLET_FILES, STACK_KEYS, applyStack, decideValkey, describeRollBack, passwdNameFrom, readSubuidRanges, rollBackWrites, valkeySharedOn, VALKEY_SHARED_KEY, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, describeAction, foreignContainerRefusal, foreignContainers, lingerNote, planStack, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal } from "./podman-stack.mjs"; +import { DEFAULT_VALKEY_PORT, NETNS_KEEPER, NETNS_KEEPER_FORMAT, QUADLET_FILES, STACK_KEYS, applyStack, decideValkey, describeRollBack, passwdNameFrom, readSubuidRanges, rollBackWrites, valkeySharedOn, VALKEY_SHARED_KEY, readValkeyKeys, valkeyTarget, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, describeAction, foreignContainerRefusal, foreignContainers, lingerNote, planStack, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal } from "./podman-stack.mjs"; // The shipped Quadlet templates, module-relative like service.mjs's: worker/deploy in a checkout, /deploy under npm. const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "deploy"); @@ -63,32 +64,13 @@ const PODMAN_EXISTS_ARGS = ["image", "exists", LOCAL_IMAGE]; // deploy/docker-compose.yml's Valkey service, reproduced as one docker run: same image, AOF on // (REQ-QUEUE-BURST-NO-DROP), bound to localhost only (the queue is not a public surface), same -// healthcheck, restart unless-stopped, and a named volume standing in for the compose volume. -const VALKEY_VOLUME_ARGS = ["volume", "create", "pi-dispatch-valkey-data"]; -const VALKEY_RUN_ARGS = [ - "run", - "-d", - "--name", - "pi-dispatch-valkey", - "--restart", - "unless-stopped", - "-p", - "127.0.0.1:6379:6379", - "-v", - "pi-dispatch-valkey-data:/data", - "--health-cmd", - "valkey-cli ping", - "--health-interval", - "10s", - "--health-timeout", - "3s", - "--health-retries", - "5", - "valkey/valkey:8", - "valkey-server", - "--appendonly", - "yes", -]; +// healthcheck, restart unless-stopped, and a named volume standing in for the compose volume. Issue #468: its password +// travels as `-e VALKEY_PASSWORD` with the value in the docker CLI's own environment, never on an argv, and reaches +// valkey-server as configuration on stdin (valkey-auth.mjs holds the one copy of this argv; doctor's fix runs it too). +// The upgrade (issue #468): a pi-dispatch-valkey container that answers without a password is stopped (SIGTERM, so +// Valkey writes its AOF out), removed, and run again from the argv above. The volume, and the queue in it, is kept. +const VALKEY_STOP_ARGS = ["stop", "pi-dispatch-valkey"]; +const VALKEY_RM_ARGS = ["rm", "pi-dispatch-valkey"]; // deploy/docker-compose.yml's `egress` profile, reproduced as one docker run (REQ-EGRESS-ALLOWLIST): // same digest-pinned image, same two mounts, same explicit container name, same restart policy, on the @@ -153,6 +135,14 @@ export async function runUp(argv = [], deps = {}) { // only production caller, and the test that covered it attached the method to its own fake. fs = { existsSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync, unlinkSync }, probeTcp = defaultProbeTcp, + // Issue #468: whether the Valkey on 127.0.0.1:6379 answers a client that sends NO password ("ok" when it does), + // through the project's one connection function. Real only where the TCP probe is: a test that stands in for the + // host's listener answers this too, or gets "unknown". + probeValkeyAuth = deps.probeTcp === undefined ? (url) => defaultProbeValkeyAuth(url, { env, cwd: deps.cwd ?? process.cwd() }) : async () => "unknown", + // PR #475's review (the volume gap): record, then read back, the `pi-dispatch:owner` marker in a Valkey this pass + // started (connection.mjs' `claimValkeyOwner`). Real only where the TCP probe is, like the one above: a test that + // stands in for the host's listener answers this too, or gets "this folder". + claimValkeyOwner = deps.probeTcp === undefined ? (url, folder) => defaultClaimValkeyOwner(url, folder, { env, cwd: deps.cwd ?? process.cwd() }) : async (_url, folder) => ({ owner: folder, claimed: false }), // Issue #464: VALKEY_URL's host resolved as the worker's client resolves it (every address), and which addresses // are this host's, for the Valkey owner rule on the podman venue. lookup = (host, opts) => dnsLookup(host, opts), @@ -165,6 +155,9 @@ export async function runUp(argv = [], deps = {}) { // Injected so tests can assert the secret never reaches output without fishing it back out of // the written file. 32 bytes hex, matching doctor's `openssl rand -hex 32` fix line. randomHex = () => randomBytes(32).toString("hex"), + // Issue #468: the Valkey password, made the same way (valkey-auth.mjs), injected so a test can hold it never + // reaches the output. + newPassword = newValkeyPassword, // Injected for the path compare below, which has to fold case on Windows and must not on POSIX. platform = process.platform, // The two resolved defaults `up` pins, injected for the same reason the clock is elsewhere: they @@ -272,35 +265,6 @@ export async function runUp(argv = [], deps = {}) { } } - // (c) Valkey. A bare TCP probe of the default bind, not a redis PING: dependency-free, and the - // honest claim is only "something is listening". If our own compose-named container is up, docker - // can say so; if a listener exists that we cannot name, up must NOT offer a second Valkey — the - // port is taken, and `docker run` would only fail after consent. - if (await probeTcp("127.0.0.1", 6379)) { - const ps = await runCmdCapture(spawn, "docker", ["ps", "--filter", "name=pi-dispatch-valkey", "--format", "{{.Names}}"]); - const ours = ps.code === 0 && ps.output.split("\n").map((l) => l.trim()).includes("pi-dispatch-valkey"); - out(`✓ something is listening on 6379 — assuming your Valkey${ours ? " (it is the pi-dispatch-valkey container)" : ""}\n`); - summary.push(["valkey", ours ? "container pi-dispatch-valkey already running" : "port 6379 already has a listener — left alone"]); - } else { - const accepted = await consent( - "Nothing is listening on 127.0.0.1:6379. up would start Valkey (same semantics as deploy/docker-compose.yml):", - [`docker ${VALKEY_VOLUME_ARGS.join(" ")}`, `docker ${quoteArgs(VALKEY_RUN_ARGS)}`], - { yes, out, prompt }, - ); - if (!accepted) { - out("skipped — start it later with `docker compose -f deploy/docker-compose.yml up -d`\n"); - summary.push(["valkey", "skipped (declined) — the queue needs it before `pi-dispatch worker` can drain"]); - } else if (await runStreamed(spawn, "docker", VALKEY_VOLUME_ARGS, out) !== 0) { - out("✗ docker volume create failed — continuing; doctor below will re-check Valkey\n"); - summary.push(["valkey", "volume create FAILED — `docker compose -f deploy/docker-compose.yml up -d` is the fallback"]); - } else if (await runStreamed(spawn, "docker", VALKEY_RUN_ARGS, out) !== 0) { - out("✗ docker run failed — continuing; doctor below will re-check Valkey\n"); - summary.push(["valkey", "container start FAILED — `docker compose -f deploy/docker-compose.yml up -d` is the fallback"]); - } else { - out("✓ started Valkey (container pi-dispatch-valkey, AOF on, bound to 127.0.0.1)\n"); - summary.push(["valkey", "started container pi-dispatch-valkey (durable: --appendonly yes, restart unless-stopped)"]); - } - } } // (a2)(b2) the podman venue: its gate and its job image, in this account's own store (issue #430). `podmanReady` @@ -484,6 +448,236 @@ export async function runUp(argv = [], deps = {}) { } } + // (e0) VALKEY_PASSWORD (issue #468), where docker's Valkey is the queue: generated into .env when the deployment has + // none and runs a Valkey of its own on this host (`valkeyPasswordDecision`), never over a value, the file narrowed to + // this account, the value never printed. The podman venue's Valkey gets its password in the stack step below. + const dockerValkeyPassword = dockerUsed ? dockerValkeyPasswordStep({ fs, envPath, platform, out, summary, newPassword }) : null; + + if (dockerUsed) { + // (c) Valkey. A bare TCP probe of the default bind, not a redis PING: dependency-free, and the + // honest claim is only "something is listening". If our own compose-named container is up, docker + // can say so; if a listener exists that we cannot name, up must NOT offer a second Valkey; the + // port is taken, and `docker run` would only fail after consent. + // + // AFTER init and the .env keys since issue #468, where it used to run first: the Valkey this step starts takes its + // password from `.env`, which a fresh folder has only once init has scaffolded it. The prompts keep their order + // (the image, then Valkey, then the egress proxy), since init and the .env keys ask nothing. + const dockerEnv = { ...env }; + // The deployment's password, and never a shell's: the container must start with the one the service's clients send. + if (dockerValkeyPassword) dockerEnv[VALKEY_PASSWORD_KEY] = dockerValkeyPassword; + else delete dockerEnv[VALKEY_PASSWORD_KEY]; + // WHERE, from VALKEY_URL as the service reads it (`deploymentValkeyEnv`, the podman step's reader since #464; read + // here, after init, so a fresh folder's scaffolded .env counts): its loopback port is probed and published. It was + // always 6379, so a deployment whose VALKEY_URL named another port was told "nothing is listening" and given a + // Valkey its worker never dialled, or had its own listener on that port ignored. Another host: nothing is added + // here. An IPv6 literal cannot reach a Valkey published on 127.0.0.1, so it is refused with the fix. + const dockerValkey = dockerValkeyWhere({ env, fs, envPath, platform }); + for (const note of dockerValkey.notes) out(`⚠ ${note}\n`); + const vport = dockerValkey.port; + // THE rule (PR #475's review, round 3): a Valkey container or volume is this deployment's only when it is PROVABLY + // this deployment's (valkey-auth.mjs, `valkeyContainerIsOurs`): up's label naming this folder, compose's working dir + // here, or, for a legacy unlabelled pi-dispatch-valkey, this deployment's VALKEY_URL port. Anything else is never + // stopped, removed or reused, and no Valkey is started on the volume while one that is not ours mounts it. + let deployment = cwd; + try { + deployment = realpath(cwd); + } catch { + // Unresolvable: the folder as given. + } + const dirs = [deployment, cwd]; + const query = (cmd, args) => runCmdQuery(spawn, cmd, args); + const VALKEY_RUN_ARGS = valkeyDockerRunArgs({ port: vport, deployment }); + // Labelled with this folder when up creates it (the volume gap): a label cannot be added later. + const VALKEY_VOLUME_ARGS = valkeyVolumeCreateArgs(deployment); + // PI_VALKEY_PORT into .env where VALKEY_URL's port is not 6379 (round 3), so a plain compose run in this folder + // publishes where the worker dials; never over a different value, which is named instead. + if (!dockerValkey.error && !dockerValkey.remote) dockerValkeyPortStep({ fs, envPath, platform, port: vport, out, summary }); + // Before any start on pi-dispatch-valkey-data: refused, named, while a container that is not ours mounts it. + const volumeClear = async () => { + const check = await foreignVolumeUsers({ dirs, port: vport, query }); + if (!check.unknown && check.foreign.length === 0) return true; + valkeyRefused = "volume"; + out(`✗ ${foreignVolumeRefusal(check)}\n`); + summary.push(["valkey", `NOT started: ${foreignVolumeRefusal(check)}`]); + return false; + }; + // The volume's own owner (PR #475's review, the volume gap: with no container on it, a second deployment's `up` started + // on the first one's queue). Another folder's label: never used. No label (made before the label, or by hand): used + // only after a question that --yes does not answer, since that queue cannot be attributed; `servedByOurs` where this + // deployment's own container serves it now (the upgrade restart), which attributes it. Sets `volumeState`. + let volumeState = null; + const volumeUsable = async ({ servedByOurs = false } = {}) => { + const v = await valkeyVolumeOwner({ dirs, query }); + const refuse = (why) => { + valkeyRefused = "volume"; + out(`✗ ${why}\n`); + summary.push(["valkey", `NOT started: ${why}`]); + return false; + }; + if (v.unknown) return refuse(`whose pi-dispatch-valkey-data is could not be read (${v.unknown}), so no Valkey is started on it`); + if (v.ours === false) return refuse(foreignVolumeLabelRefusal(v.owner)); + if (v.unlabelled && !servedByOurs) { + out("\n"); + if (!/^y(es)?$/i.test(String((await prompt(adoptVolumeQuestion(deployment))) ?? "").trim())) return refuse(unadoptedVolumeRefusal()); + volumeState = "adopted"; + return true; + } + volumeState = v.absent ? "absent" : v.unlabelled ? "served" : "ours"; + return true; + }; + // After every start on the volume: the queue's own `pi-dispatch:owner`, recorded where it is missing and checked where + // it is not. Another folder's stops what this pass just started, at once (`stop`), and fails up. + const ownerMarkerOk = async (stop) => { + const r = await claimValkeyOwner(`redis://127.0.0.1:${vport}`, deployment); + if (r.error) { + valkeyRefused = "marker"; + out(`✗ Valkey was started, but its ${OWNER_MARKER_KEY} could not be recorded or read (${r.error}), so whose queue it holds is unconfirmed\n`); + summary.push(["valkey", `started, owner marker UNREAD: ${r.error}`]); + return false; + } + if (!dirs.includes(r.owner)) { + valkeyRefused = "volume"; + const stopped = await stop(); + out(`✗ ${foreignMarkerRefusal(r.owner)}: the Valkey this pass started on it was ${stopped ? "stopped again at once" : "NOT stopped (the stop failed): stop it now"}\n`); + summary.push(["valkey", `STOPPED: ${foreignMarkerRefusal(r.owner)}`]); + return false; + } + if (r.claimed) out(`✓ recorded ${OWNER_MARKER_KEY}=${deployment} in its queue\n`); + return true; + }; + const stopRun = async () => (await runStreamed(spawn, "docker", VALKEY_STOP_ARGS, out)) === 0 && (await runStreamed(spawn, "docker", VALKEY_RM_ARGS, out)) === 0; + // A folder the setup wizard handed to compose (PR #475's review): its Valkey is compose's valkey, named by the folder's + // project and the override, published on VALKEY_URL's port through PI_VALKEY_PORT. + const handedOver = fs.existsSync(join(cwd, COMPOSE_VALKEY_OVERRIDE)); + const project = composeProjectName(cwd); + const composeBase = composeArgs(handedOver ? { project, override: true } : {}); + const composeEnv = { ...dockerEnv, [VALKEY_PORT_KEY]: String(vport) }; + const ourName = handedOver ? `${project}-valkey-1` : "pi-dispatch-valkey"; + const shownCompose = (args) => `${vport !== 6379 ? `${VALKEY_PORT_KEY}=${vport} ` : ""}docker ${quoteArgs(args)}`; + const composeNote = handedOver ? "" : ` (in a folder /dispatch setup laid out, with -p ${project} after \`compose\`)`; + let existing = null; + if (dockerValkey.error) { + valkeyRefused = "url"; + out(`✗ ${dockerValkey.error}\n`); + summary.push(["valkey", `NOT added: ${dockerValkey.error}`]); + } else if (dockerValkey.remote) { + out(`✓ VALKEY_URL names ${dockerValkey.remote}, not this host, so no Valkey is added here\n`); + summary.push(["valkey", "VALKEY_URL names another host, nothing added here"]); + } else if (await probeTcp("127.0.0.1", vport)) { + const who = await valkeyContainerOwner(ourName, { dirs, port: vport, query }); + const ours = who.ours === true; + out(`✓ something is listening on ${vport}, assuming your Valkey${ours ? ` (it is the ${ourName} container)` : ""}\n`); + if (who.ours === false) out(`⚠ ${foreignContainerSentence(ourName, who.owner)}\n`); + // Issue #468: a Valkey there that answers a client sending NO password, while this deployment has one, was started + // before the password existed (the upgrade of a deployment that had none). Ours is offered a restart with it; one + // we did not start is named, with the compose command that recreates the compose-started one. + const open = dockerValkeyPassword ? (await probeValkeyAuth(`redis://127.0.0.1:${vport}`)) === "ok" : false; + const cost = "A job running right now is interrupted, and the worker and the receiver get NOAUTH until they are restarted: pause first if a job runs (pi-dispatch pause, wait for active jobs, then pi-dispatch resume after the restart)"; + if (open && ours && handedOver) { + // The wizard handed this folder's Valkey to compose (PR #475's review): compose recreates it with the password, + // on the same volume, where `docker run` would start a second one beside it. + const recreate = [...composeBase, "up", "-d", "--force-recreate", "valkey"]; + const accepted = (await volumeClear()) && (await volumeUsable({ servedByOurs: true })) ? await consent(`${ourName} answers without a password, and ${VALKEY_PASSWORD_KEY} is set in .env. up would recreate it with the password (compose stops it first, so Valkey writes its AOF out; the pi-dispatch-valkey-data volume, and the queue in it, is kept). ${cost}:`, [shownCompose(recreate)], { yes, out, prompt }) : null; + if (accepted === null) { + // Refused above (`volumeClear` said why): nothing was asked, so nothing was declined. + } else if (!accepted) { + out(`skipped: until it restarts with ${VALKEY_PASSWORD_KEY}, any local account can reach this queue\n`); + summary.push(["valkey", `${ourName} runs WITHOUT a password (declined the restart with it); \`pi-dispatch up\` again offers it`]); + } else { + const code = await runStreamed(spawn, "docker", recreate, out, { env: composeEnv }); + out(code === 0 ? `✓ recreated ${ourName} with ${VALKEY_PASSWORD_KEY} (same volume). Restart the worker and the receiver now so they send it\n` : `✗ compose could not recreate its valkey (exit ${code}): its own output above says why\n`); + if (code === 0) await ownerMarkerOk(async () => (await runStreamed(spawn, "docker", [...composeBase, "stop", "valkey"], out, { env: composeEnv })) === 0); + summary.push(["valkey", code === 0 ? `recreated ${ourName} with ${VALKEY_PASSWORD_KEY} (volume kept); restart the worker and the receiver` : `recreate with the password FAILED (exit ${code}); see compose's output`]); + } + } else if (open && ours) { + const accepted = (await volumeClear()) && (await volumeUsable({ servedByOurs: true })) ? await consent( + `pi-dispatch-valkey answers without a password, and ${VALKEY_PASSWORD_KEY} is set in .env. up would restart it with the password (stopped first, so Valkey writes its AOF out; the pi-dispatch-valkey-data volume, and the queue in it, is kept). ${cost}:`, + [`docker ${VALKEY_STOP_ARGS.join(" ")}`, `docker ${VALKEY_RM_ARGS.join(" ")}`, `docker ${quoteArgs(VALKEY_RUN_ARGS)}`], + { yes, out, prompt }, + ) : null; + if (accepted === null) { + // Refused above (`volumeClear` said why). + } else if (!accepted) { + out(`skipped: until it restarts with ${VALKEY_PASSWORD_KEY}, any local account can reach this queue\n`); + summary.push(["valkey", `pi-dispatch-valkey runs WITHOUT a password (declined the restart with it); \`pi-dispatch up\` again offers it`]); + } else if (await runStreamed(spawn, "docker", VALKEY_STOP_ARGS, out) !== 0 || await runStreamed(spawn, "docker", VALKEY_RM_ARGS, out) !== 0) { + out("✗ stopping or removing pi-dispatch-valkey failed: it runs as it was, without a password; doctor below will re-check\n"); + summary.push(["valkey", "restart with the password FAILED at stop/rm; pi-dispatch-valkey still runs without one"]); + } else if (await runStreamed(spawn, "docker", VALKEY_RUN_ARGS, out, { env: dockerEnv }) !== 0) { + out("✗ docker run failed after pi-dispatch-valkey was removed: no Valkey runs now; its volume is kept. Re-run `pi-dispatch up`\n"); + summary.push(["valkey", "restart with the password FAILED at docker run: NO Valkey runs now (the volume is kept); re-run `pi-dispatch up`"]); + } else if (await ownerMarkerOk(stopRun)) { + out(`✓ restarted Valkey with ${VALKEY_PASSWORD_KEY} (container pi-dispatch-valkey, same volume). Restart the worker and the receiver now so they send it: \`pi-dispatch service restart\` (and \`pi-dispatch service restart --receiver\`), or restart them however you run them\n`); + summary.push(["valkey", `restarted pi-dispatch-valkey with ${VALKEY_PASSWORD_KEY} (volume kept); restart the worker and the receiver`]); + } + } else if (open) { + out(`⚠ the Valkey on ${vport} answers without a password, and it is not a container up started, so up leaves it: restart it with ${VALKEY_PASSWORD_KEY} from .env (compose: \`${shownCompose([...composeBase, "up", "-d"])}\`${composeNote}, which recreates it and keeps its volume)\n`); + summary.push(["valkey", `port ${vport} has a listener that answers WITHOUT a password, left alone: restart it with ${VALKEY_PASSWORD_KEY}`]); + } else { + summary.push(["valkey", ours ? `container ${ourName} already running` : `port ${vport} already has a listener, left alone`]); + } + } else if (handedOver) { + // The wizard handed this folder's Valkey to compose (PR #475's review): up starts THAT one, compose's valkey on + // up's old volume, with the folder's project and the override, never a `docker run` of its own beside it (measured: + // up re-created pi-dispatch-valkey on the same volume, and the wizard's next compose run then failed to bind). + const start = [...composeBase, "up", "-d", "valkey"]; + const accepted = (await volumeClear()) && (await volumeUsable()) ? await consent(`Nothing is listening on 127.0.0.1:${vport}. This folder's Valkey is compose's (${COMPOSE_VALKEY_OVERRIDE}, written by /dispatch setup), so up would start it:`, [shownCompose(start)], { yes, out, prompt }) : null; + if (accepted === null) { + // Refused above (`volumeClear` said why): nothing was asked, so nothing was declined. + } else if (!accepted) { + out(`skipped: start it later with \`${shownCompose(start)}\`\n`); + summary.push(["valkey", "skipped (declined): the queue needs it before `pi-dispatch worker` can drain"]); + } else { + const code = await runStreamed(spawn, "docker", start, out, { env: composeEnv }); + if (code === 0 && !(await ownerMarkerOk(async () => (await runStreamed(spawn, "docker", [...composeBase, "stop", "valkey"], out, { env: composeEnv })) === 0))) { + // Said above; nothing more to add. + } else if (code === 0) { + out(`✓ started compose's valkey (project ${project}, the pi-dispatch-valkey-data volume, 127.0.0.1:${vport})\n`); + summary.push(["valkey", `started compose's valkey (project ${project}, volume pi-dispatch-valkey-data)`]); + } else { + // Reported, never swallowed: compose prints why (a port another container holds, most often), and up exits + // non-zero on it. + valkeyRefused = "compose"; + out(`✗ compose could not start its valkey (exit ${code}): its own output above says why (another container on 127.0.0.1:${vport} is the usual one: \`docker ps\` shows which)\n`); + summary.push(["valkey", `compose's valkey FAILED to start (exit ${code}); see compose's output`]); + } + } + } else if ((existing = await valkeyContainerOwner("pi-dispatch-valkey", { dirs, port: vport, query })) && !existing.absent) { + // A pi-dispatch-valkey that does not answer this deployment's port: this deployment's, stopped (started as it is, + // never replaced), or another's, never touched (PR #475's review, round 3), or one docker could not describe. + if (existing.ours) { + out(`⚠ pi-dispatch-valkey is this deployment's and is not listening on ${vport}: \`docker start pi-dispatch-valkey\` starts it as it is\n`); + summary.push(["valkey", "pi-dispatch-valkey exists (this deployment's) and is not running; `docker start pi-dispatch-valkey`"]); + } else { + valkeyRefused = "volume"; + const why = existing.unknown ? `whether pi-dispatch-valkey is this deployment's could not be read (${existing.unknown})` : foreignContainerSentence("pi-dispatch-valkey", existing.owner); + out(`✗ ${why}, and a second container of that name cannot be started beside it. Give this deployment a Valkey of its own (compose in this folder, which names its container and volume after the folder's project)\n`); + summary.push(["valkey", `NOT started: ${why}`]); + } + } else if ((await volumeClear()) && (await volumeUsable())) { + // The volume is created (labelled) only where it does not exist; an adopted or labelled one is used as it is. + const creates = volumeState === "absent"; + const accepted = await consent( + `Nothing is listening on 127.0.0.1:${vport}. up would start Valkey (same semantics as deploy/docker-compose.yml)${volumeState === "adopted" ? " on the adopted pi-dispatch-valkey-data" : ""}:`, + [...(creates ? [`docker ${quoteArgs(VALKEY_VOLUME_ARGS)}`] : []), `docker ${quoteArgs(VALKEY_RUN_ARGS)}`], + { yes, out, prompt }, + ); + if (!accepted) { + out(`skipped: start it later with \`${shownCompose([...composeBase, "up", "-d"])}\`${composeNote}\n`); + summary.push(["valkey", "skipped (declined): the queue needs it before `pi-dispatch worker` can drain"]); + } else if (creates && await runStreamed(spawn, "docker", VALKEY_VOLUME_ARGS, out) !== 0) { + out("✗ docker volume create failed; continuing, doctor below will re-check Valkey\n"); + summary.push(["valkey", `volume create FAILED; \`${shownCompose([...composeBase, "up", "-d"])}\` is the fallback`]); + } else if (await runStreamed(spawn, "docker", VALKEY_RUN_ARGS, out, { env: dockerEnv }) !== 0) { + out("✗ docker run failed; continuing, doctor below will re-check Valkey\n"); + summary.push(["valkey", `container start FAILED; \`${shownCompose([...composeBase, "up", "-d"])}\` is the fallback`]); + } else if (await ownerMarkerOk(stopRun)) { + out(`✓ started Valkey (container pi-dispatch-valkey, AOF on, bound to 127.0.0.1, ${dockerValkeyPassword ? `with ${VALKEY_PASSWORD_KEY} from .env` : "WITHOUT a password: .env sets none"})\n`); + summary.push(["valkey", "started container pi-dispatch-valkey (durable: --appendonly yes, restart unless-stopped)"]); + } + } + } + // (e2) the egress policy's proxy, and ONLY when the operator has already armed it. up never invents // operator policy -- the same doctrine that keeps it pulling this repo's own image and no other -- so a // deployment that has not set PI_EGRESS hears nothing about this at all. @@ -620,7 +814,7 @@ export async function runUp(argv = [], deps = {}) { summary.push(["egress", "started pi-dispatch-egress-proxy on pi-dispatch-egress-out"]); } } else { - out("skipped — start it later with `docker compose -f deploy/docker-compose.yml --profile egress up -d`\n"); + out(`skipped: start it later with \`${composeCommandFor(cwd, fs, ["--profile", "egress", "up", "-d"])}\`\n`); summary.push(["egress", "skipped (declined) — every job is refused pre-spend until the proxy is up (PI_EGRESS=0 opts out)"]); } } @@ -630,7 +824,7 @@ export async function runUp(argv = [], deps = {}) { // the allowlist init scaffolds. if (podmanReady) { const stackState = { valkeyRefused: false }; - await podmanStackStep({ env: venueEnv, valkeyKeys: valkeyKeys.env, state: stackState, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user: userName(), euid, templatesDir, readTemplate, mkdir, realpath }); + await podmanStackStep({ env: venueEnv, valkeyKeys: valkeyKeys.env, state: stackState, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user: userName(), euid, newPassword, platform, templatesDir, readTemplate, mkdir, realpath }); valkeyRefused = stackState.valkeyRefused; } @@ -690,7 +884,19 @@ Next: // says: a worker started now would take that queue's jobs, and an exit 0 read by a script or the wizard says ready. if (valkeyRefused) { // Worded per reason (gate round 2): an owner refusal and a URL the Quadlet Valkey cannot serve are different faults. - out(valkeyRefused === "owner" ? "\nup: the Valkey VALKEY_URL reaches is not this account's (above); give this account its own port, or opt in with PI_VALKEY_SHARED=1 in .env, then re-run `pi-dispatch up`.\n" : "\nup: VALKEY_URL cannot be served as it is written (above); fix it in .env, then re-run `pi-dispatch up`.\n"); + out( + valkeyRefused === "owner" + ? "\nup: the Valkey VALKEY_URL reaches is not this account's (above); give this account its own port, or opt in with PI_VALKEY_SHARED=1 in .env, then re-run `pi-dispatch up`.\n" + : valkeyRefused === "password" + ? `\nup: ${VALKEY_PASSWORD_KEY} in .env cannot be handed to Valkey (above); fix it, then re-run \`pi-dispatch up\`.\n` + : valkeyRefused === "compose" + ? "\nup: compose could not start this folder's Valkey (above); free the port it names, then re-run `pi-dispatch up`.\n" + : valkeyRefused === "marker" + ? `\nup: the Valkey up started could not be asked whose queue it holds (${OWNER_MARKER_KEY}, above); check it answers, then re-run \`pi-dispatch up\`.\n` + : valkeyRefused === "volume" + ? "\nup: the Valkey container or volume named above is not this deployment's (or not attributed to it), and up never stops, removes, reuses or starts beside one; take the way out it names, then re-run `pi-dispatch up`.\n" + : "\nup: VALKEY_URL cannot be served as it is written (above); fix it in .env, then re-run `pi-dispatch up`.\n", + ); return doctorCode !== 0 ? doctorCode : 1; } return doctorCode; @@ -760,7 +966,7 @@ async function podmanImageStep({ spawn, out, yes, prompt, summary }) { * alone, a proxy that exists is left alone), then plans the Quadlet files with `planStack`, shows `plan.actions`, and on * consent hands those same objects to `applyStack`: what runs is literally what was shown. */ -async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user, euid, templatesDir, readTemplate, mkdir, realpath }) { +async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn, out, yes, prompt, summary, fs, probeTcp, lookup, interfaces, runSync, cwd, home, user, euid, templatesDir, readTemplate, mkdir, realpath, newPassword = newValkeyPassword, platform = "linux" }) { const armed = egressArmedFn(env); let includeValkey = false; let valkeyPort = DEFAULT_VALKEY_PORT; @@ -807,7 +1013,38 @@ async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn summary.push(["valkey", ours ? `container pi-dispatch-valkey already running on ${decided.port}` : `port ${decided.port} already has a listener, left alone`]); } } - const components = stackComponents({ venues, env, includeValkey, armed, valkeyPort }); + // Issue #468: the password the Quadlet Valkey starts with, read from .env NOW (init, earlier in this pass, may have just + // written the file and a password into it): kept when set, generated when the Valkey this step adds is the + // deployment's own and it has none, written into .env only once the plan is accepted. + const envPath = join(cwd, ".env"); + let valkeyPassword = null; + let pendingPassword = null; + if (includeValkey) { + let fileKeys = {}; + let readError = null; + if (fs.existsSync(envPath)) { + try { + const read = readValkeyKeys(fs.readFileSync(envPath), { loader: "systemd", path: envPath }); + if (read.error) readError = read.error; + else fileKeys = read.keys; + } catch (err) { + readError = `${envPath} could not be read (${err?.message ?? err})`; + } + } + const decided = readError ? { error: readError } : valkeyPasswordDecision({ ...valkeyKeys, ...fileKeys }, { envPath }); + if (decided.error) { + state.valkeyRefused = "password"; + out(`\n✗ ${decided.error}\n`); + summary.push(["valkey", `NOT added: ${decided.error}`]); + includeValkey = false; + } else { + if (decided.note) out(`\n⚠ ${decided.note}\n`); + if (decided.generate && fs.existsSync(envPath)) pendingPassword = newPassword(); + else if (decided.generate) out(`\n⚠ no .env here, so the Valkey up adds has no password: run \`pi-dispatch init\` here, then \`pi-dispatch service install --force\`, which gives it one\n`); + valkeyPassword = decided.password ?? pendingPassword; + } + } + const components = stackComponents({ venues, env, includeValkey, armed, valkeyPort, valkeyPassword }); for (const note of components.notes) out(`\n⚠ ${note}\n`); let keeperRestart = false; if (components.proxy) { @@ -898,7 +1135,7 @@ async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn const several = named.length > 1; const accepted = await consent( `${parts} ${several ? "are" : "is"} not running under this account's Podman. up would install ${several ? "them" : "it"} as Quadlet units in your user manager (the same installer \`pi-dispatch service install\` uses; systemd brings them back at boot while linger is on):`, - plan.actions.map(describeAction), + [...(pendingPassword ? [`generate ${VALKEY_PASSWORD_KEY} into ${envPath} (32 random bytes, hex; the value is not shown)`] : []), ...plan.actions.map(describeAction)], { yes, out, prompt }, ); const row = several ? "podman stack" : components.valkey ? "valkey" : components.proxy ? "egress (podman)" : "netns keeper (podman)"; @@ -914,6 +1151,21 @@ async function podmanStackStep({ env, valkeyKeys = {}, state = {}, venues, spawn // leaves the files its units run from, named. const journal = []; const stackFs = { ...fs, mkdirSync: fs.mkdirSync ?? mkdir, unlinkSync: fs.unlinkSync ?? unlinkSync }; + if (pendingPassword) { + // Issue #468: the password into .env before any stack file, journalled with what was there, so a failed write + // below puts .env back with the rest. Never over a value; the file narrowed to this account; the value unshown. + let previous = null; + try { + previous = fs.readFileSync(envPath); + if (!updateEnvFile(envPath, VALKEY_PASSWORD_KEY, pendingPassword, { fs, platform, narrow: true }).changed) throw new Error("the file already assigns it"); + } catch (err) { + out(`✗ ${VALKEY_PASSWORD_KEY} could not be written into ${envPath} (${err?.message ?? err}), so nothing was installed. Continuing; doctor below will re-check\n`); + summary.push([row, `NOT installed: ${VALKEY_PASSWORD_KEY} could not be written into .env`]); + return; + } + journal.push({ path: envPath, existed: true, previous }); + out(`✓ generated ${VALKEY_PASSWORD_KEY} into .env (value not shown; .env is now readable by this account only)\n`); + } const applied = await applyStack(plan, { fs: stackFs, run: (cmd, args) => runStreamed(spawn, cmd, args, out), journal }); if (!applied.ok) { if (!applied.ran) { @@ -970,9 +1222,128 @@ async function consent(intro, commands, { yes, out, prompt }) { return /^y(es)?$/i.test(String(answer ?? "").trim()); } -/** Re-join an argv array for display, quoting the args that contain spaces (e.g. "valkey-cli ping"). */ +/** + * Re-join an argv array for display, quoting the args that contain spaces (e.g. "valkey-cli ping"). An arg with a + * double quote or a `$` in it (issue #468: the Valkey start script) is shown in single quotes, which is how a shell + * would take it unchanged; none of those args has a single quote of its own. + */ function quoteArgs(args) { - return args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" "); + return args.map((a) => (/["$]/.test(a) ? `'${a}'` : a.includes(" ") ? `"${a}"` : a)).join(" "); +} + +/** + * Issue #468: the password docker's Valkey starts with, from the deployment's `.env` (`dockerValkeyPasswordStep`): kept + * when set, generated into the file when the deployment runs its own Valkey on this host and has none, null otherwise. + * Said on screen and in the summary without the value. + */ +function dockerValkeyPasswordStep({ fs, envPath, platform, out, summary, newPassword }) { + if (!fs.existsSync(envPath)) { + summary.push([VALKEY_PASSWORD_KEY, "no .env here, skipped (set it wherever your env lives); the Valkey up starts then has no password"]); + return null; + } + const loader = platform === "linux" ? "systemd" : platform === "darwin" ? "shell" : "cmd"; + let keys; + try { + const read = readValkeyKeys(fs.readFileSync(envPath), { loader, path: envPath }); + if (read.error) throw new Error(read.error); + keys = read.keys; + } catch (err) { + out(`\n✗ ${VALKEY_PASSWORD_KEY}: ${err?.message ?? err}\n`); + summary.push([VALKEY_PASSWORD_KEY, `NOT decided: ${err?.message ?? err}`]); + return null; + } + const decided = valkeyPasswordDecision(keys, { envPath }); + if (decided.error) { + out(`\n✗ ${decided.error}\n`); + summary.push([VALKEY_PASSWORD_KEY, `NOT usable: ${decided.error}`]); + return null; + } + if (decided.note) { + out(`\n⚠ ${decided.note}\n`); + summary.push([VALKEY_PASSWORD_KEY, decided.note]); + return null; + } + if (!decided.generate) { + summary.push([VALKEY_PASSWORD_KEY, "already set, left untouched (value not shown)"]); + return decided.password; + } + const password = newPassword(); + try { + if (!updateEnvFile(envPath, VALKEY_PASSWORD_KEY, password, { fs, platform, narrow: true }).changed) throw new Error("the file already assigns it"); + } catch (err) { + out(`\n✗ ${VALKEY_PASSWORD_KEY} could not be written: ${err?.message ?? err}\n`); + summary.push([VALKEY_PASSWORD_KEY, `NOT written: ${err?.message ?? err}. The Valkey up starts then has no password`]); + return null; + } + out(`\n✓ generated ${VALKEY_PASSWORD_KEY} into .env (32 random bytes, hex; value not shown; .env is now readable by this account only)\n`); + summary.push([VALKEY_PASSWORD_KEY, "generated into .env (value not shown; Valkey requires it, and every client sends it)"]); + return password; +} + +/** + * PI_VALKEY_PORT in .env (PR #475's review, round 3): written where VALKEY_URL's port is not 6379 and .env names none, + * a differing value named and left alone (`valkeyPortEnvDecision`). + */ +function dockerValkeyPortStep({ fs, envPath, platform, port, out, summary }) { + if (!fs.existsSync(envPath)) return; + const loader = platform === "linux" ? "systemd" : platform === "darwin" ? "shell" : "cmd"; + let decided; + try { + const found = readEnvAssignments(fs.readFileSync(envPath), [VALKEY_PORT_KEY], { loader })[VALKEY_PORT_KEY]; + decided = valkeyPortEnvDecision(found === undefined ? undefined : (found.value ?? "?"), port, { envPath }); + if (decided.write && !updateEnvFile(envPath, VALKEY_PORT_KEY, decided.write, { fs, platform }).changed) throw new Error("the file already assigns it"); + } catch (err) { + out(`\n✗ ${VALKEY_PORT_KEY} could not be written: ${err?.message ?? err}\n`); + summary.push([VALKEY_PORT_KEY, `NOT written: ${err?.message ?? err}`]); + return; + } + if (decided.conflict) { + out(`\n⚠ ${decided.conflict}\n`); + summary.push([VALKEY_PORT_KEY, decided.conflict]); + } else if (decided.write) { + out(`\n✓ wrote ${VALKEY_PORT_KEY}=${port} into .env (VALKEY_URL's port), so compose in this folder publishes its Valkey there\n`); + summary.push([VALKEY_PORT_KEY, `written: ${port} (VALKEY_URL's port)`]); + } +} + +/** + * The compose command a message names for this folder (PR #475's review): the folder's project and the hand-over + * override where the wizard wrote one, else the docs' plain command with the note a wizard folder needs. + */ +function composeCommandFor(cwd, fs, tail) { + const handedOver = fs.existsSync(join(cwd, COMPOSE_VALKEY_OVERRIDE)); + const project = composeProjectName(cwd); + const args = [...composeArgs(handedOver ? { project, override: true } : {}), ...tail]; + return `docker ${args.join(" ")}${handedOver ? "" : `\` (in a folder /dispatch setup laid out, with -p ${project} after \`compose`}`; +} + +/** + * Where docker's Valkey is, from VALKEY_URL as the service reads it (`deploymentValkeyEnv`): `{ port, notes }`, with + * `remote` for another host (nothing is added for it) or `error` for a URL the Valkey `up` starts could not serve. + */ +function dockerValkeyWhere({ env, fs, envPath, platform }) { + const read = deploymentValkeyEnv({ env, fs, envPath, platform }); + if (read.error) return { port: 6379, notes: [], error: `${read.error}. up judges the Valkey the service will use, so it adds none` }; + const url = read.env.VALKEY_URL; + const target = valkeyTarget(url); + if (target.error) return { port: 6379, notes: read.notes, error: `VALKEY_URL: ${target.error}` }; + if (!isLoopbackHost(target.host)) return { port: target.port, notes: read.notes, remote: target.host }; + if (target.host.includes(":")) { + return { port: target.port, notes: read.notes, error: `VALKEY_URL's host is ${target.host}, and the Valkey up starts is published on 127.0.0.1 only, so the worker would not reach it. Write VALKEY_URL=redis://127.0.0.1:${target.port}` }; + } + return { port: target.port, notes: read.notes }; +} + +/** `claimValkeyOwner` (connection.mjs) with the deployment's credential, as every client of the project gets it. */ +async function defaultClaimValkeyOwner(url, folder, { env, cwd }) { + const { claimValkeyOwner, valkeyClientContext } = await import("./connection.mjs"); + return claimValkeyOwner(url, folder, { context: valkeyClientContext({ env, cwd }) }); +} + +/** Whether the Valkey at `url` answers a client that sends no password: its `valkeyAuthState`, through connection.mjs. */ +async function defaultProbeValkeyAuth(url, { env, cwd }) { + const { valkeyAuthState, valkeyClientContext } = await import("./connection.mjs"); + return (await valkeyAuthState(url, { withoutPassword: true, context: valkeyClientContext({ env, cwd }) })).state; } /** Exit code of a spawned command; null when it could not launch (not on PATH) — mirrors doctor. */ @@ -994,11 +1365,13 @@ function runCmd(spawn, cmd, args) { * Run a CONSENTED command with its stdout+stderr streamed to `out` as it happens — a docker pull's * progress is the operator's confirmation that the thing they approved is the thing running. */ -function runStreamed(spawn, cmd, args, out) { +function runStreamed(spawn, cmd, args, out, { env } = {}) { return new Promise((resolve) => { let child; try { - child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); + // `env` only where a value must reach the command without being on its argv (issue #468: `docker run -e + // VALKEY_PASSWORD` reads it from here); otherwise the child inherits this process's, as before. + child = spawn(cmd, args, env ? { stdio: ["ignore", "pipe", "pipe"], env } : { stdio: ["ignore", "pipe", "pipe"] }); } catch { resolve(null); return; diff --git a/worker/src/valkey-auth.mjs b/worker/src/valkey-auth.mjs new file mode 100644 index 00000000..1da9e0fb --- /dev/null +++ b/worker/src/valkey-auth.mjs @@ -0,0 +1,452 @@ +/** + * The password of the Valkey pi-dispatch starts (issue #468). + * + * Loopback is shared by every account on a host. #464 stops one account from ADOPTING another's Valkey by accident; + * it does nothing about a co-tenant who connects on purpose, and a Valkey with no password lets any local account read + * the queued jobs (their task text, their repositories), enqueue work another account's worker runs with that + * account's provider key and forge credentials, and delete the queue. So every deployment's Valkey gets a password of + * its own, generated once, kept in the deployment's `.env` as `VALKEY_PASSWORD`. + * + * A key of its own, NOT a password inside VALKEY_URL: VALKEY_URL is printed by doctor, written into the admin's + * deployment pointer (which by contract never carries a credential) and quoted in refusals, so a URL that carried the + * password would be a credential in every one of those places. A separate key stays out of all of them, and doctor + * says only whether it is set. An operator's own VALKEY_URL with its own userinfo (a managed Valkey, TLS) is left as it + * is, and its password wins over this key (`connection.mjs`). + * + * HOW THE VALKEY LEARNS IT, measured on Podman 5.8.1 (Fedora 44) and 4.9.3 (Ubuntu 24.04) with valkey/valkey:8 + * (Valkey 8.1.10): the image runs `tini -- docker-entrypoint.sh `, and tini keeps its whole argv as PID 1 for + * the life of the container, so `valkey-server --requirepass X` put the password in a /proc//cmdline that another + * account read, on the host, for as long as the Valkey ran (the pid namespace hides nothing: /proc of the host lists + * the container's processes, and cmdline is world-readable). The image's VALKEY_EXTRA_FLAGS only appends to that same + * argv. So the password travels in the container's ENVIRONMENT (a Quadlet `EnvironmentFile=` of a 0600 file, compose's + * `environment:`, `docker run -e VALKEY_PASSWORD` with the value in the CLI's own environment), which /proc shows only + * to the account itself, and `VALKEY_START_SCRIPT` hands it to valkey-server as CONFIGURATION ON STDIN (`valkey-server + * -`): written to a 0600 temp file by the shell's own `echo` builtin (no process, so no argv), opened as stdin, deleted, + * then the image's entrypoint runs as it always did (its chown and its drop to the `valkey` user), and valkey-server + * reads the one `requirepass` line from the open descriptor. Measured: an account scanning every /proc//cmdline + * during start and at rest saw no password, and nothing is left in the container's /tmp. With VALKEY_PASSWORD empty or + * unset the same script starts Valkey with no password, exactly as before, so an older `.env` keeps working. + * + * `$` and `%` are the two characters the script must survive in three carriers: systemd expands `$X` and `%x` in the + * ExecStart Quadlet generates, and compose interpolates `$X`. The script has no `%` and no backslash, and every `$` is + * written `$$` in the Quadlet unit and the compose file (`dollarsDoubled`), and a test reads each carrier's copy back and + * holds all three equal to this constant. + */ +import { randomBytes } from "node:crypto"; +import { basename, join } from "node:path"; + +/** The `.env` key (issue #468). */ +export const VALKEY_PASSWORD_KEY = "VALKEY_PASSWORD"; + +/** + * A new password: 32 random bytes as 64 lowercase hex characters. Hex because every loader of `.env` reads it back + * byte for byte (systemd, the shells, the cmd wrapper split on `=`), nothing in it is special to a Valkey config line, + * a URL or a shell, and it matches the WEBHOOK_SECRET `up` already generates. + */ +export function newValkeyPassword(random = randomBytes) { + return random(32).toString("hex"); +} + +/** + * What is wrong with `value` as a Valkey password this project hands to valkey-server, or null. Only base64url + * characters (A-Z a-z 0-9 - _), 16 to 512 of them: the start script writes it into a Valkey config line, where a space, + * a quote or a `#` would change what the line says, and `.env` must read it back the same under every loader. + */ +export function valkeyPasswordProblem(value) { + if (typeof value !== "string" || value === "") return "it is empty"; + if (!/^[A-Za-z0-9_-]+$/.test(value)) return "it has a character other than A-Z, a-z, 0-9, - and _, which the Valkey start script and every .env loader cannot all read the same"; + if (value.length < 16) return `it is ${value.length} characters long, and a password this project hands to Valkey needs at least 16`; + if (value.length > 512) return `it is ${value.length} characters long, over the 512 this project accepts`; + return null; +} + +/** The sentence that tells an operator how to make a good one. */ +export const VALKEY_PASSWORD_HOWTO = "`openssl rand -hex 32` makes one, or remove the line and let `pi-dispatch up` or `service install` generate it"; + +/** + * The container command that starts Valkey with the password from its environment, as configuration on stdin (see the + * header). One copy, in three carriers: `deploy/pi-dispatch-valkey.container`'s Exec=, `deploy/docker-compose.yml`'s + * command, and `valkeyDockerRunArgs` (`up` and doctor's fix). `--appendonly yes` as before (REQ-QUEUE-BURST-NO-DROP). + */ +export const VALKEY_START_SCRIPT = + 'set -eu; if [ -n "${VALKEY_PASSWORD:-}" ]; then umask 077; f=$(mktemp); echo "requirepass $VALKEY_PASSWORD" >"$f"; exec 0<"$f"; rm -f "$f"; set -- -; else set --; fi; unset VALKEY_PASSWORD; exec docker-entrypoint.sh valkey-server "$@" --appendonly yes'; + +/** + * The health check, run by the container runtime through `/bin/sh -c`: healthy only when Valkey answers PONG to this + * deployment's own password (REDISCLI_AUTH, which valkey-cli reads from its environment, never an argv). A bare + * `valkey-cli ping` was measured to exit 0 on `NOAUTH Authentication required.`, so it could not tell a Valkey that + * refuses its own password from a healthy one. + */ +export const VALKEY_HEALTH_SCRIPT = 'if [ -n "${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG'; + +/** The Quadlet and compose spelling of a script: every `$` doubled, which each carrier reads back as one. */ +export function dollarsDoubled(script) { + return script.replaceAll("$", () => "$$"); +} + +/** + * `deploy/docker-compose.yml`'s Valkey, as one `docker run` (`up`'s docker step and doctor's fix), published on `port`. `-e VALKEY_PASSWORD` + * names the variable only: the docker CLI takes its value from its OWN environment (the caller's spawn env), so the + * password is on no argv. Same image, AOF on, loopback only, the named volume, the health check above. + */ +export function valkeyDockerRunArgs({ port = 6379, deployment = null } = {}) { + return [ + "run", + "-d", + "--name", + "pi-dispatch-valkey", + // Whose Valkey this is (PR #475's review, round 3): the deployment folder's real path, so a later `up`, the wizard's + // hand-over or a password restart acts on it only from that folder (`valkeyContainerIsOurs`). + ...(deployment ? ["--label", `${DEPLOYMENT_LABEL}=${deployment}`] : []), + "--restart", + "unless-stopped", + "-p", + // VALKEY_URL's port on 127.0.0.1 (the container listens on 6379 inside), as the Quadlet unit's PublishPort= is. + `127.0.0.1:${port}:6379`, + "-v", + "pi-dispatch-valkey-data:/data", + "-e", + VALKEY_PASSWORD_KEY, + "--health-cmd", + VALKEY_HEALTH_SCRIPT, + "--health-interval", + "10s", + "--health-timeout", + "3s", + "--health-retries", + "5", + "valkey/valkey:8", + "sh", + "-c", + VALKEY_START_SCRIPT, + ]; +} + +/** + * The 0600 file the Quadlet Valkey reads its password from (`EnvironmentFile=%h/.config/pi-dispatch/valkey.env`): the + * one key, nothing else of the deployment's `.env` (whose provider key and forge credentials the Valkey container has no + * use for). Empty value for a deployment with no password, which the start script reads as "start without one". + */ +export function valkeyEnvFileText(password) { + return `# Written by pi-dispatch (service install / up) from VALKEY_PASSWORD in the deployment's .env; mode 0600.\n${VALKEY_PASSWORD_KEY}=${password ?? ""}\n`; +} + +/** + * Whether a VALKEY_URL is one pi-dispatch generates a password for: its host is this machine's loopback (unset is the + * default, 127.0.0.1) and it carries no userinfo. A URL naming another host, or carrying its own credentials, is the + * operator's own Valkey and is left as it is. + */ +export function managedValkeyUrl(url) { + if (url === undefined || url === null || url === "") return true; + let u; + try { + u = new URL(String(url)); + } catch { + return false; + } + if (u.username !== "" || u.password !== "") return false; + return isLoopbackHost(u.hostname); +} + +/** A loopback host as a client dials it: 127.0.0.0/8, ::1 (bracketed or not), `localhost`, or an unspecified address. */ +export function isLoopbackHost(hostname) { + const h = String(hostname ?? "").toLowerCase().replace(/^\[(.*)\]$/, "$1"); + if (h === "localhost" || h === "::1" || h === "::" || h === "0:0:0:0:0:0:0:1") return true; + if (/^::ffff:(127|0)\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true; + return /^(127|0)\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h); +} + +/** + * The refusal for a Valkey that rejected this client's credential, or null for any other error: NOAUTH (it requires a + * password and none was sent), WRONGPASS or "invalid password" (one was sent and it is not that Valkey's). `from` is + * where the password that was sent came from (`valkeyPasswordFor`), `envPath` the deployment `.env`; the sentence names + * the key and its source, never a value. + */ +export function valkeyAuthRefusal(err, { passwordSet, from = null, envPath = "the deployment's .env" } = {}) { + const text = String(err?.message ?? err ?? ""); + if (/\bNOAUTH\b/.test(text) && !passwordSet) { + return `the Valkey VALKEY_URL reaches requires a password, and this deployment sets none: put ${VALKEY_PASSWORD_KEY}= in ${envPath} (a Valkey shared with PI_VALKEY_SHARED=1 takes the password of the account that runs it)`; + } + if (/\bWRONGPASS\b|invalid password|\bNOAUTH\b/i.test(text)) { + const source = from === "VALKEY_URL" ? "the password in VALKEY_URL" : `${VALKEY_PASSWORD_KEY} from ${from ?? envPath}`; + return `the Valkey VALKEY_URL reaches refused ${source} (${/\bNOAUTH\b/.test(text) ? "NOAUTH" : "WRONGPASS"}): it is not that Valkey's password. After changing ${VALKEY_PASSWORD_KEY}, restart Valkey with it (\`pi-dispatch up\`, or \`pi-dispatch service install --force\` on the podman venue), then the worker and the receiver`; + } + return null; +} + +/** + * Whether `service install` or `up` generates a password for this deployment (issue #468), from its `.env` keys as the + * service reads them (`readValkeyKeys`). Returns `{ password, generate, note }` or `{ error }`: + * - VALKEY_PASSWORD set: kept, never replaced (a rotation is the operator's edit), and refused when the start script + * could not hand it to Valkey (`valkeyPasswordProblem`); + * - PI_VALKEY_SHARED=1: none generated. The shared Valkey is another account's, and its password is that account's + * to give; one made here would reach no Valkey; + * - a VALKEY_URL that is not this machine's loopback, or that carries its own userinfo: none generated, the + * operator's own Valkey left as it is; + * - otherwise `generate`: a new one goes into `.env` (never over a value) and into the Valkey the command starts. + */ +export function valkeyPasswordDecision(keys, { envPath = ".env" } = {}) { + const current = keys?.[VALKEY_PASSWORD_KEY]; + if (typeof current === "string" && current !== "") { + const problem = valkeyPasswordProblem(current); + if (problem) return { error: `${VALKEY_PASSWORD_KEY} in ${envPath} cannot be handed to Valkey: ${problem}. ${VALKEY_PASSWORD_HOWTO}` }; + return { password: current, generate: false, note: null }; + } + if (keys?.PI_VALKEY_SHARED === "1") { + return { password: null, generate: false, note: `PI_VALKEY_SHARED=1 in ${envPath}: no ${VALKEY_PASSWORD_KEY} is generated, since the shared Valkey is another account's. Put that Valkey's password in ${VALKEY_PASSWORD_KEY} in ${envPath}` }; + } + if (!managedValkeyUrl(keys?.VALKEY_URL)) { + return { password: null, generate: false, note: `VALKEY_URL in ${envPath} names your own Valkey (another host, or a URL with its own credentials): no ${VALKEY_PASSWORD_KEY} is generated for it` }; + } + return { password: null, generate: true, note: null }; +} + +// --------------------------------------------------------------------------------------------------------------------- +// The compose file's Valkey, as `up` and the setup wizard drive it (PR #475's review) +// --------------------------------------------------------------------------------------------------------------------- + +/** The shipped compose file and the wizard's hand-over override, relative to the deployment folder. */ +export const COMPOSE_FILE = "deploy/docker-compose.yml"; +export const COMPOSE_VALKEY_OVERRIDE = "deploy/docker-compose.valkey.yml"; + +/** + * The compose project name of a deployment folder: its basename, normalised as compose v2 normalises a directory name + * (lower case, only a-z 0-9 _ -, no leading _ or -; measured with compose 2.31 and 5.5.1: "My Deploy.v2_x" is + * "mydeployv2_x"). A name with none of those characters left ("日本") is one compose itself refuses ("project name must + * not be empty", measured), and is "pi-dispatch" here. Compose names a project after the directory of its FIRST file, + * `deploy/` in every folder the wizard lays out, so `-p` is what keeps the folder's own project. + */ +export function composeProjectName(dir) { + const name = basename(String(dir)).toLowerCase().replace(/[^a-z0-9_-]/g, "").replace(/^[_-]+/, ""); + return name === "" ? "pi-dispatch" : name; +} + +/** + * The start of every compose command for a deployment folder, run FROM that folder: `-p` when a project is named (a + * folder the wizard laid out), `--env-file .env` (VALKEY_PASSWORD, issue #468), the shipped file, and the hand-over + * override when the folder has one. + */ +export function composeArgs({ project = null, override = false } = {}) { + return ["compose", ...(project ? ["-p", project] : []), "--env-file", ".env", "-f", COMPOSE_FILE, ...(override ? ["-f", COMPOSE_VALKEY_OVERRIDE] : [])]; +} + +/** + * The environment compose's Valkey reads its published port from (PR #475's review): `PI_VALKEY_PORT`, VALKEY_URL's + * port, so the queue stays where the worker dials it (the compose file said 6379 whatever VALKEY_URL said, and a + * hand-over moved a 16495 deployment's queue to 6379, measured). The compose file reads it as `${PI_VALKEY_PORT:-6379}`. + */ +export const VALKEY_PORT_KEY = "PI_VALKEY_PORT"; + +/** + * The compose override the wizard writes when `up` already runs the deployment's Valkey: compose's own `valkey` + * service mounts `up`'s volume, `pi-dispatch-valkey-data`, instead of a project volume of its own. Written once, + * create-only; `up` and every later command for the folder name it. + */ +export const VALKEY_HANDOVER_OVERRIDE = `# Written by /dispatch setup (the docker compose answer): this deployment's Valkey was the one \`pi-dispatch up\` +# started (container pi-dispatch-valkey, volume pi-dispatch-valkey-data). compose's valkey service takes over that +# VOLUME, so the worker and the receiver container (valkey:6379) use one Valkey and one queue. \`pi-dispatch up\` starts +# this Valkey (with -p and this file) whenever the folder has it. +services: + valkey: + volumes: + - pi-dispatch-valkey-data:/data +volumes: + pi-dispatch-valkey-data: + external: true +`; + +// --------------------------------------------------------------------------------------------------------------------- +// Whose Valkey container and volume (PR #475's review, round 3: THE simpler rule) +// --------------------------------------------------------------------------------------------------------------------- + +/** The label `up` puts on the Valkey container it creates: the deployment folder's real path. */ +export const DEPLOYMENT_LABEL = "com.pi-dispatch.deployment"; + +/** The named volume `up`'s Valkey (and a handed-over compose Valkey) keeps its AOF on. */ +export const VALKEY_VOLUME = "pi-dispatch-valkey-data"; + +/** + * Whether a container (one record of `docker container inspect`) is PROVABLY this deployment's Valkey. Nothing ever + * stops, removes, reuses or mounts beside a container this answers false for: the review found the wizard's hand-over + * removing another deployment's Valkey, and `up` starting a second Valkey on one AOF, both from treating a name or a + * volume as proof of ownership. Proof is one of: + * - `up`'s label, `com.pi-dispatch.deployment`, equal to this deployment folder (`dirs`: its real path and as given); + * - compose's own `com.docker.compose.project.working_dir`, this folder or its `deploy/` (a compose Valkey of this + * folder, the hand-over's included); + * - for a legacy, unlabelled `pi-dispatch-valkey` only: it publishes this deployment's VALKEY_URL port on 127.0.0.1, + * up's own rule since #464 (it is the Valkey this deployment's worker dials). + */ +export function valkeyContainerIsOurs(info, { dirs, port }) { + const labels = info?.Config?.Labels ?? {}; + const mine = new Set(dirs.filter(Boolean).flatMap((d) => [d, join(d, "deploy")])); + if (typeof labels[DEPLOYMENT_LABEL] === "string") return mine.has(labels[DEPLOYMENT_LABEL]); + const wd = labels["com.docker.compose.project.working_dir"]; + if (typeof wd === "string") return mine.has(wd); + const name = String(info?.Name ?? "").replace(/^\//, ""); + if (name !== "pi-dispatch-valkey") return false; + return publishedPorts(info).includes(Number(port)); +} + +/** The host ports a container publishes its 6379 on, from its inspect record (HostConfig, else NetworkSettings). */ +function publishedPorts(info) { + const bindings = info?.HostConfig?.PortBindings?.["6379/tcp"] ?? info?.NetworkSettings?.Ports?.["6379/tcp"] ?? []; + return (Array.isArray(bindings) ? bindings : []).filter((b) => !b?.HostIp || b.HostIp === "127.0.0.1").map((b) => Number(b?.HostPort)).filter(Number.isInteger); +} + +/** Who a container belongs to, as a refusal names it: its label, compose's working dir, or what it publishes. */ +function ownerShown(info) { + const labels = info?.Config?.Labels ?? {}; + if (labels[DEPLOYMENT_LABEL]) return `the deployment in ${labels[DEPLOYMENT_LABEL]}`; + if (labels["com.docker.compose.project.working_dir"]) return `the compose project in ${labels["com.docker.compose.project.working_dir"]}`; + const ports = publishedPorts(info); + return ports.length > 0 ? `unlabelled, publishing 127.0.0.1:${ports.join(", ")}` : "unlabelled, publishing nothing on 127.0.0.1"; +} + +/** + * One container by name: `{ absent }`, `{ ours, owner }`, or `{ unknown }` when docker did not answer (which is never + * taken as ours). `query(cmd, args)` resolves `{ code, stdout, stderr }`. + */ +export async function valkeyContainerOwner(name, { dirs, port, query }) { + const res = await query("docker", ["container", "inspect", name]); + if (res.code !== 0) return /no such (container|object)/i.test(String(res.stderr ?? "")) ? { absent: true } : { unknown: `docker container inspect ${name} exited ${res.code}` }; + let info; + try { + info = JSON.parse(String(res.stdout))[0]; + } catch { + return { unknown: `docker container inspect ${name} printed no record` }; + } + return { ours: valkeyContainerIsOurs(info, { dirs, port }), owner: ownerShown(info) }; +} + +/** + * The containers that mount `pi-dispatch-valkey-data` and are NOT provably this deployment's (PR #475's review, round + * 3). Asked before anything starts a Valkey on that volume: two valkey-servers appending to one AOF corrupt both queues + * (measured: a second deployment's `up` ran pi-dispatch-valkey on the volume a handed-over compose Valkey mounted, and + * both appended to /data/appendonlydir). `{ foreign: [{ name, owner }] }`, or `{ unknown }` when docker could not be + * asked, which callers refuse on too. + */ +export async function foreignVolumeUsers({ dirs, port, query }) { + const ps = await query("docker", ["ps", "-a", "--filter", `volume=${VALKEY_VOLUME}`, "--format", "{{.Names}}"]); + if (ps.code !== 0) return { unknown: `docker ps -a --filter volume=${VALKEY_VOLUME} exited ${ps.code}` }; + const foreign = []; + for (const name of String(ps.stdout ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) { + const who = await valkeyContainerOwner(name, { dirs, port, query }); + if (who.absent) continue; + if (who.unknown) foreign.push({ name, owner: who.unknown }); + else if (!who.ours) foreign.push({ name, owner: who.owner }); + } + return { foreign }; +} + +/** The refusal for a volume another deployment's Valkey mounts, naming it and the ways out. */ +export function foreignVolumeRefusal(check) { + if (check.unknown) return `whether another Valkey mounts ${VALKEY_VOLUME} could not be read (${check.unknown}), so no Valkey is started on it`; + const names = check.foreign.map((f) => `${f.name} (${f.owner})`).join(", "); + return `${VALKEY_VOLUME} is mounted by ${names}, which is not this deployment's Valkey: a second Valkey on the same AOF would corrupt both queues, so none is started. Stop that deployment's Valkey first if this folder should own the volume, or give this deployment a Valkey of its own (compose in this folder without deploy/docker-compose.valkey.yml keeps its own volume)`; +} + +/** The refusal for a `pi-dispatch-valkey` container that is not this deployment's: never stopped, removed or reused. */ +export function foreignContainerSentence(name, owner) { + return `${name} is not this deployment's Valkey (${owner}), so it is never stopped, removed or reused from here`; +} + +/** + * What `.env` should say about PI_VALKEY_PORT for a deployment whose VALKEY_URL names `port` (PR #475's review, round + * 3): the port lived only in the env `up` and the wizard hand compose, so a plain `docker compose up -d` in the folder + * published its Valkey on 6379 while the worker dialled the URL's port. `assigned` is the value `.env` holds, or + * undefined. `{ write }` where the port is not 6379 and `.env` names none, `{ conflict }` (a sentence) where `.env` + * names another port, never overwritten; else `{}`. + */ +export function valkeyPortEnvDecision(assigned, port, { envPath = ".env" } = {}) { + const have = typeof assigned === "string" ? assigned.trim() : ""; + if (have === "") return Number(port) === 6379 ? {} : { write: String(port) }; + if (have === String(port)) return {}; + return { conflict: valkeyPortConflict(have, port, envPath) }; +} + +/** The sentence for a PI_VALKEY_PORT that disagrees with VALKEY_URL's port (shared by `up`, the wizard and doctor). */ +export function valkeyPortConflict(assigned, port, envPath = ".env") { + return `${VALKEY_PORT_KEY} is ${assigned} in ${envPath}, and VALKEY_URL's port is ${port}: compose publishes its Valkey on ${assigned}, where the worker does not dial. Set ${VALKEY_PORT_KEY}=${port} there (or remove it when the port is 6379); it is never overwritten from here`; +} + +/** + * The wizard's hand-over question under the ownership rule (PR #475's review, round 3), here so the rule is one function + * both the wizard and a test on a real docker run. `{ handover, note }` or `{ refused }`: + * - `pi-dispatch-valkey` is handed to compose only when it is PROVABLY this deployment's (`valkeyContainerIsOurs`); + * one that is another deployment's is never stopped or removed (measured: the hand-over removed another + * deployment's Valkey and cut its worker off), and `note` names it; + * - where compose's valkey would mount `pi-dispatch-valkey-data` (the override on disk, or a hand-over), a container + * on that volume that is not this deployment's refuses the whole step (`foreignVolumeRefusal`), and so does a + * volume labelled for another folder; an unlabelled one is taken as this deployment's where its own + * pi-dispatch-valkey serves it (the hand-over), else `adopt` asks the caller to get consent first; + * - docker not answering is a refusal, never a guess. + * The started Valkey's `pi-dispatch:owner` marker is the caller's to check (`claimValkeyOwner`, connection.mjs). + */ +export async function composeHandoverPlan({ dirs, port, override, query }) { + const who = await valkeyContainerOwner("pi-dispatch-valkey", { dirs, port, query }); + if (who.unknown) return { refused: `whether pi-dispatch-valkey is this deployment's could not be read (${who.unknown}), so nothing is stopped or started` }; + const handover = who.ours === true; + const note = who.ours === false ? `${foreignContainerSentence("pi-dispatch-valkey", who.owner)}; it is left running` : null; + if (override || handover) { + const check = await foreignVolumeUsers({ dirs, port, query }); + if (check.unknown || check.foreign.length > 0) return { refused: foreignVolumeRefusal(check) }; + // The volume's own owner (the volume gap): another folder's label is never used; an unlabelled one is this + // deployment's when this deployment's own pi-dispatch-valkey serves it now (the hand-over), else only with consent. + const volume = await valkeyVolumeOwner({ dirs, query }); + if (volume.unknown) return { refused: `whose ${VALKEY_VOLUME} is could not be read (${volume.unknown}), so nothing is stopped or started` }; + if (volume.ours === false) return { refused: foreignVolumeLabelRefusal(volume.owner) }; + if (volume.unlabelled && !handover) return { handover, note, adopt: true }; + } + return { handover, note }; +} + +/** The key inside a Valkey that records which deployment folder its queue is (PR #475's review, round 3's close). */ +export const OWNER_MARKER_KEY = "pi-dispatch:owner"; + +/** + * `docker volume create` for `pi-dispatch-valkey-data`, labelled with the deployment folder that creates it (PR #475's + * review): a volume label cannot be changed afterwards, so the one that creates it names its owner once, for good. + */ +export function valkeyVolumeCreateArgs(deployment) { + return ["volume", "create", "--label", `${DEPLOYMENT_LABEL}=${deployment}`, VALKEY_VOLUME]; +} + +/** + * Whose `pi-dispatch-valkey-data` is (PR #475's review, the volume gap: after one deployment's compose Valkey was taken + * down, nothing mounted the volume, and a second deployment's `up` started on the first one's queue). `{ absent }`, + * `{ ours, owner }` by its label (exactly this folder, as given or real), `{ unlabelled }` for a volume made before the + * label (or by hand), which is used only with consent that `--yes` does not give, or `{ unknown }`. + */ +export async function valkeyVolumeOwner({ dirs, query }) { + const res = await query("docker", ["volume", "inspect", VALKEY_VOLUME]); + if (res.code !== 0) return /no such volume|not found/i.test(String(res.stderr ?? "")) ? { absent: true } : { unknown: `docker volume inspect ${VALKEY_VOLUME} exited ${res.code}` }; + let info; + try { + info = JSON.parse(String(res.stdout))[0]; + } catch { + return { unknown: `docker volume inspect ${VALKEY_VOLUME} printed no record` }; + } + const label = info?.Labels?.[DEPLOYMENT_LABEL]; + if (typeof label !== "string" || label === "") return { unlabelled: true }; + return { ours: dirs.filter(Boolean).includes(label), owner: label }; +} + +/** The refusal for a volume labelled for another folder: never used from here. */ +export function foreignVolumeLabelRefusal(owner) { + return `${VALKEY_VOLUME} belongs to the deployment in ${owner} (its label), so this deployment never uses it: give this deployment a Valkey of its own (compose in this folder without deploy/docker-compose.valkey.yml keeps its own volume), or run this from ${owner}`; +} + +/** The question for an unlabelled volume, asked even under --yes: what adopting it means. */ +export function adoptVolumeQuestion(deployment) { + return `${VALKEY_VOLUME} exists without an owner label: this volume holds a queue pi-dispatch cannot attribute to a folder. Starting this deployment's Valkey on it makes that queue this deployment's (recorded inside it as ${OWNER_MARKER_KEY}); if another deployment used it, its waiting jobs would run here. Adopt it for ${deployment}? (asked even under --yes) [y/N] `; +} + +/** The refusal for an unlabelled volume that was not adopted. */ +export function unadoptedVolumeRefusal() { + return `${VALKEY_VOLUME} has no owner label and was not adopted (this volume holds a queue pi-dispatch cannot attribute to a folder), so no Valkey is started on it. Answer y when asked (--yes does not), or give this deployment a Valkey of its own (compose in this folder without deploy/docker-compose.valkey.yml keeps its own volume)`; +} + +/** The refusal for a Valkey whose owner marker names another folder. */ +export function foreignMarkerRefusal(owner) { + return `the queue on ${VALKEY_VOLUME} is the deployment's in ${owner} (${OWNER_MARKER_KEY} inside it), not this one's`; +} diff --git a/worker/src/valkey-endpoint.mjs b/worker/src/valkey-endpoint.mjs index ef91d8c1..45f66d37 100644 --- a/worker/src/valkey-endpoint.mjs +++ b/worker/src/valkey-endpoint.mjs @@ -31,6 +31,7 @@ import { networkInterfaces, userInfo } from "node:os"; import { join } from "node:path"; import { venuesOf } from "./backends.mjs"; import { VALKEY_SHARED_KEY, judgeValkeyListeners, passwdNameFrom, pinnedValkeyUrl, probeTcpAddress, readStackKeys, readSubuidRanges, readValkeyKeys, valkeySharedOn } from "./podman-stack.mjs"; +import { VALKEY_PASSWORD_KEY, isLoopbackHost } from "./valkey-auth.mjs"; /** A refused Valkey: tagged as the configError it is, so the worker's CLI exits 2 on it. */ export function valkeyRefusal(message) { @@ -38,7 +39,25 @@ export function valkeyRefusal(message) { } /** - * Where a client stands (issue #464, gate round 3): `{ envPath, shared, rootRefused, error }`. + * Where a client stands when its caller names no context (issue #468): `valkeyClientContext()`, the `.env` in the + * working directory, unless a process installed its own (`useValkeyContext`). The admin panel installs one built from + * the ONE `.env` reader it has (issue #471's `readDeploymentEnv`, through the worker's shared resolver, the pointer's + * folder only, and nothing from a file another account can change), so no second reader of a `.env` runs in the panel. + */ +let contextProvider = null; + +/** Install (or with null, remove) the function that makes the default context. */ +export function useValkeyContext(fn) { + contextProvider = typeof fn === "function" ? fn : null; +} + +/** The context a client gets when its caller names none. */ +export function defaultValkeyContext() { + return contextProvider ? contextProvider() : valkeyClientContext(); +} + +/** + * Where a client stands (issue #464, gate round 3): `{ envPath, shared, rootRefused, error, password }`. * * The owner decision itself does not depend on this (gate round 3's simpler rule): on Linux EVERY client refuses a * listener held by a uid that is neither this account's euid, nor one of its subordinate uids, nor root, from any cwd. @@ -56,6 +75,11 @@ export function valkeyRefusal(message) { * The `.env` is read as BYTES through the hardened reader (`readStackKeys`, `readValkeyKeys`); a file that cannot be * read, or a line they refuse, is `error`, never dropped: a client whose judged listener is not this account's own is * then refused, naming it, since neither the opt-in nor the venue can be told. + * + * `password` (issue #468): `{ environment, file }`, VALKEY_PASSWORD from this process's environment (the service's + * loader puts the `.env` there) and from the `.env` in `cwd` (so `pi-dispatch pause` in the deployment folder needs no + * export), each null when unset or empty. Which one a client sends is `valkeyPasswordFor`'s rule. The values are never + * logged; a line may say only whether one is set. */ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), platform = process.platform, rootRefused, readEnv = (p) => readFileSync(p) } = {}) { const envPath = join(cwd, ".env"); @@ -66,16 +90,53 @@ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), pl } catch (err) { if (err?.code !== "ENOENT") error = `${envPath} could not be read (${err?.code ?? err?.message})`; } - let fileVenue = {}; - let shared = false; + const fileKeys = {}; if (content !== null) { const stack = readStackKeys(content, { loader: "systemd", path: envPath }); if (stack.error) error = stack.error; - else fileVenue = stack.keys; + else if (typeof stack.keys.PI_BACKENDS === "string") fileKeys.PI_BACKENDS = stack.keys.PI_BACKENDS; const keys = readValkeyKeys(content, { loader: "systemd", path: envPath }); if (keys.error) error ??= keys.error; - else shared = valkeySharedOn(keys.keys[VALKEY_SHARED_KEY]); + else for (const k of [VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, "VALKEY_URL"]) if (typeof keys.keys[k] === "string") fileKeys[k] = keys.keys[k]; + } + return valkeyContextFromKeys({ env, envPath, fileKeys, error, platform, rootRefused }); +} + +/** + * A context from issue #471's resolution of the service keys (`resolveServiceEnv`'s `{ env, fromFile, disagreements }`, + * 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 + * file's value where the two disagree. So a password the resolution took from the file is sent as the file's, to a + * loopback Valkey only (`valkeyPasswordFor`), and a shell's stays the shell's. + */ +export function valkeyContextFromResolution({ service, envPath = null, shared, platform = process.platform } = {}) { + const fileValue = (key) => service?.fromFile?.[key] ?? service?.disagreements?.find((d) => d.key === key)?.file; + const shellValue = (key) => (Object.hasOwn(service?.fromFile ?? {}, key) ? undefined : service?.env?.[key]); + const env = {}; + const fileKeys = {}; + for (const key of ["VALKEY_URL", VALKEY_PASSWORD_KEY]) { + if (typeof shellValue(key) === "string") env[key] = shellValue(key); + if (typeof fileValue(key) === "string") fileKeys[key] = fileValue(key); } + if (typeof service?.env?.PI_BACKENDS === "string") env.PI_BACKENDS = service.env.PI_BACKENDS; + if (typeof shared === "string") fileKeys[VALKEY_SHARED_KEY] = shared; + return valkeyContextFromKeys({ env, envPath, fileKeys, platform }); +} + +/** The keys a client's context takes from a deployment `.env` (`valkeyContextFromKeys`). */ +export const VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_KEY, VALKEY_SHARED_KEY, "PI_BACKENDS"]); + +/** + * A context from keys a caller has already read out of the deployment's `.env` (`fileKeys`, the `VALKEY_CONTEXT_KEYS` + * it supplied) and this process's environment. `valkeyClientContext` reads the file itself; the admin panel passes what + * issue #471's resolver read, so the panel has one `.env` reader. `error` is a reason the file could not be read, as + * `valkeyClientContext`'s. + */ +export function valkeyContextFromKeys({ env = process.env, envPath = null, fileKeys = {}, error = null, platform = process.platform, rootRefused } = {}) { + const fileVenue = typeof fileKeys.PI_BACKENDS === "string" ? { PI_BACKENDS: fileKeys.PI_BACKENDS } : {}; + const shared = valkeySharedOn(fileKeys[VALKEY_SHARED_KEY]); + const filePassword = fileKeys[VALKEY_PASSWORD_KEY] || null; + const fileUrl = typeof fileKeys.VALKEY_URL === "string" ? fileKeys.VALKEY_URL : null; + const envPassword = typeof env[VALKEY_PASSWORD_KEY] === "string" && env[VALKEY_PASSWORD_KEY] !== "" ? env[VALKEY_PASSWORD_KEY] : null; let venues = { localUsed: true, podmanUsed: false }; try { venues = venuesOf({ PI_BACKENDS: typeof env.PI_BACKENDS === "string" ? env.PI_BACKENDS : fileVenue.PI_BACKENDS }); @@ -83,7 +144,121 @@ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), pl // An unparseable list: the default venue, as doctor reads it; the worker refuses to boot on it anyway. } const derived = platform === "linux" && venues.podmanUsed && !venues.localUsed; - return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error }; + const envUrl = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : null; + return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } }; +} + +/** The default VALKEY_URL, the worker's own (config.mjs). */ +export const DEFAULT_VALKEY_URL = "redis://127.0.0.1:6379"; + +/** + * The VALKEY_URL a command run from a deployment folder uses (PR #475's review), by the same rule as the password it + * sends: this shell's when it sets one, else the deployment `.env`'s, else the default. `pi-dispatch run`, `pause`, + * `resume`, `status`, `cancel` and `service restart --drain` read the password from `.env` already and read the URL + * from the shell alone, so in the deployment folder of a Valkey on another port they dialled 6379 with that + * deployment's password. A shell and a `.env` that disagree are NAMED in `note` (the shell's is used: it is the + * operator's explicit choice, and the kill switch must never refuse over it); a `.env` that could not be read is too. + * Returns `{ url, from, note }`, `from` naming the source ("the environment", the `.env` path, or null for the default). + */ +export function valkeyUrlFor(context) { + const envUrl = context?.url?.environment ?? null; + const fileUrl = context?.url?.file ?? null; + if (typeof envUrl === "string") { + 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; + return { url: envUrl, from: "the environment", note }; + } + if (typeof fileUrl === "string") return { url: fileUrl, from: context.envPath, note: null }; + return { url: DEFAULT_VALKEY_URL, from: null, note: context?.error ? `${context.error}, so VALKEY_URL is the default, ${DEFAULT_VALKEY_URL}` : null }; +} + +/** + * The Valkey(s) a kill switch acts on (PR #475's review, rounds 2 and 3), for the CLI's `pause`, `resume`, `status` and + * `cancel` and the panel's `/dispatch pause|resume`: ONE rule, so the two surfaces cannot disagree about which queue + * "paused" is true of. + * - `flagUrl` (the CLI's `--valkey-url`) when given. Refused when it carries a password: a command line is readable by + * every account in /proc, so the password belongs in VALKEY_PASSWORD, which every client sends. When it names + * neither this shell's VALKEY_URL nor the deployment .env's, `note` says which it is. + * - else this shell's (or the process environment's, the panel's pointer layered in) and the deployment .env's + * VALKEY_URL: when both are set and differ, BOTH, with `disagreement` naming the two; the caller pauses both, shows + * both, and refuses a resume or a cancel until one is named. + * - else `valkeyUrlFor`'s one URL, with its `note`. + * Returns `{ urls, disagreement, note }` or `{ error }`. URLs in the sentences go through `urlShown`. + */ +export function killSwitchValkeyUrls({ env = process.env, cwd, flagUrl = null, context: given = null } = {}) { + // `context`: a caller that has read the deployment's `.env` itself (the admin panel, issue #471) passes what it read. + const context = given ?? valkeyClientContext({ env, ...(cwd ? { cwd } : {}) }); + const shell = context.url?.environment ?? null; + const file = context.url?.file ?? null; + if (flagUrl !== null && flagUrl !== undefined) { + let u; + try { + u = new URL(String(flagUrl)); + } catch { + return { error: `--valkey-url is not a URL (redis://host:port): ${urlShown(flagUrl)}` }; + } + if (u.password || u.username) { + 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)}` }; + } + const known = [shell, file].filter((x) => typeof x === "string").map(urlShown); + 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"})`; + return { urls: [String(flagUrl)], disagreement: null, note }; + } + if (typeof shell === "string" && typeof file === "string" && shell !== file) { + 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 }; + } + const resolved = valkeyUrlFor(context); + return { urls: [resolved.url], disagreement: null, note: resolved.note }; +} + +/** + * 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 + * review so every surface prints a Valkey URL through ONE function): scheme, host, port and database only, never the + * userinfo (a password or a username that is a token), the query (`password=`, a token) or the fragment; `` + * without a host, and `` when it does not parse, since then nothing can say where a credential in it + * starts or ends. Control characters are blanked: another party's value can reach this line. + */ +export function urlShown(url) { + let parsed; + try { + parsed = new URL(String(url)); + } catch { + return ""; + } + if (!parsed.hostname) return ""; + return `${parsed.protocol}//${parsed.host}${parsed.pathname && parsed.pathname !== "/" ? parsed.pathname : ""}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, " "); +} + +/** + * The password a client of `url` sends, and where it came from (issue #468): `{ password, from }`, both null for none. + * - the URL's own userinfo first: an operator's VALKEY_URL with a password (a managed Valkey) is left as it is; + * - else VALKEY_PASSWORD from the environment, for any host (the service's loader, compose's receiver container + * dialling `valkey:6379`, or an operator's export); + * - else VALKEY_PASSWORD from the deployment `.env`, and only for a loopback host: the password pi-dispatch generates + * belongs to the Valkey it starts on this machine, and is never sent to another one a shell's VALKEY_URL names. + * `withoutPassword` is for a probe asking whether a Valkey answers clients that send none (doctor, `up`). + */ +export function valkeyPasswordFor(url, context, { withoutPassword = false } = {}) { + if (withoutPassword) return { password: null, from: null }; + let u; + try { + u = new URL(url); + } catch { + return { password: null, from: null }; + } + if (u.password) return { password: decodedUserinfo(u.password), from: "VALKEY_URL" }; + const pw = context?.password ?? {}; + if (pw.environment) return { password: pw.environment, from: "the environment" }; + if (pw.file && isLoopbackHost(u.hostname)) return { password: pw.file, from: context.envPath ?? ".env" }; + return { password: null, from: null }; +} + +/** A URL's userinfo part as the Valkey sees it: percent-decoded (`p%40ss` is `p@ss`), as written when it does not decode. */ +export function decodedUserinfo(part) { + try { + return decodeURIComponent(part); + } catch { + return part; + } } let hostFacts = null; diff --git a/worker/test/config.test.mjs b/worker/test/config.test.mjs index f17f007a..63f8a804 100644 --- a/worker/test/config.test.mjs +++ b/worker/test/config.test.mjs @@ -175,6 +175,21 @@ test("forwardEnv refuses the App signing key -- worse in a container than the to assert.deepEqual(loadConfig({ PI_FORWARD_ENV: "GITHUB_APP_PRIVATE_KEY_PATH" }).forwardEnv, ["GITHUB_APP_PRIVATE_KEY_PATH"]); }); +// Issue #468: the queue's password is the worker's alone, like the App's signing key: forwarded into a job container, it +// would hand an agent reading adversarial issue text the power to read, enqueue and delete this deployment's jobs. +test("forwardEnv refuses VALKEY_PASSWORD, and a trigger cannot bind that name either (#468)", async () => { + for (const bad of ["VALKEY_PASSWORD", "FOO,VALKEY_PASSWORD", "GITHUB_APP_PRIVATE_KEY,VALKEY_PASSWORD"]) { + assert.throws( + () => loadConfig({ PI_FORWARD_ENV: bad }), + (e) => e.piDispatchConfig === true && /must not forward .*VALKEY_PASSWORD -- .*the queue's password lets whoever holds it read, enqueue and delete this deployment's jobs, and a job container is the last place it belongs/.test(e.message), + `PI_FORWARD_ENV=${bad}`, + ); + } + const { parseTriggers } = await import("../src/triggers.mjs"); + const label = { on: { type: "label", any: ["pi:x"] }, run: { kind: "github", flow: "fix", secrets: { VALKEY_PASSWORD: "op://a/b/c" } } }; + assert.throws(() => parseTriggers(JSON.stringify({ triggers: [label] }), "t.json"), /run\.secrets key "VALKEY_PASSWORD" is reserved/); +}); + test("authFromPi defaults ON; only PI_AUTH_FROM_PI=0 forces env-only", () => { assert.equal(loadConfig({}).authFromPi, true, "reusing the pi login is the default — no flag needed"); assert.equal(loadConfig({ PI_AUTH_FROM_PI: "1" }).authFromPi, true); diff --git a/worker/test/doctor.test.mjs b/worker/test/doctor.test.mjs index 568c7138..63391711 100644 --- a/worker/test/doctor.test.mjs +++ b/worker/test/doctor.test.mjs @@ -6,7 +6,8 @@ import { makeWaitChecker } from "../src/wait-check.mjs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { PassThrough } from "node:stream"; -import { CANARY_LINES, CANARY_PROBE_SLUGS, DOCTOR_SHELL_KEYS, EGRESS_CANARY_RUNNER_MODULE, EGRESS_CANARY_STALE_RUNNER, ENV_FILE_READABLE_KEYS, RECEIVER_SERVICE_KEYS, RUN_TIMEOUTS, SERVICE_ENV_KEYS, STEERING_SERVICE_KEYS, WORKER_SERVICE_KEYS, backendChecks, collectChecks, countRetained, defaultPromptFn, dockerRunVia, egressCanaryProbeArgs, egressCanaryScript, envFileKeys, githubProtectionPreflight, jobUserChecks, liveChecks, liveRunVia, podmanLiveChecks, render, runDoctor, sandboxTombstoneChecks, serviceEnvKeys, serviceEnvLoader, sweepStaleCanaryNetworks, urlShown, resolveDoctorEnv, jobImageOf, CLI_SERVICE_KEYS, cliNotHandedLines, triggersPath } from "../src/doctor.mjs"; +import { CANARY_LINES, CANARY_PROBE_SLUGS, DOCTOR_SHELL_KEYS, EGRESS_CANARY_RUNNER_MODULE, EGRESS_CANARY_STALE_RUNNER, ENV_FILE_READABLE_KEYS, RECEIVER_SERVICE_KEYS, RUN_TIMEOUTS, SERVICE_ENV_KEYS, STEERING_SERVICE_KEYS, WORKER_SERVICE_KEYS, backendChecks, collectChecks, countRetained, defaultPromptFn, dockerRunVia, egressCanaryProbeArgs, egressCanaryScript, envFileKeys, githubProtectionPreflight, jobUserChecks, liveChecks, liveRunVia, podmanLiveChecks, render, runDoctor, sandboxTombstoneChecks, serviceEnvKeys, serviceEnvLoader, sweepStaleCanaryNetworks, urlShown, resolveDoctorEnv, jobImageOf, CLI_SERVICE_KEYS, cliNotHandedLines, triggersPath, valkeyPasswordUpgradeStep } from "../src/doctor.mjs"; +import { valkeyPasswordFor } from "../src/valkey-endpoint.mjs"; import { serviceEnvFileOf } from "../src/service-env.mjs"; import { VALKEY_SHARED_KEY as VALKEY_SHARED_NAME } from "../src/podman-stack.mjs"; import { EMPTY_PAUSE_WINDOWS, EMPTY_SCOPED_LIMITS } from "../src/init.mjs"; @@ -2397,7 +2398,9 @@ test("doctor: without --fix, a fixAction-bearing failure prints exactly the old text(), /✗ Job image present \(pi-job:latest\)\n → docker pull ghcr\.io\/edgehero\/pi-job:latest && docker tag ghcr\.io\/edgehero\/pi-job:latest pi-job:latest {2}\(or build image\/Dockerfile\)\n/, ); - assert.match(text(), /✗ Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n → docker compose -f deploy\/docker-compose\.yml up -d\n/); + // Issue #468 with #471's rule: doctor starts no Valkey (its start needs the deployment's password in a child's + // environment); the fix line names `pi-dispatch up`. + assert.match(text(), /✗ Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n → run `pi-dispatch up` in the deployment folder: it starts Valkey with the deployment's VALKEY_PASSWORD/); assert.equal(prompts.length, 0, "no --fix, no prompt -- even with a promptFn injected"); assert.doesNotMatch(text(), /fix available|run this\?|skipped:|fixed:|re-check after fixes/); }); @@ -2413,13 +2416,10 @@ test("doctor --fix: declining every offer runs nothing and leaves the exit code assert.equal(code, 1, "warn-not-fail doctrine: offering fixes changes nothing about severity"); // The EXACT command is shown before each prompt -- consent is to a command, not to a vibe. assert.match(text(), /fix available: Job image present \(pi-job:latest\)\n {4}\$ docker pull ghcr\.io\/edgehero\/pi-job:latest && docker tag ghcr\.io\/edgehero\/pi-job:latest pi-job:latest\n/); - assert.match( - text(), - /fix available: Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n {4}\$ docker run -d --name pi-dispatch-valkey --restart unless-stopped -p 127\.0\.0\.1:6379:6379 -v pi-dispatch-valkey-data:\/data valkey\/valkey:8 valkey-server --appendonly yes\n/, - ); + // Issue #468 with #471's rule: no Valkey offer any more, only the image's. + assert.doesNotMatch(text(), /fix available: Valkey reachable/); assert.match(text(), /skipped: Job image present \(pi-job:latest\)/); - assert.match(text(), /skipped: Valkey reachable/); - assert.deepEqual(prompts, ["run this? [y/N] ", "run this? [y/N] "]); + assert.deepEqual(prompts, ["run this? [y/N] "]); assert.ok(!calls.some((c) => ["pull", "tag", "run"].includes(c.args[0])), "no spawn beyond the probes: a declined offer executes nothing"); assert.doesNotMatch(text(), /re-check after fixes/, "nothing ran, so nothing is re-checked"); }); @@ -2480,46 +2480,22 @@ test("doctor --fix: the converge re-check reruns the probes once and reports gre assert.equal(code, 0, "converge-to-green: the exit code judges the re-checked list by the same failed/ok logic"); }); -test("doctor --fix: accepting the valkey offer runs the exact loopback docker run argv, and converges", async () => { +// Issue #468 with issue #471's rule (PR #474): doctor hands no program anything from `.env`, and a Valkey `up` or +// compose starts takes the deployment's VALKEY_PASSWORD from there, so `doctor --fix` starts no Valkey at all: the check +// carries no offer, and its fix line names `pi-dispatch up`, which starts it with the password, labels its container and +// volume with the folder, and asks before it uses a volume it cannot attribute. +test("doctor --fix starts no Valkey: the unreachable check names `pi-dispatch up` and offers nothing (#468 over #471)", async () => { const calls = []; const { out, text } = capture(); + const { fn: promptFn, calls: prompts } = promptRecorder(true); const code = await runDoctor( { PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-x", GITHUB_AUTH_SOURCE: "pat" }, - { - out, - cwd: tmpdir(), - spawn: fakeSpawn({ ...EGRESS_OK, "docker info": 0, "docker image": 0, "docker run": 0 }, calls), - // Reachable exactly once the container has been started: the converge pass flips to ✓ only - // because the fix actually ran, not because fixing earns credit. - probeValkey: async () => calls.some((c) => c.cmd === "docker" && c.args[0] === "run" && c.args.includes("pi-dispatch-valkey")), - fileExists: () => true, - nodeVersion: "22.19.0", - fix: true, - promptFn: async () => true, - }, + { out, cwd: tmpdir(), spawn: fakeSpawn({ ...EGRESS_OK, "docker info": 0, "docker image": 0, "docker run": 0 }, calls), probeValkey: async () => false, fileExists: () => true, nodeVersion: "22.19.0", fix: true, promptFn }, ); - // The VALKEY run, named explicitly: the egress checks run their own probe containers, so "the first - // docker run" stopped being a unique way to name this one. - const run = calls.find((c) => c.cmd === "docker" && c.args[0] === "run" && c.args.includes("pi-dispatch-valkey")); - assert.deepEqual(run.args, [ - "run", - "-d", - "--name", - "pi-dispatch-valkey", - "--restart", - "unless-stopped", - "-p", - "127.0.0.1:6379:6379", - "-v", - "pi-dispatch-valkey-data:/data", - "valkey/valkey:8", - "valkey-server", - "--appendonly", - "yes", - ]); - assert.match(text(), /fixed: Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)/); - assert.match(text(), /re-check after fixes/); - assert.equal(code, 0); + assert.equal(code, 1); + assert.ok(!calls.some((c) => c.cmd === "docker" && ["run", "volume", "start", "compose"].includes(c.args[0]) && c.args.join(" ").includes("valkey")), "nothing started for Valkey"); + assert.deepEqual(prompts, [], "no offer to accept"); + assert.match(text(), /✗ Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n → run `pi-dispatch up` in the deployment folder: it starts Valkey with the deployment's VALKEY_PASSWORD \(or, in a folder \/dispatch setup handed to compose, compose's own: `docker compose -p --env-file \.env -f deploy\/docker-compose\.yml -f deploy\/docker-compose\.valkey\.yml up -d valkey`\)\n/); }); test("doctor prints which resume bounds are on, so an unset one is legible as a choice", async () => { @@ -2744,7 +2720,6 @@ const ALLOWED_FIXACTIONS = [ [/^Job image present \(pi-job:latest\)$/, "prompt"], // Issue #433: the podman venue's own image line, the image check of a deployment without `local`. [/^podman: job image is not in this account's Podman store \(pi-job:latest\)$/, "prompt"], - [/^Valkey reachable \(redis:\/\/(127\.0\.0\.1|localhost)(:6379)?\/?\)$/, "prompt"], [/^Overlay is credential-free \(no auth\.json\)$/, "prompt"], [/^Staged packages manifest readable \(/, "prompt"], [/^Staged packages present \(/, "prompt"], @@ -2834,10 +2809,10 @@ test("doctor --fix doctrine: from a fully-broken env, no check outside the allow const carried = assertFixActionDoctrine(checks); // The fixture must actually reach every eligible check -- a triggers-parse regression swallowed to // zeroes would otherwise hollow this pin out silently. - for (const expected of [/^\.env present$/, /^Job image present \(pi-job:latest\)$/, /^Valkey reachable/, /^Overlay is credential-free/, /^Staged packages present/, /^Session store does not exist/]) { + for (const expected of [/^\.env present$/, /^Job image present \(pi-job:latest\)$/, /^Overlay is credential-free/, /^Staged packages present/, /^Session store does not exist/]) { assert.ok(carried.some((l) => expected.test(l)), `fixture failed to produce a fixAction for ${expected}`); } - assert.equal(carried.length, 6, "exactly the eligible checks carry one, no more"); + assert.equal(carried.length, 5, "exactly the eligible checks carry one, no more"); // The nevers, by name: each of these IS failing here and still gets no offer. const never = (re, why) => { const c = checks.find((x) => re.test(x.label)); @@ -5723,34 +5698,6 @@ test("doctor: a trigger image whose daemon did not answer says so too (#397)", a assert.match(line.label, /my-python:1\.2\.0.*the daemon did not answer/, "and an unanswered read says so rather than reading as absent"); }); -test("doctor: the valkey --fix `docker run` gets the PULL bound, because it may fetch an image (#397)", async () => { - // `docker run valkey/valkey:8` on a host that does not have that image PULLS it first, so this is the - // second fetching action and it needs the same bound as the job-image pull. Pinned by the clock, as a - // floor: on the default bound this would give up at `cmd`. - const cwd = scaffoldedCwd(); - const checks = await collectChecks( - { ...imgEnv(), VALKEY_URL: "redis://127.0.0.1:6379" }, - { - cwd, - spawn: fakeSpawn(green), - probeValkey: async () => false, - fileExists: existsSync, - nodeVersion: "22.19.0", - platform: "linux", - home: tempDir("pi-397-valkey-"), - out: () => {}, - runTimeouts: FAST_TIMEOUTS, - }, - ); - const valkey = checks.find((c) => c.fixAction?.describe?.startsWith("docker run")); - assert.ok(valkey, "the unreachable-valkey check offers the start"); - const began = Date.now(); - const res = await valkey.fixAction.run({ spawn: fakeSpawn({ docker: "hang" }), cwd, env: imgEnv() }); - const took = Date.now() - began; - assert.equal(res.ok, false); - assert.ok(took >= FAST_TIMEOUTS.pull - 5, `waited ${took}ms, which is the PULL bound (${FAST_TIMEOUTS.pull}) and not the default (${FAST_TIMEOUTS.cmd})`); -}); - test("doctor: a line the reader cannot model is said ONCE for the file, naming both keys (#396)", async () => { // It was said once per KEY, inside the per-key loop, so one unreadable line produced two near-identical // warnings differing only in which key they named. The fact is about the FILE, and nothing pinned the @@ -7437,11 +7384,11 @@ test("podman-only: Valkey's fix points at the podman route, and --fix never runs assert.doesNotMatch(text(), /docker compose|docker run|fix available: Valkey/); assert.deepEqual(asked, []); assert.deepEqual(calls.filter((c) => c.cmd === "docker"), []); - // With local blessed, the docker route is offered exactly as before. + // With local blessed, the docker route is named (issue #468 over #471: `pi-dispatch up`, never an offer doctor runs). const mixed = capture(); await runDoctor(podmanEnv({ PI_BACKENDS: "local,podman" }), { ...podmanDeps(mixed.out, { ...green, ...podmanPlan() }, [], { probeValkey: async () => false }), fix: true, promptFn: async () => false }); - assert.match(mixed.text(), /✗ Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n {4}→ docker compose -f deploy\/docker-compose\.yml up -d\n/); - assert.match(mixed.text(), /fix available: Valkey reachable/); + assert.match(mixed.text(), /✗ Valkey reachable \(redis:\/\/127\.0\.0\.1:6379\)\n {4}→ run `pi-dispatch up` in the deployment folder/); + assert.doesNotMatch(mixed.text(), /fix available: Valkey reachable/); }); test("podman-only: the fleet digest is the podman store's own id, read with no docker spawn (#433)", async () => { @@ -7989,7 +7936,7 @@ test("doctor's .env reads pass ONE allowlist and ONE loader mapping (#453 gate 3 // PR #466 gate round 2 added the GitHub auth source and App keys; issue #464 the jobs and sandbox dirs, and in its gate // round 1 the Valkey opt-in, the TMPDIR the default jobs root lives under, and the worker's name. // Issue #471: and every other key the service reads that doctor judges, the worker's and the receiver's. - assert.deepEqual([...SERVICE_ENV_KEYS], ["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY", "VALKEY_URL", "PI_VALKEY_SHARED", "PI_PROVIDER", "GITHUB_AUTH_SOURCE", "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_PRIVATE_KEY", "PI_JOBS_DIR", "PI_SANDBOX_DIR", "TMPDIR", "PI_WORKER_NAME", ...WORKER_SERVICE_KEYS, ...RECEIVER_SERVICE_KEYS, ...new Set(Object.values(CLI_SERVICE_KEYS).flat())]); + assert.deepEqual([...SERVICE_ENV_KEYS], ["PI_BACKENDS", "PI_EGRESS", "PI_EGRESS_PROXY", "VALKEY_URL", "PI_VALKEY_SHARED", "VALKEY_PASSWORD", "PI_VALKEY_PORT", "PI_PROVIDER", "GITHUB_AUTH_SOURCE", "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_PRIVATE_KEY", "PI_JOBS_DIR", "PI_SANDBOX_DIR", "TMPDIR", "PI_WORKER_NAME", ...WORKER_SERVICE_KEYS, ...RECEIVER_SERVICE_KEYS, ...new Set(Object.values(CLI_SERVICE_KEYS).flat())]); for (const key of ["PI_JOB_IMAGE", "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "AZURE_ORG_URL", "AZURE_WEBHOOK_MODE", "WEBHOOK_SECRET", "RECEIVER_PORT", "GITHUB_PAT_VAR"]) { assert.ok(SERVICE_ENV_KEYS.includes(key), `${key}, which issue #471 lists, is a service key`); } @@ -9363,7 +9310,7 @@ test("no program doctor starts receives an environment derived from .env values const marker = (k) => `from-env-${k.toLowerCase()}-${k.length}`; const cliKeys = [...new Set(Object.values(CLI_SERVICE_KEYS).flat())]; - const envText = [...cliKeys.map((k) => `${k}=/${marker(k)}`), "GITHUB_AUTH_SOURCE=pat", `GITHUB_PAT=${marker("PAT")}`, `WEBHOOK_SECRET=${marker("WH")}`, `PI_JOB_IMAGE=${marker("IMG")}:1`, "PI_BACKENDS=local,podman"].join("\n") + "\n"; + const envText = [...cliKeys.map((k) => `${k}=/${marker(k)}`), "GITHUB_AUTH_SOURCE=pat", `GITHUB_PAT=${marker("PAT")}`, `WEBHOOK_SECRET=${marker("WH")}`, `PI_JOB_IMAGE=${marker("IMG")}:1`, "PI_BACKENDS=local,podman", `VALKEY_PASSWORD=${marker("VALKEYPW")}`].join("\n") + "\n"; for (const [shell, deps] of [ [ghEnv(), (out, calls, cwd) => ({ ...ghDeps(out, { ...green, "docker run": 0, "gh auth status": { code: 0, output: ghStatusOutput }, "gh auth token": { code: 0, output: "gho_shell\n" } }, calls), cwd, agentDir: NO_AGENT_DIR })], [podmanEnv({ PI_BACKENDS: "local,podman" }), (out, calls, cwd) => podmanDeps(out, { ...podmanPlan(), ...green }, calls, { cwd })], @@ -9381,7 +9328,10 @@ test("no program doctor starts receives an environment derived from .env values } // Each CLI variable is named once, as not handed on; its value shown only for a path. for (const k of cliKeys) assert.equal(r.text().split("\n").filter((l) => l.startsWith(`⚠ ${k} is set in `)).length, 1, `${k}: ${r.text()}`); - assert.doesNotMatch(r.text(), /from-env-gh_token|from-env-github_token|from-env-container_host|from-env-docker_host|from-env-docker_context|from-env-container_connection|from-env-gh_host|from-env-pat|from-env-wh/, "no endpoint, name or token value is printed"); + assert.doesNotMatch(r.text(), /from-env-gh_token|from-env-github_token|from-env-container_host|from-env-docker_host|from-env-docker_context|from-env-container_connection|from-env-gh_host|from-env-pat|from-env-wh|from-env-valkeypw/, "no endpoint, name or token value is printed"); + // Issue #468: the Valkey password is a service key, secret, named only: read as a service setting, never printed, + // and never handed to a child (the loop above), since doctor starts no Valkey. + assert.match(r.text(), /service settings read from [^\n]*VALKEY_PASSWORD/); assert.match(r.text(), /⚠ XDG_CONFIG_HOME is set in [^\n]* \(\/from-env-xdg_config_home-15\)/, "a path's value is shown"); assert.doesNotMatch(r.text(), /service settings read from[^\n]*(CONTAINERS_CONF|DOCKER_HOST|GH_TOKEN)/, "a CLI variable is never listed as read"); } @@ -9430,3 +9380,111 @@ test("this account's own unix socket in .env is no failure: named as not handed assert.match(r.text(), /^⚠ DOCKER_HOST is set in /m); assert.ok(!calls.some((c) => c.opts?.env?.CONTAINER_HOST || c.opts?.env?.DOCKER_HOST)); }); + +// PR #475's review, round 3: PI_VALKEY_PORT is where compose publishes its Valkey; one that disagrees with VALKEY_URL's +// port (in .env, or this shell, which wins over --env-file) moves the queue off the worker's port on the next compose run. +test("doctor flags a PI_VALKEY_PORT that disagrees with VALKEY_URL's port, in .env or this shell (#475 round 3)", async () => { + const dir = tempDir("doctor-valkey-port-"); + writeFileSync(join(dir, ".env"), "VALKEY_URL=redis://127.0.0.1:16495\nPI_VALKEY_PORT=16000\n"); + const run = async (env) => { + const { out, text } = capture(); + await runDoctor({ PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-x", GITHUB_AUTH_SOURCE: "pat", ...env }, { out, cwd: dir, spawn: fakeSpawn(green), probeValkey: async () => true, fileExists: () => true, nodeVersion: "22.19.0" }); + return text(); + }; + const t1 = await run({}); + assert.ok(t1.includes(`⚠ PI_VALKEY_PORT is 16000 in ${join(dir, ".env")}, and VALKEY_URL's port is 16495: compose publishes its Valkey on 16000, where the worker does not dial.`), t1); + const t2 = await run({ PI_VALKEY_PORT: "17000" }); + assert.match(t2, /⚠ PI_VALKEY_PORT is 17000 in this shell, and VALKEY_URL's port is 16495/); + writeFileSync(join(dir, ".env"), "VALKEY_URL=redis://127.0.0.1:16495\nPI_VALKEY_PORT=16495\n"); + assert.doesNotMatch(await run({}), /PI_VALKEY_PORT is/, "agreeing: nothing said"); + writeFileSync(join(dir, ".env"), "VALKEY_URL=redis://valkey.internal:16495\nPI_VALKEY_PORT=16000\n"); + assert.doesNotMatch(await run({}), /PI_VALKEY_PORT is/, "another host's port is not compose's"); +}); + +const PW468 = "7a11ed0c".repeat(8); +async function doctorAuth({ auth, open, passwordSet = false, env = {}, envFile = null, mode = 0o600 }) { + const cwd = scaffoldedCwd(); + if (envFile !== null) writeFileSync(join(cwd, ".env"), envFile); + const asked = []; + const probed = []; + const { out, text } = capture(); + const code = await runDoctor( + { PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-x", GITHUB_AUTH_SOURCE: "pat", ...env }, + { + out, + cwd, + spawn: fakeSpawn(green), + probeValkey: async (url) => (probed.push(url), true), + valkeyAuth: async (url, opts = {}) => (asked.push([url, opts.withoutPassword === true]), opts.withoutPassword ? { state: open, passwordSet: false } : { state: auth, passwordSet, from: passwordSet ? join(cwd, ".env") : null }), + nodeVersion: "22.19.0", + readEnvFile: (path) => readFileSync(path, "utf8"), + stat: (p) => (p.endsWith(".env") ? { mode: 0o100000 | mode } : statSync(p)), + }, + ); + return { code, text: text(), asked, probed }; +} + +test("doctor (#468): a Valkey that requires a password this deployment does not send, or refuses the one it sends, is a ✗ naming the key, never the value", async () => { + const none = await doctorAuth({ auth: "noauth" }); + assert.equal(none.code, 1); + assert.match(none.text, /✗ Valkey \(redis:\/\/127\.0\.0\.1:6379\) answers, and requires a password this deployment does not send: the worker refuses to start on it \(exit 2\)\n\s+→ put VALKEY_PASSWORD= in [^\n]*\.env \(for a Valkey shared with PI_VALKEY_SHARED=1, the password of the account that runs it\)/); + assert.doesNotMatch(none.text, /Valkey reachable/, "not \"unreachable\", and no offer to start a second one"); + assert.deepEqual(none.probed, []); + const wrong = await doctorAuth({ auth: "wrongpass", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n` }); + assert.equal(wrong.code, 1); + assert.match(wrong.text, /✗ Valkey \(redis:\/\/127\.0\.0\.1:6379\) answers, and refuses the VALKEY_PASSWORD this deployment sends/); + assert.ok(!wrong.text.includes(PW468), "the value is never printed"); +}); + +test("doctor (#468): a loopback Valkey that answers a client sending NO password is a warning with the upgrade step; one that requires it is a ✓", async () => { + const open = await doctorAuth({ auth: "ok", open: "ok" }); + assert.match(open.text, /⚠ Valkey \(redis:\/\/127\.0\.0\.1:6379\) has no password: any local account can read, enqueue or delete this deployment's jobs \(VALKEY_PASSWORD is not set\)\n\s+→ run `pi-dispatch up`: it writes a VALKEY_PASSWORD into \.env and restarts pi-dispatch-valkey with it/); + assert.deepEqual(open.asked.map((a) => a[1]), [false, true], "asked as this deployment, then as a client with none"); + const stale = await doctorAuth({ auth: "ok", open: "ok", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n` }); + assert.match(stale.text, /⚠ Valkey \(redis:\/\/127\.0\.0\.1:6379\) answers a client that sends no password, although VALKEY_PASSWORD is set: it was started before the password existed/); + const guarded = await doctorAuth({ auth: "ok", open: "noauth", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n` }); + assert.match(guarded.text, /✓ Valkey \(redis:\/\/127\.0\.0\.1:6379\) requires a password, and the one this deployment sends \(VALKEY_PASSWORD from [^\n]*\/\.env\) is accepted \(the value is not shown\)/, "names where the password came from"); + for (const r of [open, stale, guarded]) assert.ok(!r.text.includes(PW468)); + // Another machine's Valkey is the operator's own to secure: not judged. + const remote = await doctorAuth({ auth: "ok", open: "ok", env: { VALKEY_URL: "redis://queue.lan:6379" } }); + assert.doesNotMatch(remote.text, /has no password|answers a client that sends no password/); + assert.equal(remote.asked.length, 1, "no second probe for a remote Valkey"); + // The upgrade step names the command that restarts THIS venue's Valkey. + assert.match(valkeyPasswordUpgradeStep({ localUsed: false, podmanUsed: true }), /^run `pi-dispatch service install --force` as this account: it writes a VALKEY_PASSWORD into \.env, restarts the Quadlet Valkey with it \(the queue in its volume is kept\) and then the worker and the receiver$/); + assert.match(valkeyPasswordUpgradeStep({ localUsed: true, podmanUsed: false }), /docker compose --env-file \.env -f deploy\/docker-compose\.yml up -d/); +}); + +test("doctor (#468): a .env holding VALKEY_PASSWORD that others can read is a warning, and a value Valkey could not take is a ✗", async () => { + const wide = await doctorAuth({ auth: "ok", open: "noauth", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n`, mode: 0o644 }); + assert.match(wide.text, /⚠ [^\n]*\.env holds VALKEY_PASSWORD and is readable by every account on this host \(mode 644\)\n\s+→ chmod 600 [^\n]*\.env/); + const group = await doctorAuth({ auth: "ok", open: "noauth", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n`, mode: 0o640 }); + assert.match(group.text, /readable by its group \(mode 640\)/); + const tight = await doctorAuth({ auth: "ok", open: "noauth", passwordSet: true, envFile: `VALKEY_PASSWORD=${PW468}\n` }); + assert.doesNotMatch(tight.text, /holds VALKEY_PASSWORD and is readable/); + const bad = await doctorAuth({ auth: "ok", open: "noauth", passwordSet: true, envFile: "VALKEY_PASSWORD=tooshort\n" }); + assert.match(bad.text, /✗ [^\n]*\.env cannot be handed to Valkey: it is 8 characters long/); + assert.doesNotMatch(bad.text, /tooshort/); +}); + +// Issue #468 over issue #471's rule: doctor's own in-process client sends the RESOLVED password (this shell's, else the +// file's, the file's to a loopback Valkey only), and no program doctor starts is handed it. +test("doctor (#468 over #471): the AUTH check's context carries the resolved password, a disagreement never prints it, and no child is handed it", async () => { + const cwd = scaffoldedCwd(); + writeFileSync(join(cwd, ".env"), `VALKEY_PASSWORD=${PW468}\n`); + const contexts = []; + const calls = []; + const run = async (env, url = "redis://127.0.0.1:6379") => { + const { out, text } = capture(); + contexts.length = 0; + await runDoctor({ PI_PROVIDER: "anthropic", ANTHROPIC_API_KEY: "sk-x", GITHUB_AUTH_SOURCE: "pat", VALKEY_URL: url, ...env }, { out, cwd, spawn: fakeSpawn(green, calls), probeValkey: async () => true, valkeyAuth: async (_u, opts = {}) => (contexts.push(opts.context), { state: opts.withoutPassword ? "noauth" : "ok", passwordSet: true, from: "x" }), nodeVersion: "22.19.0" }); + return text(); + }; + await run({}); + assert.equal(valkeyPasswordFor("redis://127.0.0.1:6379", contexts[0]).password, PW468, "the file's, to a loopback Valkey"); + assert.equal(valkeyPasswordFor("redis://queue.lan:6379", contexts[0]).password, null, "never the file's to another host"); + const both = await run({ VALKEY_PASSWORD: "shellside0123456789" }); + assert.equal(valkeyPasswordFor("redis://127.0.0.1:6379", contexts[0]).password, "shellside0123456789", "this shell's where it sets one"); + assert.match(both, /VALKEY_PASSWORD is set differently in this shell and in [^\n]*\.env \(neither value is shown\)/); + assert.ok(!both.includes(PW468) && !both.includes("shellside0123456789"), "neither value printed"); + for (const c of calls) for (const v of Object.values(c.opts?.env ?? {})) assert.notEqual(String(v), PW468, `${c.cmd} ${c.args.join(" ")} was handed the file's password`); +}); diff --git a/worker/test/env-docs.test.mjs b/worker/test/env-docs.test.mjs index 96e8cae8..a0f4cc69 100644 --- a/worker/test/env-docs.test.mjs +++ b/worker/test/env-docs.test.mjs @@ -553,8 +553,8 @@ test("no assignment in .env.example carries an inline comment, live or commented "an inline comment is part of the value to systemd and to compose: put the comment on its own line above the key", ); // NON-VACUITY, the discipline this file already keeps for its other scans: a regex that matches nothing - // passes every assertion above it. 96 is the count both mirrors carry today. - assert.equal(scanned, 96, "the scan must actually reach every assignment in the file"); + // passes every assertion above it. 98 is the count both mirrors carry today (VALKEY_PASSWORD and PI_VALKEY_PORT joined in issue #468). + assert.equal(scanned, 98, "the scan must actually reach every assignment in the file"); assert.match(" PI_X=1 # c", /^\s*(# )?([A-Z_][A-Z0-9_]*)=(.*)$/, "and the shape it scans for is the shape the file uses, indented or not"); // systemd drops an `export` line entirely (measured: "Ignoring invalid environment assignment"), and a BOM diff --git a/worker/test/init.test.mjs b/worker/test/init.test.mjs index bc3daaa4..33ed8341 100644 --- a/worker/test/init.test.mjs +++ b/worker/test/init.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { runInit } from "../src/init.mjs"; @@ -61,14 +61,35 @@ test("init without a cwd .env.example falls back to the copy shipped with the pa const dir = tmp(); const { out, text } = capture(); - const code = runInit(dir, { out }); + const code = runInit(dir, { out, newPassword: () => PASSWORD }); assert.equal(code, 0); const packaged = readFileSync(fileURLToPath(new URL("../.env.example", import.meta.url)), "utf8"); - assert.equal(readFileSync(join(dir, ".env"), "utf8"), packaged, ".env is the packaged worker/.env.example, byte for byte"); + // Issue #468: byte for byte but for the one line init fills in, the deployment's own Valkey password. + assert.equal(readFileSync(join(dir, ".env"), "utf8"), packaged.replace("\n# VALKEY_PASSWORD=\n", `\nVALKEY_PASSWORD=${PASSWORD}\n`), ".env is the packaged worker/.env.example, byte for byte, with its password"); assert.match(text(), /created\s+\.env/, "the fallback still reports .env as created"); }); +// Issue #468: a new .env carries the deployment's own Valkey password, is readable by this account alone, never shows +// the value, and an existing one is left as it is (init's contract; `up` and `service install` add a missing password). +const PASSWORD = "0f1e2d3c".repeat(8); +test("init writes a new .env at mode 0600 with a generated VALKEY_PASSWORD it never prints, and never touches an existing one (#468)", () => { + const dir = tmp(); + writeFileSync(join(dir, ".env.example"), "ANTHROPIC_API_KEY=\n# the password\n# VALKEY_PASSWORD=\n"); + const { out, text } = capture(); + assert.equal(runInit(dir, { out, newPassword: () => PASSWORD }), 0); + assert.equal(readFileSync(join(dir, ".env"), "utf8"), `ANTHROPIC_API_KEY=\n# the password\nVALKEY_PASSWORD=${PASSWORD}\n`); + if (process.platform !== "win32") assert.equal(statSync(join(dir, ".env")).mode & 0o777, 0o600, "readable by this account alone"); + assert.ok(!text().includes(PASSWORD), "the value is never printed"); + assert.match(text(), /created\s+\.env\s+from \.env\.example, mode 0600, with a generated VALKEY_PASSWORD \(value not shown\)/); + // A second run keeps the file, password and all: init never overwrites. + assert.equal(runInit(dir, { out: () => {}, newPassword: () => "f".repeat(64) }), 0); + assert.match(readFileSync(join(dir, ".env"), "utf8"), new RegExp(`^VALKEY_PASSWORD=${PASSWORD}$`, "m")); + // Every password init can generate reads back the same under every loader of the file (the hex rule, #447's writer). + const example = readFileSync(fileURLToPath(new URL("../.env.example", import.meta.url)), "utf8"); + assert.match(example, /^# VALKEY_PASSWORD=$/m, "the shipped example carries the commented line init fills in"); +}); + test("init scaffolds an egress allowlist that WORKS, not an empty one", () => { const dir = tmp(); writeFileSync(join(dir, ".env.example"), "ANTHROPIC_API_KEY=\n"); @@ -94,12 +115,14 @@ test("init never overwrites an edited allowlist", () => { // --- issue #453: the next steps follow the venue ------------------------------------------------------------------------ -// Byte for byte what init printed before #453, and still prints for every venue set that includes `local`. +// Byte for byte what init printed before #453, and still prints for every venue set that includes `local` (step 2 names the +// .env compose reads VALKEY_PASSWORD from since #468). const DOCKER_NEXT = ` Next: 1. docker pull ghcr.io/edgehero/pi-job:latest && docker tag ghcr.io/edgehero/pi-job:latest pi-job:latest # the prebuilt job image (or build image/Dockerfile) - 2. docker compose -f deploy/docker-compose.yml up -d # the durable queue (Valkey) + 2. docker compose --env-file .env -f deploy/docker-compose.yml up -d + # the durable queue (Valkey, with the password init wrote) 3. edit .env # set ANTHROPIC_API_KEY (or your provider's key) 4. pi-dispatch doctor # verify Docker, Valkey, image, and key 5. pi-dispatch worker # drain the queue diff --git a/worker/test/podman-stack.test.mjs b/worker/test/podman-stack.test.mjs index 8899c0fa..07af32ad 100644 --- a/worker/test/podman-stack.test.mjs +++ b/worker/test/podman-stack.test.mjs @@ -4,7 +4,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { DEFAULT_EGRESS_PROXY } from "../src/egress.mjs"; -import { ALL_QUADLET_FILES, DETACH_GATE_READ_MAX_BUFFER, DETACH_GATE_READ_TIMEOUT_MS, NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_NOW_FORMAT, detachBlockedSentence, makeDetachGate, runtimeFromFacts, QUADLET_FILES, managerEnvRefusal, unquoteShowEnvironment, planStack, podmanNeedsNetnsKeeper, quadletDir, readStackKeys, stackComponents, STACK_KEYS, decideValkey, describeRollBack, judgeValkeyListeners, listenerUids, pinnedValkeyUrl, reachedAddress, readSubuidRanges, readValkeyKeys, resolveWorkerValkey, rollBackWrites, subordinateUids, unplainCause, valkeyTarget } from "../src/podman-stack.mjs"; +import { ALL_QUADLET_FILES, DETACH_GATE_READ_MAX_BUFFER, DETACH_GATE_READ_TIMEOUT_MS, NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_NOW_FORMAT, detachBlockedSentence, makeDetachGate, runtimeFromFacts, QUADLET_FILES, managerEnvRefusal, unquoteShowEnvironment, planStack, podmanNeedsNetnsKeeper, quadletDir, readStackKeys, stackComponents, STACK_KEYS, decideValkey, describeRollBack, judgeValkeyListeners, listenerUids, pinnedValkeyUrl, reachedAddress, readSubuidRanges, readValkeyKeys, resolveWorkerValkey, rollBackWrites, subordinateUids, unplainCause, valkeyTarget, journalWrite, valkeyPasswordRestartWarning } from "../src/podman-stack.mjs"; import { SYSTEMD_259_ENV_BYTES, SYSTEMD_259_ENV_FILES } from "./helpers/systemd-env-259.mjs"; import { systemdEnvFile } from "./helpers/systemd-env-parse.mjs"; import { runService } from "../src/service.mjs"; @@ -23,6 +23,9 @@ const HOME = "/home/tester"; const QDIR = "/home/tester/.config/containers/systemd"; const USER_UNIT = "/home/tester/.config/systemd/user/pi-dispatch-worker.service"; const ENV_PATH = `${DEPLOY_AT}/.env`; +// Issue #468: the password the harnesses generate, and where the Quadlet Valkey reads it. +const TEST_PASSWORD = "0123456789abcdef".repeat(4); +const VALKEY_ENV = "/home/tester/.config/pi-dispatch/valkey.env"; const ALLOWLIST = `${DEPLOY_AT}/egress-allowlist.conf`; const template = (name) => readFileSync(join(DEPLOY_DIR, name), "utf8"); @@ -190,7 +193,10 @@ const realReadFs = (files = {}) => { test("stackComponents: Valkey only without local; the proxy only while armed and only under the default name; the keeper whenever armed", () => { const podmanOnly = { localUsed: false, podmanUsed: true }; const mixed = { localUsed: true, podmanUsed: true }; - assert.deepEqual(stackComponents({ venues: podmanOnly, env: {}, includeValkey: true, armed: true }), { valkey: true, proxy: true, keeper: true, notes: [], valkeyPort: 6379 }); + assert.deepEqual(stackComponents({ venues: podmanOnly, env: {}, includeValkey: true, armed: true }), { valkey: true, proxy: true, keeper: true, notes: [], valkeyPort: 6379, valkeyPassword: null }); + // Issue #468: the password rides with the Valkey, and never without it. + assert.equal(stackComponents({ venues: podmanOnly, env: {}, includeValkey: true, armed: false, valkeyPassword: TEST_PASSWORD }).valkeyPassword, TEST_PASSWORD); + assert.equal(stackComponents({ venues: mixed, env: {}, includeValkey: true, armed: false, valkeyPassword: TEST_PASSWORD }).valkeyPassword, null); assert.equal(stackComponents({ venues: mixed, env: {}, includeValkey: true, armed: true }).valkey, false, "docker's Valkey owns the port when local is blessed"); assert.equal(stackComponents({ venues: mixed, env: {}, includeValkey: true, armed: true }).keeper, true, "podman jobs on a mixed host disconnect the proxy too"); const off = stackComponents({ venues: podmanOnly, env: {}, includeValkey: true, armed: false }); @@ -208,8 +214,11 @@ test("planStack: the proxy mounts an ACCOUNT-OWNED copy of the rules and the dep const fs = realReadFs(); const plan = planStack({ components: { valkey: true, proxy: true }, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs }); assert.equal(plan.dir, QDIR); + // Issue #468: the Valkey's 0600 password file sits right before its unit, which reads it at start. assert.deepEqual(plan.files.map((f) => f.path), [ - ...[QUADLET_FILES.valkeyNetwork, QUADLET_FILES.valkey, QUADLET_FILES.egressNetwork].map((q) => join(QDIR, q.file)), + join(QDIR, QUADLET_FILES.valkeyNetwork.file), + VALKEY_ENV, + ...[QUADLET_FILES.valkey, QUADLET_FILES.egressNetwork].map((q) => join(QDIR, q.file)), CONF_COPY, join(QDIR, QUADLET_FILES.proxy.file), ]); @@ -303,10 +312,13 @@ function svc({ argv = ["install"], files = {}, plan = {}, listening = false, lis const err = []; const store = new Map(Object.entries(files)); const writes = []; + // Issue #468: file modes as the fake fs keeps them (the Valkey password file is written 0600, .env narrowed). + const modes = new Map(); const deps = { env, platform, euid: 1234, + newPassword: () => TEST_PASSWORD, execPath: "/fake/node", cwd, moduleDir: WORKER_SRC, @@ -339,10 +351,11 @@ function svc({ argv = ["install"], files = {}, plan = {}, listening = false, lis } return store.has(p) ? store.get(p) : readFileSync(p, enc); }, - writeFileSync: (p, d) => { + writeFileSync: (p, d, opts) => { writes.push(p); // A stored Error for a path is one this account cannot write (issue #464's injected write failure). if (failWrites.has(p)) throw failWrites.get(p); + if (!store.has(p)) modes.set(p, opts?.mode ?? 0o644); store.set(p, d); }, mkdirSync: () => {}, @@ -350,10 +363,24 @@ function svc({ argv = ["install"], files = {}, plan = {}, listening = false, lis if (failUnlinks.has(p)) throw failUnlinks.get(p); store.delete(p); }, + chmodSync: (p, m) => { + modes.set(p, m); + }, + statSync: (p) => { + if (!store.has(p)) throw Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" }); + return { mode: 0o100000 | (modes.get(p) ?? 0o644), uid: process.getuid?.() ?? 0, gid: process.getgid?.() ?? 0 }; + }, + renameSync: (from, to) => { + writes.push(to); + store.set(to, store.get(from)); + modes.set(to, modes.get(from)); + store.delete(from); + modes.delete(from); + }, }, ...extra, }; - return { run: () => runService(argv, deps), calls, store, writes, text: () => out.join(""), errText: () => err.join(""), failWrites, failUnlinks, probed }; + return { run: () => runService(argv, deps), calls, store, writes, modes, text: () => out.join(""), errText: () => err.join(""), failWrites, failUnlinks, probed }; } test("service install (user scope, podman in .env, egress armed): writes the Quadlets, starts them (never enables), and the worker Wants/After them", async () => { @@ -805,8 +832,11 @@ test("service install on podman: a write that fails puts back every file this ru assert.equal(await h.run(), 1); const valkey = join(QDIR, QUADLET_FILES.valkey.file); const network = join(QDIR, QUADLET_FILES.valkeyNetwork.file); - assert.equal(h.errText(), `error: write ${USER_UNIT} failed (EACCES: permission denied, open '${USER_UNIT}'), so nothing was installed: rolled back what this run wrote (removed ${valkey}, ${network}); no file of this run remains. Fix it, then re-run this install\n`, "the refused unit write changed nothing, so only the stack's two files are named"); - for (const p of [valkey, network, USER_UNIT]) assert.ok(!h.store.has(p), `${p} is gone`); + // Issue #468: the password this install generated into .env and the Valkey's password file are this run's too, and + // put back with the rest (.env to the bytes it had, the password file removed). + assert.equal(h.errText(), `error: write ${USER_UNIT} failed (EACCES: permission denied, open '${USER_UNIT}'), so nothing was installed: rolled back what this run wrote (removed ${valkey}, ${VALKEY_ENV}, ${network}; put back the earlier ${ENV_PATH}); no file of this run remains. Fix it, then re-run this install\n`, "the refused unit write changed nothing, so only the stack's files and .env are named"); + for (const p of [valkey, network, VALKEY_ENV, USER_UNIT]) assert.ok(!h.store.has(p), `${p} is gone`); + assert.equal(h.store.get(ENV_PATH), `${PODMAN_ENV}PI_EGRESS=0\n`, ".env has no password line left from the failed run"); assert.deepEqual(h.calls.filter((c) => c[0] === "systemctl" && c[2] !== "show-environment"), [], "nothing was started or reloaded"); }); @@ -821,8 +851,9 @@ test("service install --force on podman: a failed write puts back the files it r assert.equal(h.store.get(valkey), "# an operator's edit\n", "the replaced file has its old bytes back"); assert.equal(h.store.get(USER_UNIT), "old unit\n"); assert.ok(h.store.has(network), "the one that could not be removed is still there"); - assert.match(h.errText(), new RegExp(`\\(put back the earlier ${valkey.replaceAll(".", "\\.")}\\); these could NOT be put back and remain as this run wrote them: ${network.replaceAll(".", "\\.")} \\(EBUSY: resource busy\\)\\. Fix it`), "the refused write changed nothing, so it is not reported as left"); - assert.deepEqual(h.calls.filter((c) => c[0] === "systemctl" && c[2] !== "show-environment"), []); + assert.match(h.errText(), new RegExp(`\\(removed ${VALKEY_ENV.replaceAll(".", "\\.")}; put back the earlier ${valkey.replaceAll(".", "\\.")}, ${ENV_PATH.replaceAll(".", "\\.")}\\); these could NOT be put back and remain as this run wrote them: ${network.replaceAll(".", "\\.")} \\(EBUSY: resource busy\\)\\. Fix it`), "the refused write changed nothing, so it is not reported as left"); + // Issue #468: an installed Valkey getting a new password asks which clients run (read-only), and nothing else ran. + assert.deepEqual(h.calls.filter((c) => c[0] === "systemctl" && c[2] !== "show-environment" && c[2] !== "is-active"), []); }); test("service install on podman: a stack command that fails puts back only the worker unit and names the stack files that remain (#464)", async () => { @@ -832,7 +863,8 @@ test("service install on podman: a stack command that fails puts back only the w const network = join(QDIR, QUADLET_FILES.valkeyNetwork.file); assert.ok(!h.store.has(USER_UNIT), "the worker unit is not left behind"); assert.ok(h.store.has(valkey) && h.store.has(network), "the stack's files stay: its units run from them"); - assert.match(h.errText(), new RegExp(`The stack files this run wrote remain, since its units run from them: ${network.replaceAll(".", "\\.")}, ${valkey.replaceAll(".", "\\.")}\\.`)); + // Issue #468: .env keeps the password this run generated, and the password file stays with the unit that reads it. + assert.match(h.errText(), new RegExp(`The stack files this run wrote remain, since its units run from them: ${ENV_PATH.replaceAll(".", "\\.")}, ${network.replaceAll(".", "\\.")}, ${VALKEY_ENV.replaceAll(".", "\\.")}, ${valkey.replaceAll(".", "\\.")}\\.`)); assert.equal(h.calls.filter((c) => c.join(" ") === "systemctl --user daemon-reload").length, 2, "one reload before the start, one after the unit was put back"); // Gate round 1: the rollback here is of the worker unit only, so it never claims that no file of this run remains. assert.doesNotMatch(h.errText(), /no file of this run remains/); @@ -879,7 +911,7 @@ test("service install: an enable that fails after every write names the files th assert.equal(await h.run(), 1); const valkey = join(QDIR, QUADLET_FILES.valkey.file); const network = join(QDIR, QUADLET_FILES.valkeyNetwork.file); - assert.ok(h.errText().endsWith(`(files this run wrote, which remain: ${network}, ${valkey}, ${USER_UNIT})\n`), h.errText()); + assert.ok(h.errText().endsWith(`(files this run wrote, which remain: ${ENV_PATH}, ${network}, ${VALKEY_ENV}, ${valkey}, ${USER_UNIT})\n`), h.errText()); }); test("rollBackWrites: the FIRST record of a path is what is put back, and describeRollBack says what remains", () => { @@ -1021,11 +1053,14 @@ test("D3: a hand-started proxy is foreign too, and --force warns that running jo }); test("D5: --force over a changed .container RESTARTS that unit (start is a no-op on an active one), and warns for the proxy", async () => { - const planned = planStack({ components: { valkey: true, proxy: true, keeper: true }, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: realReadFs() }); - const files = { [ENV_PATH]: PODMAN_ENV, [ALLOWLIST]: "x\n", [join(QDIR, QUADLET_FILES.valkey.file)]: planned.files[1].text }; + // Issue #468: a deployment that already has its password, and a password file that already holds it, so only the + // proxy's file differs. + const planned = planStack({ components: { valkey: true, proxy: true, keeper: true, valkeyPassword: TEST_PASSWORD }, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: realReadFs() }); + const files = { [ENV_PATH]: `${PODMAN_ENV}VALKEY_PASSWORD=${TEST_PASSWORD}\n`, [ALLOWLIST]: "x\n" }; for (const f of planned.files) files[f.path] = f.text; files[join(QDIR, QUADLET_FILES.proxy.file)] = "[Container]\nImage=old\n"; const h = svc({ argv: ["install", "--force"], files, listening: true }); + h.modes.set(VALKEY_ENV, 0o600); assert.equal(await h.run(), 0, h.errText()); assert.deepEqual(h.calls.filter((c) => (c[0] === "systemctl" && c[2] !== "show-environment")).slice(0, 3), [ ["systemctl", "--user", "daemon-reload"], @@ -1043,7 +1078,9 @@ test("D5: --force over a changed .container RESTARTS that unit (start is a no-op test("planStack (#458): the keeper's two files follow the proxy's, the rules copy stays right before the proxy, and the keeper is started", () => { const all = planStack({ components: { valkey: true, proxy: true, keeper: true }, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: realReadFs() }); assert.deepEqual(all.files.map((f) => f.path), [ - ...[QUADLET_FILES.valkeyNetwork, QUADLET_FILES.valkey, QUADLET_FILES.egressNetwork].map((q) => join(QDIR, q.file)), + join(QDIR, QUADLET_FILES.valkeyNetwork.file), + VALKEY_ENV, + ...[QUADLET_FILES.valkey, QUADLET_FILES.egressNetwork].map((q) => join(QDIR, q.file)), CONF_COPY, ...[QUADLET_FILES.proxy, QUADLET_FILES.keeperNetwork, QUADLET_FILES.keeper].map((q) => join(QDIR, q.file)), ]); @@ -1704,3 +1741,121 @@ test("makeDetachGate reads ONCE per pass, an unanswered read included, and takes assert.match(detachBlockedSentence("keeper-not-holding", "docker"), /rootless Podman 4\.x this shell's docker CLI reaches/); assert.match(detachBlockedSentence("runtime-unreadable", "podman"), /this shell's podman CLI could not say which container runtime/); }); + +// --------------------------------------------------------------------------------------------------------------------- +// Issue #468: the Quadlet Valkey's password +// --------------------------------------------------------------------------------------------------------------------- + +test("planStack (#468): the Valkey's password file is planned 0600 before its unit, and a change or a wide mode restarts Valkey and only then its running clients", () => { + const components = { valkey: true, proxy: false, keeper: false, valkeyPassword: TEST_PASSWORD }; + const fresh = planStack({ components, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: realReadFs() }); + const secret = fresh.files.find((f) => f.kind === "secret"); + assert.deepEqual([secret.path, secret.mode, secret.state, secret.restarts, secret.unit], [VALKEY_ENV, 0o600, "new", "pi-dispatch-valkey.service", null]); + assert.match(secret.text, new RegExp(`^VALKEY_PASSWORD=${TEST_PASSWORD}$`, "m")); + assert.ok(fresh.files.indexOf(secret) < fresh.files.findIndex((f) => f.path.endsWith("pi-dispatch-valkey.container")), "written before the unit that reads it"); + assert.equal(planStack({ components: { ...components, valkey: false }, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: realReadFs() }).files.some((f) => f.kind === "secret"), false, "no Valkey, no password file"); + assert.equal(fresh.clientsRestarted.length, 0); + // An installed Valkey (its unit and password file on disk) whose password file now changes: Valkey restarts with it, + // then the running clients the caller named, which read VALKEY_PASSWORD only at start. + const unit = fresh.files.find((f) => f.path.endsWith("pi-dispatch-valkey.container")); + const net = fresh.files.find((f) => f.path.endsWith("pi-dispatch-valkey.network")); + const installed = (secretText, mode = 0o600) => { + const fs = realReadFs({ [unit.path]: unit.text, [net.path]: net.text, [VALKEY_ENV]: secretText }); + fs.statSync = (p) => ({ mode: 0o100000 | (p === VALKEY_ENV ? mode : 0o644) }); + return fs; + }; + const clients = ["pi-dispatch-worker.service", "pi-dispatch-receiver.service"]; + const upgrade = planStack({ components, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: installed("VALKEY_PASSWORD=\n"), restartAfterValkey: clients }); + assert.deepEqual(upgrade.restart, ["pi-dispatch-valkey.service"]); + assert.deepEqual(upgrade.actions.slice(-2).map((a) => a.argv), [["systemctl", "--user", "restart", "pi-dispatch-valkey.service"], ["systemctl", "--user", "try-restart", ...clients]], "the clients after Valkey, never before"); + assert.match(valkeyPasswordRestartWarning(upgrade), /^pi-dispatch-valkey\.service restarts with the password in \/home\/tester\/\.config\/pi-dispatch\/valkey\.env \(the queue in its volume is kept\), and pi-dispatch-worker\.service and pi-dispatch-receiver\.service restart after it to send that password\. A job running right now is interrupted: pause first/); + assert.ok(!valkeyPasswordRestartWarning(upgrade).includes(TEST_PASSWORD)); + // The same password, the file 0600: nothing moves, no client is touched, nothing is said. + const same = planStack({ components, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: installed(secret.text), restartAfterValkey: clients }); + assert.deepEqual([same.restart, same.clientsRestarted, valkeyPasswordRestartWarning(same)], [[], [], null]); + assert.ok(!same.actions.some((a) => a.kind === "write")); + // The same text in a file another account can read is rewritten (and narrowed): a password file must not stay wide. + const wide = planStack({ components, templatesDir: DEPLOY_DIR, deployDir: DEPLOY_AT, home: HOME, fs: installed(secret.text, 0o644) }); + assert.equal(wide.files.find((f) => f.kind === "secret").state, "changed"); +}); + +test("journalWrite (#468): a file with a mode is narrowed BEFORE the new text goes in, and created with it", () => { + const ops = []; + const store = new Map([["/x/valkey.env", "VALKEY_PASSWORD=\n"]]); + const fs = { existsSync: (p) => store.has(p), readFileSync: (p) => store.get(p), writeFileSync: (p, d, o) => (ops.push(["write", p, o?.mode]), store.set(p, d)), chmodSync: (p, m) => ops.push(["chmod", p, m]) }; + const journal = []; + journalWrite(fs, journal, "/x/valkey.env", `VALKEY_PASSWORD=${TEST_PASSWORD}\n`, { mode: 0o600 }); + assert.deepEqual(ops, [["chmod", "/x/valkey.env", 0o600], ["write", "/x/valkey.env", 0o600], ["chmod", "/x/valkey.env", 0o600]]); + assert.deepEqual(journal, [{ path: "/x/valkey.env", existed: true, previous: "VALKEY_PASSWORD=\n" }]); + ops.length = 0; + journalWrite(fs, null, "/x/new.env", "V=1\n", { mode: 0o600 }); + assert.deepEqual(ops, [["write", "/x/new.env", 0o600], ["chmod", "/x/new.env", 0o600]]); + ops.length = 0; + journalWrite(fs, null, "/x/unit", "u\n"); + assert.deepEqual(ops, [["write", "/x/unit", undefined]], "a file without a mode is written as before"); +}); + +test("service install on podman (#468): a deployment without a password gets one in .env (0600) and in the Valkey's 0600 file, never printed; render names the file only", async () => { + const h = svc({ files: { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\n` } }); + assert.equal(await h.run(), 0, h.errText()); + assert.equal(h.store.get(ENV_PATH), `${PODMAN_ENV}PI_EGRESS=0\nVALKEY_PASSWORD=${TEST_PASSWORD}\n`); + assert.equal(h.modes.get(ENV_PATH), 0o600, ".env narrowed to this account"); + assert.match(h.store.get(VALKEY_ENV), new RegExp(`^VALKEY_PASSWORD=${TEST_PASSWORD}$`, "m")); + assert.equal(h.modes.get(VALKEY_ENV), 0o600); + assert.ok(h.writes.indexOf(VALKEY_ENV) < h.writes.indexOf(join(QDIR, QUADLET_FILES.valkey.file)), "the password file before the unit that reads it"); + assert.match(h.text(), /^generated VALKEY_PASSWORD into \/srv\/pi-deploy\/\.env \(32 random bytes, hex; the value is not shown; the file is now readable by this account only\)$/m); + assert.ok(!h.text().includes(TEST_PASSWORD) && !h.errText().includes(TEST_PASSWORD), "the value is never printed"); + // A second install finds it and keeps it: never generated over a value. + const again = svc({ argv: ["install", "--force"], files: { [ENV_PATH]: h.store.get(ENV_PATH), [VALKEY_ENV]: h.store.get(VALKEY_ENV), [join(QDIR, QUADLET_FILES.valkey.file)]: h.store.get(join(QDIR, QUADLET_FILES.valkey.file)), [join(QDIR, QUADLET_FILES.valkeyNetwork.file)]: h.store.get(join(QDIR, QUADLET_FILES.valkeyNetwork.file)) }, listening: true, extra: { newPassword: () => "f".repeat(64) } }); + again.modes.set(VALKEY_ENV, 0o600); + assert.equal(await again.run(), 0, again.errText()); + assert.match(again.store.get(ENV_PATH), new RegExp(`^VALKEY_PASSWORD=${TEST_PASSWORD}$`, "m")); + assert.doesNotMatch(again.text(), /generated VALKEY_PASSWORD/); + assert.ok(!again.calls.some((c) => c.join(" ").includes("restart")), "nothing about the password changed, so nothing restarts"); + // render: the file is named, its value never shown, and nothing is written. + const r = svc({ argv: ["render"], files: { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\nVALKEY_PASSWORD=${TEST_PASSWORD}\n` } }); + assert.equal(await r.run(), 0, r.errText()); + assert.match(r.text(), /# → \/home\/tester\/\.config\/pi-dispatch\/valkey\.env \(mode 0600: VALKEY_PASSWORD for the Quadlet Valkey, from \.env or generated into it at install; the value is not shown\)/); + assert.ok(!r.text().includes(TEST_PASSWORD), "render never prints the password"); + assert.deepEqual(r.writes, []); + assert.match(r.text(), /^EnvironmentFile=%h\/\.config\/pi-dispatch\/valkey\.env$/m, "the unit itself is printed as before"); +}); + +test("service install on podman (#468): the upgrade of a Valkey that ran without a password restarts it with one, then the running worker and receiver", async () => { + // The pre-#468 install: its unit and network on disk (the old unit text), no password file, no VALKEY_PASSWORD. + const files = { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\n`, [join(QDIR, QUADLET_FILES.valkey.file)]: "[Container]\nExec=valkey-server --appendonly yes\n", [join(QDIR, QUADLET_FILES.valkeyNetwork.file)]: template(QUADLET_FILES.valkeyNetwork.file), [USER_UNIT]: "old\n" }; + const h = svc({ argv: ["install", "--force"], files, listening: true, plan: { "systemctl --user is-active pi-dispatch-worker.service": { code: 0, output: "active\n" }, "systemctl --user is-active pi-dispatch-receiver.service": { code: 3, output: "inactive\n" } } }); + assert.equal(await h.run(), 0, h.errText()); + const sys = h.calls.filter((c) => c[0] === "systemctl" && !["show-environment", "is-active"].includes(c[2])).map((c) => c.slice(2).join(" ")); + assert.deepEqual(sys.slice(0, 3), ["daemon-reload", "restart pi-dispatch-valkey.service", "try-restart pi-dispatch-worker.service"], "Valkey with its password first, then the one running client"); + assert.match(h.text(), /⚠ pi-dispatch-valkey\.service restarts with the password in \/home\/tester\/\.config\/pi-dispatch\/valkey\.env \(the queue in its volume is kept\), and pi-dispatch-worker\.service restarts after it/); + assert.match(h.text(), /^restarted pi-dispatch-valkey\.service, because this install replaced \/home\/tester\/\.config\/pi-dispatch\/valkey\.env and \/home\/tester\/\.config\/containers\/systemd\/pi-dispatch-valkey\.container$/m); + assert.match(h.store.get(ENV_PATH), new RegExp(`^VALKEY_PASSWORD=${TEST_PASSWORD}$`, "m")); +}); + +test("service install on podman (#468): a shared or operator-owned Valkey gets no password generated, and a password Valkey could not take is refused before anything is written", async () => { + // Shared on purpose: another account's Valkey, its password that account's to give. + const shared = svc({ files: { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\nPI_VALKEY_SHARED=1\n` }, listening: true, listenerUid: 1235 }); + assert.equal(await shared.run(), 0, shared.errText()); + assert.doesNotMatch(shared.store.get(ENV_PATH), /VALKEY_PASSWORD/); + assert.ok(!shared.store.has(VALKEY_ENV)); + // A remote VALKEY_URL: the operator's own, left as it is. + const remote = svc({ files: { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\nVALKEY_URL=redis://queue.lan:6379\n` } }); + assert.equal(await remote.run(), 0, remote.errText()); + assert.doesNotMatch(remote.store.get(ENV_PATH), /VALKEY_PASSWORD/); + // A value the start script cannot hand to Valkey (a space, a quote): refused, named, nothing written. + const bad = svc({ files: { [ENV_PATH]: `${PODMAN_ENV}PI_EGRESS=0\nVALKEY_PASSWORD='not ok here'\n` } }); + assert.equal(await bad.run(), 1); + assert.match(bad.errText(), /VALKEY_PASSWORD in \/srv\/pi-deploy\/\.env cannot be handed to Valkey: it has a character other than A-Z, a-z, 0-9, - and _/); + assert.ok(!bad.errText().includes("not ok here")); + assert.deepEqual(bad.writes, []); +}); + +test("service uninstall (#468): the Valkey's password file goes with its unit; the password stays in .env for a re-install", async () => { + const files = { [ENV_PATH]: `VALKEY_PASSWORD=${TEST_PASSWORD}\n`, [VALKEY_ENV]: `VALKEY_PASSWORD=${TEST_PASSWORD}\n` }; + for (const q of [QUADLET_FILES.valkey, QUADLET_FILES.valkeyNetwork]) files[join(QDIR, q.file)] = "q"; + const h = svc({ argv: ["uninstall"], files }); + assert.equal(await h.run(), 0, h.errText()); + assert.ok(!h.store.has(VALKEY_ENV)); + assert.equal(h.store.get(ENV_PATH), `VALKEY_PASSWORD=${TEST_PASSWORD}\n`); +}); diff --git a/worker/test/processor.test.mjs b/worker/test/processor.test.mjs index a41ef2a1..73c51143 100644 --- a/worker/test/processor.test.mjs +++ b/worker/test/processor.test.mjs @@ -961,7 +961,7 @@ test("an egress-proxy refusal on the podman venue names the rootless-podman reme return runJob(job, d); }; await run(ghJob, ["local"], { proxyMissing: "pi-dispatch-egress-proxy" }); - assert.ok(texts[0].includes("Start it with `docker compose -f deploy/docker-compose.yml --profile egress up -d`, or set PI_EGRESS=0"), texts[0]); + assert.ok(texts[0].includes("Start it with `docker compose --env-file .env -f deploy/docker-compose.yml --profile egress up -d`, or set PI_EGRESS=0"), texts[0]); const r = await run({ ...ghJob, backend: "podman" }, ["local", "podman"], { proxyStopped: "pi-dispatch-egress-proxy" }); assert.equal(r.reason, "egress-proxy-stopped"); assert.ok(texts[1].includes("Start it under the worker account's own rootless podman, on a named bridge network (docs/podman.md), or set PI_EGRESS=0"), texts[1]); diff --git a/worker/test/start-wiring.test.mjs b/worker/test/start-wiring.test.mjs index bedf8658..851b2966 100644 --- a/worker/test/start-wiring.test.mjs +++ b/worker/test/start-wiring.test.mjs @@ -3332,6 +3332,23 @@ test("boot judges VALKEY_URL's owner before any Valkey contact, refuses as a con assert.deepEqual([started.valkey, started.valkeyPinned], ["redis://valkey-name-never-resolves.invalid:1", "127.0.0.1:6399"]); }); +// Issue #468: the boot says whether this worker sends a Valkey password, and never the password; and a worker that has +// one still runs against a Valkey that has none (an older deployment mid-upgrade): ioredis takes the server's "no password +// configured" answer as a warning. +test("boot logs whether it sends a Valkey password, never the value, and one it sends to a Valkey without a password still works (#468)", { skip }, async () => { + const pinnedUrl = process.env.VALKEY_TEST_URL; + const judged = async () => ({ url: pinnedUrl, servername: null, pinned: { address: "127.0.0.1", port: 6399, heldBy: "this account" }, notes: [] }); + const none = await runStart({ env: {}, judgeValkey: judged }); + const line = none.logs.find((l) => l.event === "valkey_password"); + assert.deepEqual(Object.keys(line).sort(), ["event", "host", "set"], "whether one is set, and nothing else"); + assert.equal(line.set, false); + const pw = "c0ffee00".repeat(8); + const withPw = await runStart({ env: { VALKEY_PASSWORD: pw }, judgeValkey: judged }); + assert.equal(withPw.logs.find((l) => l.event === "valkey_password").set, true); + assert.ok(withPw.logs.some((l) => l.event === "worker_started"), "booted against a Valkey that has no password"); + assert.ok(!JSON.stringify(withPw.logs).includes(pw), "the password is in no log line"); +}); + test("boot secures the account root for the two durable stores too, and a refusal there stops it before anything is built (#464)", { skip }, async () => { const asked = []; await runStart({ env: { PI_LOGS_DIR: "/t/pi-dispatch-501/logs", PI_SETTINGS_FILE: "/t/pi-dispatch-501/settings.json" }, ensureJobsDir: () => {}, ensureUnderAccountRoot: (p) => asked.push(p) }); diff --git a/worker/test/up.test.mjs b/worker/test/up.test.mjs index 6bec8141..fb22c22c 100644 --- a/worker/test/up.test.mjs +++ b/worker/test/up.test.mjs @@ -5,16 +5,20 @@ import { join } from "node:path"; import { tempDir } from "./helpers/temp-dir.mjs"; import { defaultPrompt, runUp, valkeyContainerPublishes } from "../src/up.mjs"; import { NETNS_KEEPER_FORMAT } from "../src/podman-stack.mjs"; +import { VALKEY_HEALTH_SCRIPT, VALKEY_START_SCRIPT } from "../src/valkey-auth.mjs"; // A fake `spawn`, mirroring doctor.test.mjs: plan keys are command-line prefixes ("docker version", // "docker image inspect", "docker pull", "docker tag", "docker ps", "docker volume", "docker run") // mapped to a canned exit code, a `{code, output}` pair, or "enoent" for a launch failure. Every spawn // is recorded into `calls` so tests can assert the EXACT argv of every host mutation. -function fakeSpawn(plan, calls = []) { +// `defaults` answers first where it has a key (the harness's docker ownership answers, only for prefixes the plan has +// none of), read beside the plan rather than merged into a copy, so a test that edits its plan mid-run is still heard. +function fakeSpawn(plan, calls = [], defaults = {}) { return (cmd, args, opts) => { const line = [cmd, ...args].join(" "); - const key = Object.keys(plan).find((k) => line.startsWith(k)); - const outcome = plan[key]; + const dkey = Object.keys(defaults).find((k) => line.startsWith(k)); + const key = dkey ?? Object.keys(plan).find((k) => line.startsWith(k)); + const outcome = dkey ? defaults[dkey] : plan[key]; calls.push({ cmd, args, opts }); const stream = () => ({ handlers: {}, @@ -49,6 +53,8 @@ function fakeSpawn(plan, calls = []) { // 64 hex chars, distinctive on purpose: the no-secret-in-output assertions grep for exactly this. const SECRET = "cafef00d".repeat(8); +// Issue #468: the Valkey password `up` generates in these tests, as distinctive, for the same assertions. +const PASSWORD = "5eca1ed0".repeat(8); // Everything injected, everything recorded. `files` seeds the in-memory fs (path → text); the fake // init deliberately creates nothing, so a test that wants a .env after init seeds it up front. @@ -64,6 +70,14 @@ function harness({ plan = {}, listening = true, answers = [], files = {}, doctor if (listening && !("/proc/net/tcp" in files)) files = { ...listenerFiles(1234), ...files }; extra = { lookup: async () => { throw Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" }); }, interfaces: () => ({}), runSync: () => null, ...extra }; } + // PR #475's review, round 3: docker answers the ownership questions up asks before it acts on a Valkey container or + // volume. Unless a test says otherwise: no pi-dispatch-valkey, and nothing mounts pi-dispatch-valkey-data. + const dockerDefaults = {}; + const planned = (line) => Object.keys(plan).some((k) => line.startsWith(k) || k.startsWith(line)); + if (!planned("docker container inspect pi-dispatch-valkey")) dockerDefaults["docker container inspect pi-dispatch-valkey"] = { code: 1, stderr: "Error: No such container: pi-dispatch-valkey\n" }; + if (!planned("docker ps -a")) dockerDefaults["docker ps -a"] = { code: 0, output: "" }; + // The volume gap (PR #475's review): no pi-dispatch-valkey-data yet, unless a test plans its own inspect answer. + if (!Object.keys(plan).some((k) => k.startsWith("docker volume inspect"))) dockerDefaults["docker volume inspect"] = { code: 1, stderr: "Error: no such volume\n" }; const calls = []; const promptCalls = []; const initCalls = []; @@ -74,7 +88,7 @@ function harness({ plan = {}, listening = true, answers = [], files = {}, doctor const store = new Map(Object.entries(files)); const deps = { env, - spawn: fakeSpawn(plan, calls), + spawn: fakeSpawn(plan, calls, dockerDefaults), out: (s) => buf.push(s), prompt: (q) => { promptCalls.push(q); @@ -106,6 +120,7 @@ function harness({ plan = {}, listening = true, answers = [], files = {}, doctor cwd, platform, randomHex: () => SECRET, + newPassword: () => PASSWORD, // Resolved defaults, pinned rather than read off this host: `logsDirPath` calls the real // `homedir()`, so without these the byte-exact `.env` assertions below would assert whose account // ran the suite (issue #357). @@ -129,20 +144,37 @@ function harness({ plan = {}, listening = true, answers = [], files = {}, doctor return { run: () => runUp(argv, deps), deps, calls, promptCalls, initCalls, initOpts, doctorCalls, doctorOpts, store, text: () => buf.join("") }; } -const green = { "docker version": 0, "docker image inspect": 0, "docker ps": { code: 0, output: "pi-dispatch-valkey\n" } }; +// pi-dispatch-valkey is this deployment's: up's label names the harness's folder (PR #475's review, round 3). +const green = { "docker version": 0, "docker image inspect": 0, "docker container inspect pi-dispatch-valkey": inspectRecord({ label: "/deploy" }) }; // The exact host mutations up may ever run — asserted array-for-array below, because "shown then run" // only holds if what runs is literally what was shown. const PULL = ["pull", "ghcr.io/edgehero/pi-job:latest"]; const TAG = ["tag", "ghcr.io/edgehero/pi-job:latest", "pi-job:latest"]; -const VOLUME = ["volume", "create", "pi-dispatch-valkey-data"]; +// Labelled with the deployment folder (the volume gap, PR #475's review): the harness's /deploy. +const VOLUME = ["volume", "create", "--label", "com.pi-dispatch.deployment=/deploy", "pi-dispatch-valkey-data"]; +// Issue #468: the password as `-e VALKEY_PASSWORD` (its value in the spawn's environment, never here), and the start +// script and health check that hand it to Valkey on stdin and REDISCLI_AUTH (valkey-auth.mjs pins those two strings to +// the Quadlet unit and the compose file). +// Round 3 of PR #475's review: labelled with the deployment folder (the harness's /deploy, which has no real path here). const VALKEY_RUN = [ - "run", "-d", "--name", "pi-dispatch-valkey", "--restart", "unless-stopped", - "-p", "127.0.0.1:6379:6379", "-v", "pi-dispatch-valkey-data:/data", - "--health-cmd", "valkey-cli ping", "--health-interval", "10s", "--health-timeout", "3s", "--health-retries", "5", - "valkey/valkey:8", "valkey-server", "--appendonly", "yes", + "run", "-d", "--name", "pi-dispatch-valkey", "--label", "com.pi-dispatch.deployment=/deploy", "--restart", "unless-stopped", + "-p", "127.0.0.1:6379:6379", "-v", "pi-dispatch-valkey-data:/data", "-e", "VALKEY_PASSWORD", + "--health-cmd", VALKEY_HEALTH_SCRIPT, "--health-interval", "10s", "--health-timeout", "3s", "--health-retries", "5", + "valkey/valkey:8", "sh", "-c", VALKEY_START_SCRIPT, ]; +/** + * One `docker container inspect` answer (PR #475's review, round 3): a container with up's label, compose's working + * dir, or neither, publishing 6379 on `port`. + */ +function inspectRecord({ name = "pi-dispatch-valkey", label, workingDir, port = 6379 } = {}) { + const Labels = {}; + if (label) Labels["com.pi-dispatch.deployment"] = label; + if (workingDir) Labels["com.docker.compose.project.working_dir"] = workingDir; + return { code: 0, output: JSON.stringify([{ Name: `/${name}`, Config: { Labels }, HostConfig: { PortBindings: { "6379/tcp": [{ HostIp: "127.0.0.1", HostPort: String(port) }] } } }]) }; +} + test("up: everything already in place prompts for nothing and exits 0", async () => { const h = harness({ plan: green, listening: true }); assert.equal(await h.run(), 0); @@ -167,6 +199,11 @@ test("up: declined prompts run NOTHING and the summary says skipped", async () = assert.deepEqual(h.calls.map((c) => c.args), [ ["version"], ["image", "inspect", "pi-job:latest"], + // Round 3 of PR #475's review: whose pi-dispatch-valkey is (none here) and who mounts its volume, both read-only. + ["container", "inspect", "pi-dispatch-valkey"], + ["ps", "-a", "--filter", "volume=pi-dispatch-valkey-data", "--format", "{{.Names}}"], + // The volume gap (PR #475's review): whose pi-dispatch-valkey-data is (none yet), read-only. + ["volume", "inspect", "pi-dispatch-valkey-data"], ["inspect", PROXY_INSPECT.slice("docker inspect ".length, -" pi-dispatch-egress-proxy".length), "pi-dispatch-egress-proxy"], ]); assert.match(h.text(), /job image\s+skipped \(declined\)/); @@ -185,13 +222,13 @@ test("up: --yes runs both docker action pairs with exactly the argv that was sho const argvs = h.calls.map((c) => c.args); assert.deepEqual(argvs.find((a) => a[0] === "pull"), PULL); assert.deepEqual(argvs.find((a) => a[0] === "tag"), TAG); - assert.deepEqual(argvs.find((a) => a[0] === "volume"), VOLUME); + assert.deepEqual(argvs.find((a) => a[0] === "volume" && a[1] === "create"), VOLUME); assert.deepEqual(argvs.find((a) => a[0] === "run"), VALKEY_RUN); assert.ok(argvs.findIndex((a) => a[0] === "pull") < argvs.findIndex((a) => a[0] === "tag"), "pull before tag"); - assert.ok(argvs.findIndex((a) => a[0] === "volume") < argvs.findIndex((a) => a[0] === "run"), "volume before run"); + assert.ok(argvs.findIndex((a) => a[0] === "volume" && a[1] === "create") < argvs.findIndex((a) => a[0] === "run"), "volume before run"); // --yes waives consent, never visibility: the commands are still printed before they run. assert.match(h.text(), /docker pull ghcr\.io\/edgehero\/pi-job:latest/); - assert.match(h.text(), /docker volume create pi-dispatch-valkey-data/); + assert.match(h.text(), /docker volume create --label com\.pi-dispatch\.deployment=\/deploy pi-dispatch-valkey-data/); }); test("up: an image already present is never prompted for", async () => { @@ -213,10 +250,18 @@ test("up: something listening on 6379 never prompts and never runs docker run", }); test("up: a foreign listener on 6379 is assumed but not claimed as ours", async () => { - const h = harness({ plan: { ...green, "docker ps": { code: 0, output: "" } }, listening: true }); + const h = harness({ plan: { ...green, "docker container inspect pi-dispatch-valkey": { code: 1, stderr: "Error: No such container: pi-dispatch-valkey\n" } }, listening: true }); await h.run(); assert.match(h.text(), /assuming your Valkey/); assert.doesNotMatch(h.text(), /it is the pi-dispatch-valkey container/); + // Round 3 of PR #475's review: a pi-dispatch-valkey another deployment's up labelled is not ours by its name, and one + // docker cannot describe is not ours either. + for (const [answer, said] of [[inspectRecord({ label: "/srv/other" }), /pi-dispatch-valkey is not this deployment's Valkey \(the deployment in \/srv\/other\), so it is never stopped, removed or reused from here/], [{ code: 125, stderr: "Cannot connect to the Docker daemon\n" }, null]]) { + const o = harness({ plan: { ...green, "docker container inspect pi-dispatch-valkey": answer }, listening: true }); + await o.run(); + assert.doesNotMatch(o.text(), /it is the pi-dispatch-valkey container/); + if (said) assert.match(o.text(), said); + } }); test("up: init and doctor always run, even when every docker action was declined", async () => { @@ -234,13 +279,16 @@ test("up: WEBHOOK_SECRET is generated into an empty .env and the value NEVER rea const h = harness({ plan: green, files: { "/deploy/.env": "A=1\nWEBHOOK_SECRET=\n" } }); await h.run(); // The four path keys land beside it (issue #357), which four other files have promised for a year. + // Issue #468: and the deployment's Valkey password, generated like the webhook secret, never printed either. assert.equal( h.store.get("/deploy/.env"), - `A=1\nWEBHOOK_SECRET=${SECRET}\nPI_PAUSE_WINDOWS_FILE=/deploy/pause-windows.json\nPI_SCOPED_LIMITS_FILE=/deploy/scoped-limits.json\nPI_LOGS_DIR=/home/op/.pi-dispatch/logs\nPI_SETTINGS_FILE=/home/op/.pi-dispatch/settings.json\n`, - "the key was filled, the four paths appended, other lines untouched", + `A=1\nWEBHOOK_SECRET=${SECRET}\nPI_PAUSE_WINDOWS_FILE=/deploy/pause-windows.json\nPI_SCOPED_LIMITS_FILE=/deploy/scoped-limits.json\nPI_LOGS_DIR=/home/op/.pi-dispatch/logs\nPI_SETTINGS_FILE=/home/op/.pi-dispatch/settings.json\nVALKEY_PASSWORD=${PASSWORD}\n`, + "the key was filled, the four paths and the password appended, other lines untouched", ); assert.ok(!h.text().includes(SECRET), "the secret value must never be printed"); + assert.ok(!h.text().includes(PASSWORD), "the Valkey password must never be printed"); assert.match(h.text(), /generated WEBHOOK_SECRET/); + assert.match(h.text(), /generated VALKEY_PASSWORD into \.env/); }); test("up: a key whose value is `\"\"` is named as EMPTY, not merely `already set` (#365)", async () => { @@ -467,6 +515,7 @@ test("up edits a symlinked .env through the link, with the REAL fs seam (#357)", prompt: async () => "n", probeTcp: async () => true, randomHex: () => SECRET, + newPassword: () => PASSWORD, runInitFn: () => 0, runDoctorFn: () => 0, }); @@ -541,9 +590,9 @@ test("up hands its OWN doctor call what it just wrote, or it warns about what it test("up leaves every one of the four alone when the operator already set it (#357)", async () => { const mine = ["PI_PAUSE_WINDOWS_FILE=/elsewhere/windows.json", "PI_SCOPED_LIMITS_FILE=/elsewhere/limits.json", "PI_LOGS_DIR=/var/log/pi", "PI_SETTINGS_FILE=/etc/pi/settings.json"].join("\n"); - const h = harness({ plan: green, files: { "/deploy/.env": `${mine}\nWEBHOOK_SECRET=x\n` } }); + const h = harness({ plan: green, files: { "/deploy/.env": `${mine}\nWEBHOOK_SECRET=x\nVALKEY_PASSWORD=${PASSWORD}\n` } }); await h.run(); - assert.equal(h.store.get("/deploy/.env"), `${mine}\nWEBHOOK_SECRET=x\n`, "four keys, four chances to clobber, none taken"); + assert.equal(h.store.get("/deploy/.env"), `${mine}\nWEBHOOK_SECRET=x\nVALKEY_PASSWORD=${PASSWORD}\n`, "four keys, four chances to clobber, none taken (and the password, #468)"); // And the layer must not put back what the operator overrode: `wrote` holds only keys that were empty. assert.equal(h.doctorCalls[0].PI_LOGS_DIR, undefined, "an operator value stays the environment's business"); for (const key of ["PI_PAUSE_WINDOWS_FILE", "PI_SCOPED_LIMITS_FILE", "PI_LOGS_DIR", "PI_SETTINGS_FILE"]) { @@ -739,10 +788,10 @@ test("up on podman: --yes runs exactly the lines it showed, installs the Quadlet const shown = block.split("\n").slice(1).filter((l) => l.startsWith(" ")).map((l) => l.trim()); const ran = []; for (const c of h.calls) if ((c.cmd === "systemctl" && c.args[1] !== "show-environment")) ran.push([c.cmd, ...c.args].join(" ")); - const written = [...h.store.keys()].filter((p) => p.startsWith(QDIR) || p === "/home/op/.config/pi-dispatch/egress-proxy.conf").map((p) => `write ${p}`); + const written = [...h.store.keys()].filter((p) => p.startsWith(QDIR) || p === "/home/op/.config/pi-dispatch/egress-proxy.conf" || p === "/home/op/.config/pi-dispatch/valkey.env").map((p) => `write ${p}`); assert.deepEqual(shown, [...written, ...ran], "shown then run: the same lines, in the same order"); assert.deepEqual(ran, ["systemctl --user daemon-reload", "systemctl --user start pi-dispatch-valkey.service pi-dispatch-egress-proxy.service pi-dispatch-netns-keeper.service"]); - assert.equal(written.length, 7, "six units (the keeper's two since #458) and the account-owned copy of the rules"); + assert.equal(written.length, 8, "six units (the keeper's two since #458), the account-owned copy of the rules, and the Valkey's password file (#468)"); assert.ok(!ran.some((l) => / enable /.test(l)), "a generated unit is never enabled"); assert.deepEqual(h.calls.filter((c) => c.cmd === "podman" && c.args[0] !== "inspect" && c.args[0] !== "container").map((c) => c.args), [ ["image", "exists", "pi-job:latest"], @@ -928,10 +977,13 @@ test("up on local,podman: the docker steps are unchanged, then podman's image an extra: podmanExtra(), }); assert.equal(await h.run(), 0); - assert.deepEqual(h.calls.slice(0, 3).map((c) => [c.cmd, ...c.args]), [ + // Issue #468: docker's Valkey step runs after init and the .env keys now (its password comes from .env), so the + // podman image step comes before it; the docker steps themselves are the ones they were. + assert.deepEqual(h.calls.slice(0, 4).map((c) => [c.cmd, ...c.args]), [ ["docker", "version"], ["docker", "image", "inspect", "pi-job:latest"], - ["docker", "ps", "--filter", "name=pi-dispatch-valkey", "--format", "{{.Names}}"], + ["podman", "image", "exists", "pi-job:latest"], + ["docker", "container", "inspect", "pi-dispatch-valkey"], ]); assert.ok(![...h.store.keys()].some((p) => p.endsWith("pi-dispatch-valkey.container")), "no second Valkey on a docker host"); assert.ok(h.store.has(`${QDIR}/pi-dispatch-egress-proxy.container`), "podman jobs still need podman's own proxy"); @@ -1636,6 +1688,317 @@ test("up on podman: a stack command that fails names the files this run wrote, w extra: podmanExtra(), }); await h.run(); - assert.match(h.text(), new RegExp(`Files this run wrote, which remain: ${QDIR.replaceAll(".", "\\.")}/pi-dispatch-valkey\\.network, ${QDIR.replaceAll(".", "\\.")}/pi-dispatch-valkey\\.container`)); + assert.match(h.text(), new RegExp(`Files this run wrote, which remain: ${QDIR.replaceAll(".", "\\.")}/pi-dispatch-valkey\\.network, /home/op/\\.config/pi-dispatch/valkey\\.env, ${QDIR.replaceAll(".", "\\.")}/pi-dispatch-valkey\\.container`)); assert.ok(h.store.has(`${QDIR}/pi-dispatch-valkey.container`)); }); + +// --------------------------------------------------------------------------------------------------------------------- +// Issue #468: the Valkey `up` starts gets the deployment's password, from .env, through the environment only +// --------------------------------------------------------------------------------------------------------------------- + +test("up on docker (#468): a .env without a password gets one first, and docker's Valkey starts with it in the CLI's environment, never its argv", async () => { + const h = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": "A=1\n" }, env: { PI_PROVIDER: "anthropic", VALKEY_PASSWORD: "a-shell-value-that-is-not-the-deployments" } }); + assert.equal(await h.run(), 0); + assert.match(h.store.get("/deploy/.env"), new RegExp(`^VALKEY_PASSWORD=${PASSWORD}$`, "m")); + const run = h.calls.find((c) => c.cmd === "docker" && c.args[0] === "run"); + assert.deepEqual(run.args, VALKEY_RUN); + assert.ok(!run.args.some((a) => a.includes(PASSWORD)), "on no argv"); + assert.equal(run.opts.env.VALKEY_PASSWORD, PASSWORD, "the deployment's, in the docker CLI's environment (`-e VALKEY_PASSWORD` names it)"); + assert.ok(!h.text().includes(PASSWORD)); + assert.match(h.text(), /✓ started Valkey \(container pi-dispatch-valkey, AOF on, bound to 127\.0\.0\.1, with VALKEY_PASSWORD from \.env\)/); + // The password is decided before Valkey starts: the .env write comes first. + assert.ok(h.text().indexOf("generated VALKEY_PASSWORD") < h.text().indexOf("started Valkey")); + // A deployment that set its own: kept, and that one is what Valkey gets. + const mine = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_PASSWORD=${SECRET}\n` } }); + await mine.run(); + assert.equal(mine.store.get("/deploy/.env").match(/VALKEY_PASSWORD=/g).length, 1); + assert.equal(mine.calls.find((c) => c.args[0] === "run").opts.env.VALKEY_PASSWORD, SECRET); + // A shared Valkey or an operator's own: nothing generated, and the Valkey up would start gets none from a shell. + const shared = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": "PI_VALKEY_SHARED=1\n" }, env: { VALKEY_PASSWORD: "x".repeat(20) } }); + await shared.run(); + assert.doesNotMatch(shared.store.get("/deploy/.env"), /VALKEY_PASSWORD/); + assert.equal(shared.calls.find((c) => c.args[0] === "run").opts.env.VALKEY_PASSWORD, undefined); + assert.match(shared.text(), /⚠ PI_VALKEY_SHARED=1 in \/deploy\/\.env: no VALKEY_PASSWORD is generated/); +}); + +test("up on docker (#468): our pi-dispatch-valkey answering without a password is offered a restart with it (stop, rm, run; the volume kept), and a declined or a foreign one is said", async () => { + const files = { "/deploy/.env": `VALKEY_PASSWORD=${SECRET}\n` }; + const plan = { ...green, "docker stop": 0, "docker rm": 0, "docker run": 0 }; + const h = harness({ plan, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "ok" } }); + assert.equal(await h.run(), 0); + const acts = h.calls.filter((c) => c.cmd === "docker" && ["stop", "rm", "run"].includes(c.args[0])); + assert.deepEqual(acts.map((c) => c.args), [["stop", "pi-dispatch-valkey"], ["rm", "pi-dispatch-valkey"], VALKEY_RUN], "stopped (SIGTERM: Valkey writes its AOF out), removed, run again; never rm -f, never the volume"); + assert.equal(acts[2].opts.env.VALKEY_PASSWORD, SECRET); + assert.match(h.text(), /pi-dispatch-valkey answers without a password, and VALKEY_PASSWORD is set in \.env\. up would restart it with the password \(stopped first, so Valkey writes its AOF out; the pi-dispatch-valkey-data volume, and the queue in it, is kept\)\. A job running right now is interrupted, and the worker and the receiver get NOAUTH until they are restarted: pause first if a job runs \(pi-dispatch pause, wait for active jobs, then pi-dispatch resume after the restart\):/, "the consent says what the restart costs before it is given"); + assert.match(h.text(), /✓ restarted Valkey with VALKEY_PASSWORD \(container pi-dispatch-valkey, same volume\)\. Restart the worker and the receiver now/); + assert.ok(!h.text().includes(SECRET)); + // Declined: nothing runs, and the summary says what that leaves. + const no = harness({ plan, listening: true, answers: [""], files, extra: { probeValkeyAuth: async () => "ok" } }); + await no.run(); + assert.ok(!no.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0]))); + assert.match(no.text(), /pi-dispatch-valkey runs WITHOUT a password \(declined the restart with it\)/); + // Not ours (compose's, or a package's): never touched, the compose recreate named. + const foreign = harness({ plan: { ...plan, "docker container inspect pi-dispatch-valkey": { code: 1, stderr: "Error: No such container: pi-dispatch-valkey\n" } }, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "ok" } }); + await foreign.run(); + assert.ok(!foreign.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0]))); + assert.match(foreign.text(), /⚠ the Valkey on 6379 answers without a password, and it is not a container up started, so up leaves it: restart it with VALKEY_PASSWORD from \.env \(compose: `docker compose --env-file \.env -f deploy\/docker-compose\.yml up -d`/); + // Round 3 of PR #475's review: a pi-dispatch-valkey is ours by up's label naming this folder, or, unlabelled, by + // publishing this deployment's VALKEY_URL port; another deployment's (its label, or an unlabelled one on another port) + // is never stopped, removed or run again, whatever its name. + for (const [answer, ours] of [[inspectRecord({}), true], [inspectRecord({ label: "/srv/other" }), false], [inspectRecord({ port: 16496 }), false], [inspectRecord({ label: "/deploy/../srv/other" }), false]]) { + const t = harness({ plan: { ...plan, "docker container inspect pi-dispatch-valkey": answer }, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "ok" } }); + await t.run(); + assert.equal(t.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0])), ours, JSON.stringify(answer)); + if (!ours) assert.match(t.text(), /pi-dispatch-valkey is not this deployment's Valkey \(.*\), so it is never stopped, removed or reused from here/); + } + // Ours, but another deployment's Valkey mounts pi-dispatch-valkey-data: no restart (two valkey-servers on one AOF, + // measured), the container and the ways out named, and `up` fails. + const shared = harness({ plan: { ...plan, "docker ps -a": { code: 0, output: "pi-dispatch-valkey\nother-valkey-1\n" }, "docker container inspect other-valkey-1": inspectRecord({ name: "other-valkey-1", workingDir: "/srv/other/deploy", port: 16496 }) }, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "ok" } }); + assert.equal(await shared.run(), 1); + assert.ok(!shared.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0]))); + assert.match(shared.text(), /✗ pi-dispatch-valkey-data is mounted by other-valkey-1 \(the compose project in \/srv\/other\/deploy\), which is not this deployment's Valkey: a second Valkey on the same AOF would corrupt both queues, so none is started\. Stop that deployment's Valkey first if this folder should own the volume, or give this deployment a Valkey of its own/); + assert.match(shared.text(), /up: the Valkey container or volume named above is not this deployment's/); + assert.doesNotMatch(shared.text(), /skipped \(declined|skipped: start it later|declined the restart|skipped: until it restarts/, "a refusal, not a decline"); + // The same refusal where compose recreates this folder's Valkey (a handed-over folder). + const hfiles = { ...files, "/deploy/deploy/docker-compose.valkey.yml": "# override\n" }; + const hshared = harness({ plan: { ...plan, "docker compose": 0, "docker container inspect deploy-valkey-1": inspectRecord({ name: "deploy-valkey-1", workingDir: "/deploy/deploy" }), "docker ps -a": { code: 0, output: "deploy-valkey-1\nother-valkey-1\n" }, "docker container inspect other-valkey-1": inspectRecord({ name: "other-valkey-1", workingDir: "/srv/other/deploy", port: 16496 }) }, listening: true, argv: ["--yes"], files: hfiles, extra: { probeValkeyAuth: async () => "ok" } }); + assert.equal(await hshared.run(), 1); + assert.ok(!hshared.calls.some((c) => ["compose", "stop", "rm", "run"].includes(c.args[0]))); + assert.match(hshared.text(), /✗ pi-dispatch-valkey-data is mounted by other-valkey-1/); + assert.doesNotMatch(hshared.text(), /skipped \(declined|skipped: start it later|declined the restart|skipped: until it restarts/); + // Already protected (NOAUTH to a client with none): nothing offered. + const fine = harness({ plan, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "noauth" } }); + await fine.run(); + assert.ok(!fine.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0]))); + assert.match(fine.text(), /valkey\s+container pi-dispatch-valkey already running/); +}); + +test("up on podman (#468): the Valkey it adds gets a password written into .env only once the plan is accepted, shown as a line of it, never valued", async () => { + const base = { env: { PI_BACKENDS: "podman", PI_EGRESS: "0" }, plan: { "podman image exists": 0, systemctl: 0, "loginctl show-user": { code: 0, output: "Linger=yes\n" } }, listening: false, files: { "/deploy/.env": "PI_BACKENDS=podman\n" }, extra: podmanExtra() }; + const no = harness({ ...base, answers: [""] }); + await no.run(); + assert.doesNotMatch(no.store.get("/deploy/.env"), /VALKEY_PASSWORD/, "declined: no password written (the other keys `up` fills are unrelated)"); + assert.match(no.text(), /^ {2}generate VALKEY_PASSWORD into \/deploy\/\.env \(32 random bytes, hex; the value is not shown\)$/m); + const yes = harness({ ...base, argv: ["--yes"] }); + assert.equal(await yes.run(), 0); + assert.match(yes.store.get("/deploy/.env"), new RegExp(`\nVALKEY_PASSWORD=${PASSWORD}\n$`)); + assert.match(yes.store.get("/home/op/.config/pi-dispatch/valkey.env"), new RegExp(`^VALKEY_PASSWORD=${PASSWORD}$`, "m")); + assert.ok(!yes.text().includes(PASSWORD)); + // A password Valkey could not take stops the Valkey step and fails `up`, naming the key. + const bad = harness({ ...base, argv: ["--yes"], files: { "/deploy/.env": "PI_BACKENDS=podman\nVALKEY_PASSWORD=short\n" } }); + assert.equal(await bad.run(), 1); + assert.match(bad.text(), /✗ VALKEY_PASSWORD in \/deploy\/\.env cannot be handed to Valkey: it is 5 characters long/); + assert.match(bad.text(), /up: VALKEY_PASSWORD in \.env cannot be handed to Valkey \(above\); fix it, then re-run `pi-dispatch up`\./); + assert.ok(![...bad.store.keys()].some((p) => p.startsWith(QDIR))); +}); + +// Issue #468 follow-up: docker's Valkey step used to probe and publish 6379 whatever VALKEY_URL said, so a deployment on +// another port was offered a Valkey its worker never dialled, or had its own listener there ignored. The port is now +// VALKEY_URL's, read as the service reads it (the podman step's reader since #464). +test("up on docker: VALKEY_URL's port is the one probed and published; another host adds none; an IPv6 literal is refused", async () => { + const probed = []; + const h = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16470\nVALKEY_PASSWORD=${PASSWORD}\n` }, extra: { probeTcp: async (host, port) => (probed.push(`${host}:${port}`), false) } }); + assert.equal(await h.run(), 0); + assert.ok(probed.includes("127.0.0.1:16470") && !probed.includes("127.0.0.1:6379"), probed.join(" ")); + const run = h.calls.find((c) => c.cmd === "docker" && c.args[0] === "run"); + assert.equal(run.args[run.args.indexOf("-p") + 1], "127.0.0.1:16470:6379", "published on VALKEY_URL's port (the container still listens on 6379)"); + assert.match(h.text(), /Nothing is listening on 127\.0\.0\.1:16470\. up would start Valkey/); + // This shell's VALKEY_URL counts where the file sets none, as for the service's other keys. + const shell = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_PASSWORD=${PASSWORD}\n` }, env: { VALKEY_URL: "redis://localhost:16471" } }); + await shell.run(); + const r2 = shell.calls.find((c) => c.args[0] === "run"); + assert.equal(r2.args[r2.args.indexOf("-p") + 1], "127.0.0.1:16471:6379"); + // A listener on that port is taken as the Valkey, named by its port. + const taken = harness({ plan: green, listening: true, files: { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16472\nVALKEY_PASSWORD=${PASSWORD}\n` } }); + await taken.run(); + assert.match(taken.text(), /✓ something is listening on 16472, assuming your Valkey/); + // Another host: nothing started, nothing probed, said. + const remote = harness({ plan: { "docker version": 0, "docker image inspect": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": "VALKEY_URL=redis://queue.lan:6379\n" } }); + assert.equal(await remote.run(), 0); + assert.ok(!remote.calls.some((c) => ["run", "volume"].includes(c.args[0]))); + assert.match(remote.text(), /✓ VALKEY_URL names queue\.lan, not this host, so no Valkey is added here/); + // An [::1] URL cannot reach a Valkey published on 127.0.0.1: refused with the fix, and up fails. + const v6 = harness({ plan: { "docker version": 0, "docker image inspect": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": 'VALKEY_URL="redis://[::1]:6380"\n' } }); + assert.equal(await v6.run(), 1); + assert.match(v6.text(), /✗ VALKEY_URL's host is ::1, and the Valkey up starts is published on 127\.0\.0\.1 only, so the worker would not reach it\. Write VALKEY_URL=redis:\/\/127\.0\.0\.1:6380/); + assert.ok(!v6.calls.some((c) => ["run", "volume"].includes(c.args[0]))); + // A shell and a .env that disagree: the service runs the file's, so up adds nothing and says so. + const conflict = harness({ plan: { "docker version": 0, "docker image inspect": 0 }, listening: false, argv: ["--yes"], files: { "/deploy/.env": "VALKEY_URL=redis://127.0.0.1:16473\n" }, env: { VALKEY_URL: "redis://127.0.0.1:16474" } }); + assert.equal(await conflict.run(), 1); + assert.match(conflict.text(), /✗ VALKEY_URL is "redis:\/\/127\.0\.0\.1:16474" in this shell and "redis:\/\/127\.0\.0\.1:16473" in \/deploy\/\.env/); +}); + +// PR #475's review, round 2: in a folder the setup wizard handed to compose (deploy/docker-compose.valkey.yml), `up` +// used to `docker run` pi-dispatch-valkey beside compose's, and the wizard's next compose run failed to bind (measured). +// It now starts compose's valkey with the folder's project and the override, on VALKEY_URL's port, and says a failure. +// PR #475's review, round 3 (measured on Fedora: a second deployment's `up` ran pi-dispatch-valkey on the volume another +// deployment's compose Valkey mounted, and both appended to one AOF): the plain start asks who mounts the volume first. +test("up on docker never starts a Valkey on pi-dispatch-valkey-data beside another deployment's, and never replaces a pi-dispatch-valkey that is not its own (#475 round 3)", async () => { + const files = { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16496\nVALKEY_PASSWORD=${PASSWORD}\n` }; + const base = { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0, "docker start": 0 }; + // Another deployment's compose valkey on the volume: refused, named, nothing created or run, `up` fails. + const other = inspectRecord({ name: "a-valkey-1", workingDir: "/srv/a/deploy", port: 16495 }); + const h = harness({ plan: { ...base, "docker ps -a": { code: 0, output: "a-valkey-1\n" }, "docker container inspect a-valkey-1": other }, listening: false, argv: ["--yes"], files }); + assert.equal(await h.run(), 1); + assert.ok(!h.calls.some((c) => ["volume", "run", "stop", "rm", "start"].includes(c.args[0])), "nothing created, run, stopped or removed"); + assert.match(h.text(), /✗ pi-dispatch-valkey-data is mounted by a-valkey-1 \(the compose project in \/srv\/a\/deploy\), which is not this deployment's Valkey: a second Valkey on the same AOF would corrupt both queues, so none is started\. Stop that deployment's Valkey first if this folder should own the volume, or give this deployment a Valkey of its own \(compose in this folder without deploy\/docker-compose\.valkey\.yml keeps its own volume\)/); + assert.match(h.text(), /up: the Valkey container or volume named above is not this deployment's \(or not attributed to it\), and up never stops, removes, reuses or starts beside one/); + // docker not answering who mounts it is a refusal too. + const dark = harness({ plan: { ...base, "docker ps -a": 1 }, listening: false, argv: ["--yes"], files }); + assert.equal(await dark.run(), 1); + assert.ok(!dark.calls.some((c) => ["volume", "run"].includes(c.args[0]))); + assert.match(dark.text(), /whether another Valkey mounts pi-dispatch-valkey-data could not be read \(docker ps -a --filter volume=pi-dispatch-valkey-data exited 1\)/); + // This deployment's own stopped container on the volume is not a reason to refuse; it is started as it is, never replaced. + const mine = inspectRecord({ label: "/deploy", port: 16496 }); + const stopped = harness({ plan: { ...base, "docker ps -a": { code: 0, output: "pi-dispatch-valkey\n" }, "docker container inspect pi-dispatch-valkey": mine }, listening: false, argv: ["--yes"], files }); + assert.equal(await stopped.run(), 0); + assert.ok(!stopped.calls.some((c) => ["volume", "run", "rm"].includes(c.args[0]))); + assert.match(stopped.text(), /pi-dispatch-valkey is this deployment's and is not listening on 16496: `docker start pi-dispatch-valkey` starts it as it is/); + // Another deployment's pi-dispatch-valkey (its label, or unlabelled on another port), or one docker cannot describe: + // never replaced, and `up` fails, since a second container of that name cannot be started. + for (const answer of [inspectRecord({ label: "/srv/a", port: 16495 }), inspectRecord({ port: 16495 }), { code: 125, stderr: "Cannot connect to the Docker daemon\n" }]) { + const t = harness({ plan: { ...base, "docker container inspect pi-dispatch-valkey": answer }, listening: false, argv: ["--yes"], files }); + assert.equal(await t.run(), 1, JSON.stringify(answer)); + assert.ok(!t.calls.some((c) => ["volume", "run", "stop", "rm", "start"].includes(c.args[0]))); + assert.match(t.text(), /a second container of that name cannot be started beside it/); + } +}); + +// PR #475's review, the volume gap (measured on Fedora: A's compose Valkey taken down left nothing on +// pi-dispatch-valkey-data, and B's `up` started on A's queue). The volume carries its creator's label; another folder's +// is never used; an unlabelled one only after a question --yes does not answer; and every start checks the queue's own +// pi-dispatch:owner marker, recording it where it is missing. +const volumeRecord = (label) => ({ code: 0, output: JSON.stringify([{ Name: "pi-dispatch-valkey-data", Labels: label ? { "com.pi-dispatch.deployment": label } : {} }]) }); +test("up on docker uses pi-dispatch-valkey-data only when it is this folder's: another's label refuses, an unlabelled one is asked about even under --yes, and the owner marker is checked on every start (#475 volume gap)", async () => { + const files = { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16496\nVALKEY_PASSWORD=${PASSWORD}\n` }; + const base = { "docker version": 0, "docker image inspect": 0, "docker volume create": 0, "docker run": 0, "docker stop": 0, "docker rm": 0 }; + const acts = (h) => h.calls.filter((c) => c.cmd === "docker" && (["run", "stop", "rm"].includes(c.args[0]) || (c.args[0] === "volume" && c.args[1] === "create"))).map((c) => c.args[0] === "volume" ? "create" : c.args[0]); + // The gap: the volume is A's (its label), nothing mounts it: refused, nothing created or run, up fails. + const theirs = harness({ plan: { ...base, "docker volume inspect": volumeRecord("/srv/a") }, listening: false, argv: ["--yes"], files }); + assert.equal(await theirs.run(), 1); + assert.deepEqual(acts(theirs), []); + assert.match(theirs.text(), /✗ pi-dispatch-valkey-data belongs to the deployment in \/srv\/a \(its label\), so this deployment never uses it: give this deployment a Valkey of its own .*, or run this from \/srv\/a/); + // Labelled for this folder: used as it is (no create), and the marker recorded. + const claims = []; + const mine = harness({ plan: { ...base, "docker volume inspect": volumeRecord("/deploy") }, listening: false, argv: ["--yes"], files, extra: { claimValkeyOwner: async (url, folder) => (claims.push([url, folder]), { owner: folder, claimed: true }) } }); + assert.equal(await mine.run(), 0); + assert.deepEqual(acts(mine), ["run"]); + assert.deepEqual(claims, [["redis://127.0.0.1:16496", "/deploy"]], "the marker, on VALKEY_URL's port, for this folder"); + assert.match(mine.text(), /✓ recorded pi-dispatch:owner=\/deploy in its queue/); + // Unlabelled: asked even under --yes, the risk named; no answer (a script's --yes) refuses. + const unl = { ...base, "docker volume inspect": volumeRecord(null) }; + const no = harness({ plan: unl, listening: false, argv: ["--yes"], files }); + assert.equal(await no.run(), 1); + assert.deepEqual(acts(no), []); + assert.equal(no.promptCalls.length, 1, "--yes did not answer it"); + assert.match(no.promptCalls[0], /pi-dispatch-valkey-data exists without an owner label: this volume holds a queue pi-dispatch cannot attribute to a folder\. .*Adopt it for \/deploy\? \(asked even under --yes\) \[y\/N\] $/); + assert.match(no.text(), /✗ pi-dispatch-valkey-data has no owner label and was not adopted/); + // Adopted: no create (it exists), run, and the ownership recorded in the queue. + const adoptClaims = []; + const yes = harness({ plan: unl, listening: false, argv: ["--yes"], answers: ["y"], files, extra: { claimValkeyOwner: async (url, folder) => (adoptClaims.push(folder), { owner: folder, claimed: true }) } }); + assert.equal(await yes.run(), 0); + assert.deepEqual(acts(yes), ["run"]); + assert.deepEqual(adoptClaims, ["/deploy"]); + assert.match(yes.text(), /up would start Valkey \(same semantics as deploy\/docker-compose\.yml\) on the adopted pi-dispatch-valkey-data/); + // A queue whose marker names another folder: what this pass started is stopped and removed at once, and up fails. + const marked = harness({ plan: unl, listening: false, argv: ["--yes"], answers: ["y"], files, extra: { claimValkeyOwner: async () => ({ owner: "/srv/a", claimed: false }) } }); + assert.equal(await marked.run(), 1); + assert.deepEqual(acts(marked), ["run", "stop", "rm"]); + assert.match(marked.text(), /✗ the queue on pi-dispatch-valkey-data is the deployment's in \/srv\/a \(pi-dispatch:owner inside it\), not this one's: the Valkey this pass started on it was stopped again at once/); + assert.doesNotMatch(marked.text(), /✓ started Valkey/); + // A marker that cannot be read: up fails, saying so. + const dark = harness({ plan: { ...base }, listening: false, argv: ["--yes"], files, extra: { claimValkeyOwner: async () => ({ error: "Connection is closed." }) } }); + assert.equal(await dark.run(), 1); + assert.match(dark.text(), /✗ Valkey was started, but its pi-dispatch:owner could not be recorded or read \(Connection is closed\.\)/); + assert.match(dark.text(), /up: the Valkey up started could not be asked whose queue it holds/); + // docker not answering whose the volume is: refused. + const blind = harness({ plan: { ...base, "docker volume inspect": { code: 125, stderr: "Cannot connect\n" } }, listening: false, argv: ["--yes"], files }); + assert.equal(await blind.run(), 1); + assert.deepEqual(acts(blind), []); + assert.match(blind.text(), /whose pi-dispatch-valkey-data is could not be read/); +}); + +test("up's other starts on the volume keep the rule: compose's valkey in a handed-over folder asks about an unlabelled volume, the upgrade restart of this deployment's own container does not, and both check the marker (#475 volume gap)", async () => { + const files = { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16495\nVALKEY_PASSWORD=${PASSWORD}\n`, "/deploy/deploy/docker-compose.valkey.yml": "# override\n" }; + const composeUp = (h) => h.calls.filter((c) => c.args[0] === "compose").map((c) => c.args.slice(-2).join(" ")); + const unl = { "docker version": 0, "docker image inspect": 0, "docker compose": 0, "docker volume inspect": volumeRecord(null) }; + const no = harness({ plan: unl, listening: false, argv: ["--yes"], files }); + assert.equal(await no.run(), 1); + assert.deepEqual(composeUp(no), [], "not adopted: compose's valkey not started"); + const yes = harness({ plan: unl, listening: false, argv: ["--yes"], answers: ["y"], files, extra: { claimValkeyOwner: async () => ({ owner: "/srv/a", claimed: false }) } }); + assert.equal(await yes.run(), 1); + assert.deepEqual(composeUp(yes), ["-d valkey", "stop valkey"], "started, then its marker named another folder: stopped at once"); + const theirs = harness({ plan: { ...unl, "docker volume inspect": volumeRecord("/srv/a") }, listening: false, argv: ["--yes"], files }); + assert.equal(await theirs.run(), 1); + assert.deepEqual(composeUp(theirs), []); + // The upgrade restart: this deployment's own pi-dispatch-valkey serves the unlabelled volume now, which attributes it; + // no question, and the marker is checked after the restart. + const rfiles = { "/deploy/.env": `VALKEY_PASSWORD=${SECRET}\n` }; + const claims = []; + const restart = harness({ plan: { ...green, "docker stop": 0, "docker rm": 0, "docker run": 0, "docker volume inspect": volumeRecord(null) }, listening: true, argv: ["--yes"], files: rfiles, extra: { probeValkeyAuth: async () => "ok", claimValkeyOwner: async (url, folder) => (claims.push(folder), { owner: folder, claimed: true }) } }); + assert.equal(await restart.run(), 0); + assert.equal(restart.promptCalls.length, 0); + assert.deepEqual(claims, ["/deploy"]); + const rtheirs = harness({ plan: { ...green, "docker stop": 0, "docker rm": 0, "docker run": 0, "docker volume inspect": volumeRecord("/srv/a") }, listening: true, argv: ["--yes"], files: rfiles, extra: { probeValkeyAuth: async () => "ok" } }); + assert.equal(await rtheirs.run(), 1); + assert.ok(!rtheirs.calls.some((c) => ["stop", "rm", "run"].includes(c.args[0])), "another folder's volume: no restart"); +}); + +// PR #475's review, round 3: PI_VALKEY_PORT lived only in the env up and the wizard hand compose, so the docs' plain +// compose command in the folder published on 6379 while the worker dialled VALKEY_URL's port (measured). +test("up on docker writes PI_VALKEY_PORT into .env when VALKEY_URL's port is not 6379, never over a different value (#475 round 3)", async () => { + const plan = { "docker version": 0, "docker image inspect": 0, "docker volume": 0, "docker run": 0 }; + const h = harness({ plan, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16470\nVALKEY_PASSWORD=${PASSWORD}\n` } }); + assert.equal(await h.run(), 0); + assert.match(h.store.get("/deploy/.env"), /^PI_VALKEY_PORT=16470$/m); + assert.match(h.text(), /✓ wrote PI_VALKEY_PORT=16470 into \.env \(VALKEY_URL's port\)/); + // A different value is named and left. + const differ = harness({ plan, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16470\nVALKEY_PASSWORD=${PASSWORD}\nPI_VALKEY_PORT=16000\n` } }); + await differ.run(); + assert.match(differ.store.get("/deploy/.env"), /^PI_VALKEY_PORT=16000$/m); + assert.doesNotMatch(differ.store.get("/deploy/.env"), /PI_VALKEY_PORT=16470/); + assert.match(differ.text(), /⚠ PI_VALKEY_PORT is 16000 in \/deploy\/\.env, and VALKEY_URL's port is 16470: compose publishes its Valkey on 16000, where the worker does not dial/); + // 6379, or another host: nothing written. + for (const url of ["redis://127.0.0.1:6379", "redis://valkey.internal:16470"]) { + const t = harness({ plan, listening: false, argv: ["--yes"], files: { "/deploy/.env": `VALKEY_URL=${url}\nVALKEY_PASSWORD=${PASSWORD}\n` } }); + await t.run(); + assert.doesNotMatch(t.store.get("/deploy/.env"), /PI_VALKEY_PORT/, url); + } +}); + +test("up on docker in a handed-over folder starts compose's valkey (-p, the override, PI_VALKEY_PORT) and reports a failure", async () => { + const files = { "/deploy/.env": `VALKEY_URL=redis://127.0.0.1:16495\nVALKEY_PASSWORD=${PASSWORD}\n`, "/deploy/deploy/docker-compose.valkey.yml": "# override\n" }; + const composeUp = ["compose", "-p", "deploy", "--env-file", ".env", "-f", "deploy/docker-compose.yml", "-f", "deploy/docker-compose.valkey.yml", "up", "-d", "valkey"]; + const h = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker compose": 0 }, listening: false, argv: ["--yes"], files }); + assert.equal(await h.run(), 0); + const run = h.calls.find((c) => c.cmd === "docker" && c.args[0] === "compose"); + assert.deepEqual(run.args, composeUp, "compose's valkey, the folder's project, the override"); + assert.equal(run.opts.env.PI_VALKEY_PORT, "16495", "on VALKEY_URL's port"); + assert.equal(run.opts.env.VALKEY_PASSWORD, PASSWORD); + assert.ok(!h.calls.some((c) => c.args[0] === "run" || (c.args[0] === "volume" && c.args[1] === "create")), "never a docker run of its own beside it"); + assert.match(h.text(), /PI_VALKEY_PORT=16495 docker compose -p deploy --env-file \.env -f deploy\/docker-compose\.yml -f deploy\/docker-compose\.valkey\.yml up -d valkey/); + assert.match(h.text(), /✓ started compose's valkey \(project deploy, the pi-dispatch-valkey-data volume, 127\.0\.0\.1:16495\)/); + // A failure (the bind that measured round 2 swallowed) is said, and up exits non-zero on it. + const bad = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker compose": 1 }, listening: false, argv: ["--yes"], files }); + assert.equal(await bad.run(), 1); + assert.match(bad.text(), /✗ compose could not start its valkey \(exit 1\): its own output above says why \(another container on 127\.0\.0\.1:16495 is the usual one/); + assert.match(bad.text(), /up: compose could not start this folder's Valkey \(above\); free the port it names, then re-run `pi-dispatch up`\./); + // Running and answering without a password: compose recreates it (never stop/rm/run). + // Ours by compose's own working-dir label: this folder's deploy/ (round 3 of PR #475's review). + const open = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker container inspect deploy-valkey-1": inspectRecord({ name: "deploy-valkey-1", workingDir: "/deploy/deploy", port: 16495 }), "docker compose": 0 }, listening: true, argv: ["--yes"], files, extra: { probeValkeyAuth: async () => "ok" } }); + await open.run(); + const acts = open.calls.filter((c) => c.cmd === "docker" && ["compose", "stop", "rm", "run"].includes(c.args[0])); + assert.deepEqual(acts.map((c) => c.args), [["compose", "-p", "deploy", "--env-file", ".env", "-f", "deploy/docker-compose.yml", "-f", "deploy/docker-compose.valkey.yml", "up", "-d", "--force-recreate", "valkey"]]); + // Round 3 of PR #475's review: compose's valkey here mounts pi-dispatch-valkey-data, so another deployment's container + // on that volume (stopped or not) refuses the start, named, and `up` fails; nothing is run. + const mounted = harness({ plan: { "docker version": 0, "docker image inspect": 0, "docker compose": 0, "docker ps -a": { code: 0, output: "pi-dispatch-valkey\n" }, "docker container inspect pi-dispatch-valkey": inspectRecord({ label: "/srv/b", port: 16496 }) }, listening: false, argv: ["--yes"], files }); + assert.equal(await mounted.run(), 1); + assert.ok(!mounted.calls.some((c) => ["compose", "run", "stop", "rm"].includes(c.args[0]))); + assert.match(mounted.text(), /✗ pi-dispatch-valkey-data is mounted by pi-dispatch-valkey \(the deployment in \/srv\/b\), which is not this deployment's Valkey/); + // A refusal is not a decline: nothing was asked (measured on Fedora, where it first read "skipped (declined)"). + assert.doesNotMatch(mounted.text(), /skipped \(declined|skipped: start it later|declined the restart|skipped: until it restarts/); + // A folder without the override: every compose hint names the -p a wizard folder needs. + const plain = harness({ plan: { "docker version": 0, "docker image inspect": 0 }, listening: false, answers: [""], files: { "/deploy/.env": `VALKEY_PASSWORD=${PASSWORD}\n` } }); + await plain.run(); + assert.match(plain.text(), /skipped: start it later with `docker compose --env-file \.env -f deploy\/docker-compose\.yml up -d` \(in a folder \/dispatch setup laid out, with -p deploy after `compose`\)/); +}); diff --git a/worker/test/valkey-auth.test.mjs b/worker/test/valkey-auth.test.mjs new file mode 100644 index 00000000..ab40d77a --- /dev/null +++ b/worker/test/valkey-auth.test.mjs @@ -0,0 +1,760 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tempDir } from "./helpers/temp-dir.mjs"; +import { authRefusalFor, judgeValkeyAtStart, makeRedisClient, parseConnection, valkeyAuthState } from "../src/connection.mjs"; +import { readEnvAssignments, renderEnvValue, updateEnvFile } from "../src/env-file.mjs"; +import { readValkeyKeys } from "../src/podman-stack.mjs"; +import { refuseValkeyAuth } from "../src/start.mjs"; +import { DEPLOYMENT_LABEL, OWNER_MARKER_KEY, valkeyVolumeCreateArgs, valkeyVolumeOwner, VALKEY_HEALTH_SCRIPT, VALKEY_PASSWORD_KEY, VALKEY_PORT_KEY, VALKEY_START_SCRIPT, VALKEY_VOLUME, composeHandoverPlan, dollarsDoubled, foreignVolumeUsers, valkeyContainerIsOurs, valkeyContainerOwner, valkeyPortEnvDecision, isLoopbackHost, managedValkeyUrl, newValkeyPassword, valkeyAuthRefusal, valkeyDockerRunArgs, valkeyEnvFileText, valkeyPasswordDecision, valkeyPasswordProblem } from "../src/valkey-auth.mjs"; +import { defaultValkeyContext, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, valkeyPasswordFor } from "../src/valkey-endpoint.mjs"; + +// Issue #468: the Valkey pi-dispatch starts gets a per-deployment password. Every rule here is one an account on the same +// host would otherwise walk through: read, enqueue or delete another account's jobs. + +const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const PW = "0123456789abcdef".repeat(4); +const ctx = (password = {}, over = {}) => ({ envPath: "/d/.env", shared: false, rootRefused: false, error: null, password: { environment: null, file: null, ...password }, ...over }); + +test("a generated password is 64 hex characters that every .env loader reads back byte for byte (#468)", () => { + const seen = new Set(); + for (let i = 0; i < 50; i++) { + const pw = newValkeyPassword(); + assert.match(pw, /^[0-9a-f]{64}$/); + assert.equal(valkeyPasswordProblem(pw), null); + seen.add(pw); + // The writer renders it bare (nothing to quote), and systemd, a sourcing shell and the cmd wrapper all read the same. + assert.equal(renderEnvValue(pw, { platform: "linux" }), pw); + for (const loader of ["systemd", "shell", "cmd"]) { + const read = readEnvAssignments(`A=1\n${VALKEY_PASSWORD_KEY}=${pw}\n`, [VALKEY_PASSWORD_KEY], { loader })[VALKEY_PASSWORD_KEY]; + assert.deepEqual([read.plain, read.value], [true, pw], loader); + } + assert.deepEqual(readValkeyKeys(Buffer.from(`${VALKEY_PASSWORD_KEY}=${pw}\n`)).keys, { [VALKEY_PASSWORD_KEY]: pw }, "the hardened reader every client uses"); + } + assert.equal(seen.size, 50, "random, not a constant"); + assert.equal(newValkeyPassword(() => Buffer.alloc(32, 0xab)), "ab".repeat(32), "the randomness is the one seam"); +}); + +test("a password the start script could not hand to Valkey is refused, with the way to make one (#468)", () => { + for (const ok of [PW, "A-Za_z0123456789", "x".repeat(512)]) assert.equal(valkeyPasswordProblem(ok), null, ok); + for (const [bad, why] of [["", /empty/], ["has space inside it", /character other than/], ['quote"d-0123456789', /character other than/], ["hash#0123456789abc", /character other than/], ["short", /at least 16/], ["x".repeat(513), /over the 512/], ["dollar$0123456789ab", /character other than/]]) { + assert.match(valkeyPasswordProblem(bad), why, bad); + } + const decided = valkeyPasswordDecision({ VALKEY_PASSWORD: "bad value here!" }, { envPath: "/d/.env" }); + assert.match(decided.error, /^VALKEY_PASSWORD in \/d\/\.env cannot be handed to Valkey: .*`openssl rand -hex 32`/); + assert.ok(!decided.error.includes("bad value here!"), "the value is never quoted back"); +}); + +test("valkeyPasswordDecision: kept when set, generated only for this machine's own Valkey, never for a shared or an operator's one (#468)", () => { + assert.deepEqual(valkeyPasswordDecision({ VALKEY_PASSWORD: PW }), { password: PW, generate: false, note: null }, "never replaced"); + assert.deepEqual(valkeyPasswordDecision({}), { password: null, generate: true, note: null }, "unset VALKEY_URL is 127.0.0.1"); + assert.equal(valkeyPasswordDecision({ VALKEY_PASSWORD: "" }).generate, true, "an empty line is filled, as WEBHOOK_SECRET's is"); + for (const url of ["redis://127.0.0.1:6380", "redis://localhost:6379", "redis://[::1]:6379"]) assert.equal(valkeyPasswordDecision({ VALKEY_URL: url }).generate, true, url); + const shared = valkeyPasswordDecision({ PI_VALKEY_SHARED: "1" }, { envPath: "/d/.env" }); + assert.deepEqual([shared.generate, shared.password], [false, null]); + assert.match(shared.note, /PI_VALKEY_SHARED=1 in \/d\/\.env: no VALKEY_PASSWORD is generated, since the shared Valkey is another account's\. Put that Valkey's password in VALKEY_PASSWORD/); + for (const url of ["redis://queue.lan:6379", "rediss://managed.example:6380", "redis://:secret@127.0.0.1:6379", "redis://user:pw@localhost:6379", "not a url"]) { + const d = valkeyPasswordDecision({ VALKEY_URL: url }); + assert.deepEqual([d.generate, d.password], [false, null], url); + } + assert.equal(managedValkeyUrl(undefined), true); + for (const h of ["127.0.0.1", "127.9.9.9", "localhost", "LOCALHOST", "::1", "[::1]", "0.0.0.0", "::", "::ffff:127.0.0.1"]) assert.equal(isLoopbackHost(h), true, h); + for (const h of ["10.0.0.5", "valkey", "queue.lan", "128.0.0.1", "::2", "localhost.example"]) assert.equal(isLoopbackHost(h), false, h); +}); + +// --------------------------------------------------------------------------------------------------------------------- +// The one start script, in three carriers, and never on a command line +// --------------------------------------------------------------------------------------------------------------------- + +const quadlet = readFileSync(join(REPO, "deploy", "pi-dispatch-valkey.container"), "utf8"); +const compose = readFileSync(join(REPO, "deploy", "docker-compose.yml"), "utf8"); +const undoubled = (s) => s.replaceAll("$$", "$"); + +test("the Quadlet unit, the compose file and `up`'s docker run carry the ONE start script and health check, the password in the environment only (#468)", () => { + // Quadlet: Exec=sh -c '