@edgehero/pi-dispatch 1.10.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +300 -148
- package/README.md +50 -0
- package/deploy/com.pi-dispatch.worker.plist +9 -3
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +11 -0
- package/deploy/worker-env-wrapper.sh +60 -34
- package/deploy/worker.service +18 -8
- package/package.json +14 -4
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4701 -414
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +455 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +222 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/processor.mjs +505 -26
- package/src/provider-key.mjs +41 -0
- package/src/provider-steering.mjs +144 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +23 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +176 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The password of the Valkey pi-dispatch starts (issue #468).
|
|
3
|
+
*
|
|
4
|
+
* Loopback is shared by every account on a host. #464 stops one account from ADOPTING another's Valkey by accident;
|
|
5
|
+
* it does nothing about a co-tenant who connects on purpose, and a Valkey with no password lets any local account read
|
|
6
|
+
* the queued jobs (their task text, their repositories), enqueue work another account's worker runs with that
|
|
7
|
+
* account's provider key and forge credentials, and delete the queue. So every deployment's Valkey gets a password of
|
|
8
|
+
* its own, generated once, kept in the deployment's `.env` as `VALKEY_PASSWORD`.
|
|
9
|
+
*
|
|
10
|
+
* A key of its own, NOT a password inside VALKEY_URL: VALKEY_URL is printed by doctor, written into the admin's
|
|
11
|
+
* deployment pointer (which by contract never carries a credential) and quoted in refusals, so a URL that carried the
|
|
12
|
+
* password would be a credential in every one of those places. A separate key stays out of all of them, and doctor
|
|
13
|
+
* says only whether it is set. An operator's own VALKEY_URL with its own userinfo (a managed Valkey, TLS) is left as it
|
|
14
|
+
* is, and its password wins over this key (`connection.mjs`).
|
|
15
|
+
*
|
|
16
|
+
* HOW THE VALKEY LEARNS IT, measured on Podman 5.8.1 (Fedora 44) and 4.9.3 (Ubuntu 24.04) with valkey/valkey:8
|
|
17
|
+
* (Valkey 8.1.10): the image runs `tini -- docker-entrypoint.sh <command>`, and tini keeps its whole argv as PID 1 for
|
|
18
|
+
* the life of the container, so `valkey-server --requirepass X` put the password in a /proc/<pid>/cmdline that another
|
|
19
|
+
* account read, on the host, for as long as the Valkey ran (the pid namespace hides nothing: /proc of the host lists
|
|
20
|
+
* the container's processes, and cmdline is world-readable). The image's VALKEY_EXTRA_FLAGS only appends to that same
|
|
21
|
+
* argv. So the password travels in the container's ENVIRONMENT (a Quadlet `EnvironmentFile=` of a 0600 file, compose's
|
|
22
|
+
* `environment:`, `docker run -e VALKEY_PASSWORD` with the value in the CLI's own environment), which /proc shows only
|
|
23
|
+
* to the account itself, and `VALKEY_START_SCRIPT` hands it to valkey-server as CONFIGURATION ON STDIN (`valkey-server
|
|
24
|
+
* -`): written to a 0600 temp file by the shell's own `echo` builtin (no process, so no argv), opened as stdin, deleted,
|
|
25
|
+
* then the image's entrypoint runs as it always did (its chown and its drop to the `valkey` user), and valkey-server
|
|
26
|
+
* reads the one `requirepass` line from the open descriptor. Measured: an account scanning every /proc/<pid>/cmdline
|
|
27
|
+
* during start and at rest saw no password, and nothing is left in the container's /tmp. With VALKEY_PASSWORD empty or
|
|
28
|
+
* unset the same script starts Valkey with no password, exactly as before, so an older `.env` keeps working.
|
|
29
|
+
*
|
|
30
|
+
* `$` and `%` are the two characters the script must survive in three carriers: systemd expands `$X` and `%x` in the
|
|
31
|
+
* ExecStart Quadlet generates, and compose interpolates `$X`. The script has no `%` and no backslash, and every `$` is
|
|
32
|
+
* written `$$` in the Quadlet unit and the compose file (`dollarsDoubled`), and a test reads each carrier's copy back and
|
|
33
|
+
* holds all three equal to this constant.
|
|
34
|
+
*/
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
36
|
+
import { basename, join } from "node:path";
|
|
37
|
+
|
|
38
|
+
/** The `.env` key (issue #468). */
|
|
39
|
+
export const VALKEY_PASSWORD_KEY = "VALKEY_PASSWORD";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A new password: 32 random bytes as 64 lowercase hex characters. Hex because every loader of `.env` reads it back
|
|
43
|
+
* byte for byte (systemd, the shells, the cmd wrapper split on `=`), nothing in it is special to a Valkey config line,
|
|
44
|
+
* a URL or a shell, and it matches the WEBHOOK_SECRET `up` already generates.
|
|
45
|
+
*/
|
|
46
|
+
export function newValkeyPassword(random = randomBytes) {
|
|
47
|
+
return random(32).toString("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What is wrong with `value` as a Valkey password this project hands to valkey-server, or null. Only base64url
|
|
52
|
+
* 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,
|
|
53
|
+
* a quote or a `#` would change what the line says, and `.env` must read it back the same under every loader.
|
|
54
|
+
*/
|
|
55
|
+
export function valkeyPasswordProblem(value) {
|
|
56
|
+
if (typeof value !== "string" || value === "") return "it is empty";
|
|
57
|
+
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";
|
|
58
|
+
if (value.length < 16) return `it is ${value.length} characters long, and a password this project hands to Valkey needs at least 16`;
|
|
59
|
+
if (value.length > 512) return `it is ${value.length} characters long, over the 512 this project accepts`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The sentence that tells an operator how to make a good one. */
|
|
64
|
+
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";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The container command that starts Valkey with the password from its environment, as configuration on stdin (see the
|
|
68
|
+
* header). One copy, in three carriers: `deploy/pi-dispatch-valkey.container`'s Exec=, `deploy/docker-compose.yml`'s
|
|
69
|
+
* command, and `valkeyDockerRunArgs` (`up` and doctor's fix). `--appendonly yes` as before (REQ-QUEUE-BURST-NO-DROP).
|
|
70
|
+
*/
|
|
71
|
+
export const VALKEY_START_SCRIPT =
|
|
72
|
+
'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';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The health check, run by the container runtime through `/bin/sh -c`: healthy only when Valkey answers PONG to this
|
|
76
|
+
* deployment's own password (REDISCLI_AUTH, which valkey-cli reads from its environment, never an argv). A bare
|
|
77
|
+
* `valkey-cli ping` was measured to exit 0 on `NOAUTH Authentication required.`, so it could not tell a Valkey that
|
|
78
|
+
* refuses its own password from a healthy one.
|
|
79
|
+
*/
|
|
80
|
+
export const VALKEY_HEALTH_SCRIPT = 'if [ -n "${VALKEY_PASSWORD:-}" ]; then REDISCLI_AUTH="$VALKEY_PASSWORD"; export REDISCLI_AUTH; fi; valkey-cli ping | grep -q PONG';
|
|
81
|
+
|
|
82
|
+
/** The Quadlet and compose spelling of a script: every `$` doubled, which each carrier reads back as one. */
|
|
83
|
+
export function dollarsDoubled(script) {
|
|
84
|
+
return script.replaceAll("$", () => "$$");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `deploy/docker-compose.yml`'s Valkey, as one `docker run` (`up`'s docker step and doctor's fix), published on `port`. `-e VALKEY_PASSWORD`
|
|
89
|
+
* names the variable only: the docker CLI takes its value from its OWN environment (the caller's spawn env), so the
|
|
90
|
+
* password is on no argv. Same image, AOF on, loopback only, the named volume, the health check above.
|
|
91
|
+
*/
|
|
92
|
+
export function valkeyDockerRunArgs({ port = 6379, deployment = null } = {}) {
|
|
93
|
+
return [
|
|
94
|
+
"run",
|
|
95
|
+
"-d",
|
|
96
|
+
"--name",
|
|
97
|
+
"pi-dispatch-valkey",
|
|
98
|
+
// Whose Valkey this is (PR #475's review, round 3): the deployment folder's real path, so a later `up`, the wizard's
|
|
99
|
+
// hand-over or a password restart acts on it only from that folder (`valkeyContainerIsOurs`).
|
|
100
|
+
...(deployment ? ["--label", `${DEPLOYMENT_LABEL}=${deployment}`] : []),
|
|
101
|
+
"--restart",
|
|
102
|
+
"unless-stopped",
|
|
103
|
+
"-p",
|
|
104
|
+
// VALKEY_URL's port on 127.0.0.1 (the container listens on 6379 inside), as the Quadlet unit's PublishPort= is.
|
|
105
|
+
`127.0.0.1:${port}:6379`,
|
|
106
|
+
"-v",
|
|
107
|
+
"pi-dispatch-valkey-data:/data",
|
|
108
|
+
"-e",
|
|
109
|
+
VALKEY_PASSWORD_KEY,
|
|
110
|
+
"--health-cmd",
|
|
111
|
+
VALKEY_HEALTH_SCRIPT,
|
|
112
|
+
"--health-interval",
|
|
113
|
+
"10s",
|
|
114
|
+
"--health-timeout",
|
|
115
|
+
"3s",
|
|
116
|
+
"--health-retries",
|
|
117
|
+
"5",
|
|
118
|
+
"valkey/valkey:8",
|
|
119
|
+
"sh",
|
|
120
|
+
"-c",
|
|
121
|
+
VALKEY_START_SCRIPT,
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The 0600 file the Quadlet Valkey reads its password from (`EnvironmentFile=%h/.config/pi-dispatch/valkey.env`): the
|
|
127
|
+
* one key, nothing else of the deployment's `.env` (whose provider key and forge credentials the Valkey container has no
|
|
128
|
+
* use for). Empty value for a deployment with no password, which the start script reads as "start without one".
|
|
129
|
+
*/
|
|
130
|
+
export function valkeyEnvFileText(password) {
|
|
131
|
+
return `# Written by pi-dispatch (service install / up) from VALKEY_PASSWORD in the deployment's .env; mode 0600.\n${VALKEY_PASSWORD_KEY}=${password ?? ""}\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether a VALKEY_URL is one pi-dispatch generates a password for: its host is this machine's loopback (unset is the
|
|
136
|
+
* default, 127.0.0.1) and it carries no userinfo. A URL naming another host, or carrying its own credentials, is the
|
|
137
|
+
* operator's own Valkey and is left as it is.
|
|
138
|
+
*/
|
|
139
|
+
export function managedValkeyUrl(url) {
|
|
140
|
+
if (url === undefined || url === null || url === "") return true;
|
|
141
|
+
let u;
|
|
142
|
+
try {
|
|
143
|
+
u = new URL(String(url));
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (u.username !== "" || u.password !== "") return false;
|
|
148
|
+
return isLoopbackHost(u.hostname);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A loopback host as a client dials it: 127.0.0.0/8, ::1 (bracketed or not), `localhost`, or an unspecified address. */
|
|
152
|
+
export function isLoopbackHost(hostname) {
|
|
153
|
+
const h = String(hostname ?? "").toLowerCase().replace(/^\[(.*)\]$/, "$1");
|
|
154
|
+
if (h === "localhost" || h === "::1" || h === "::" || h === "0:0:0:0:0:0:0:1") return true;
|
|
155
|
+
if (/^::ffff:(127|0)\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
|
156
|
+
return /^(127|0)\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The "is it running?" half of a could-not-reach-Valkey line (issue #480, PR #488's review). `pi-dispatch up` is named
|
|
161
|
+
* only for a loopback VALKEY_URL, the only Valkey `up` starts; a Valkey on another host is asked about by its host
|
|
162
|
+
* (`URL.hostname`: never userinfo, a path or a query), and a URL that does not parse by nothing at all.
|
|
163
|
+
*/
|
|
164
|
+
export function valkeyDownHint(url) {
|
|
165
|
+
let host = null;
|
|
166
|
+
try {
|
|
167
|
+
host = new URL(String(url)).hostname || null;
|
|
168
|
+
} catch {
|
|
169
|
+
host = null;
|
|
170
|
+
}
|
|
171
|
+
if (host !== null && isLoopbackHost(host)) return "is it running? (`pi-dispatch up` in the deployment folder starts it)";
|
|
172
|
+
return host !== null ? `is the Valkey at ${host} running?` : "is it running?";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The refusal for a Valkey that rejected this client's credential, or null for any other error: NOAUTH (it requires a
|
|
177
|
+
* password and none was sent), WRONGPASS or "invalid password" (one was sent and it is not that Valkey's). `from` is
|
|
178
|
+
* where the password that was sent came from (`valkeyPasswordFor`), `envPath` the deployment `.env`; the sentence names
|
|
179
|
+
* the key and its source, never a value.
|
|
180
|
+
*/
|
|
181
|
+
export function valkeyAuthRefusal(err, { passwordSet, from = null, envPath = "the deployment's .env" } = {}) {
|
|
182
|
+
const text = String(err?.message ?? err ?? "");
|
|
183
|
+
if (/\bNOAUTH\b/.test(text) && !passwordSet) {
|
|
184
|
+
return `the Valkey VALKEY_URL reaches requires a password, and this deployment sets none: put ${VALKEY_PASSWORD_KEY}=<that Valkey's password> in ${envPath} (a Valkey shared with PI_VALKEY_SHARED=1 takes the password of the account that runs it)`;
|
|
185
|
+
}
|
|
186
|
+
if (/\bWRONGPASS\b|invalid password|\bNOAUTH\b/i.test(text)) {
|
|
187
|
+
const source = from === "VALKEY_URL" ? "the password in VALKEY_URL" : `${VALKEY_PASSWORD_KEY} from ${from ?? envPath}`;
|
|
188
|
+
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`;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether `service install` or `up` generates a password for this deployment (issue #468), from its `.env` keys as the
|
|
195
|
+
* service reads them (`readValkeyKeys`). Returns `{ password, generate, note }` or `{ error }`:
|
|
196
|
+
* - VALKEY_PASSWORD set: kept, never replaced (a rotation is the operator's edit), and refused when the start script
|
|
197
|
+
* could not hand it to Valkey (`valkeyPasswordProblem`);
|
|
198
|
+
* - PI_VALKEY_SHARED=1: none generated. The shared Valkey is another account's, and its password is that account's
|
|
199
|
+
* to give; one made here would reach no Valkey;
|
|
200
|
+
* - a VALKEY_URL that is not this machine's loopback, or that carries its own userinfo: none generated, the
|
|
201
|
+
* operator's own Valkey left as it is;
|
|
202
|
+
* - otherwise `generate`: a new one goes into `.env` (never over a value) and into the Valkey the command starts.
|
|
203
|
+
*/
|
|
204
|
+
export function valkeyPasswordDecision(keys, { envPath = ".env" } = {}) {
|
|
205
|
+
const current = keys?.[VALKEY_PASSWORD_KEY];
|
|
206
|
+
if (typeof current === "string" && current !== "") {
|
|
207
|
+
const problem = valkeyPasswordProblem(current);
|
|
208
|
+
if (problem) return { error: `${VALKEY_PASSWORD_KEY} in ${envPath} cannot be handed to Valkey: ${problem}. ${VALKEY_PASSWORD_HOWTO}` };
|
|
209
|
+
return { password: current, generate: false, note: null };
|
|
210
|
+
}
|
|
211
|
+
if (keys?.PI_VALKEY_SHARED === "1") {
|
|
212
|
+
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}` };
|
|
213
|
+
}
|
|
214
|
+
if (!managedValkeyUrl(keys?.VALKEY_URL)) {
|
|
215
|
+
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` };
|
|
216
|
+
}
|
|
217
|
+
return { password: null, generate: true, note: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
221
|
+
// The compose file's Valkey, as `up` and the setup wizard drive it (PR #475's review)
|
|
222
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
/** The shipped compose file and the wizard's hand-over override, relative to the deployment folder. */
|
|
225
|
+
export const COMPOSE_FILE = "deploy/docker-compose.yml";
|
|
226
|
+
export const COMPOSE_VALKEY_OVERRIDE = "deploy/docker-compose.valkey.yml";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The compose project name of a deployment folder: its basename, normalised as compose v2 normalises a directory name
|
|
230
|
+
* (lower case, only a-z 0-9 _ -, no leading _ or -; measured with compose 2.31 and 5.5.1: "My Deploy.v2_x" is
|
|
231
|
+
* "mydeployv2_x"). A name with none of those characters left ("日本") is one compose itself refuses ("project name must
|
|
232
|
+
* not be empty", measured), and is "pi-dispatch" here. Compose names a project after the directory of its FIRST file,
|
|
233
|
+
* `deploy/` in every folder the wizard lays out, so `-p` is what keeps the folder's own project.
|
|
234
|
+
*/
|
|
235
|
+
export function composeProjectName(dir) {
|
|
236
|
+
const name = basename(String(dir)).toLowerCase().replace(/[^a-z0-9_-]/g, "").replace(/^[_-]+/, "");
|
|
237
|
+
return name === "" ? "pi-dispatch" : name;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The start of every compose command for a deployment folder, run FROM that folder: `-p` when a project is named (a
|
|
242
|
+
* folder the wizard laid out), `--env-file .env` (VALKEY_PASSWORD, issue #468), the shipped file, and the hand-over
|
|
243
|
+
* override when the folder has one.
|
|
244
|
+
*/
|
|
245
|
+
export function composeArgs({ project = null, override = false } = {}) {
|
|
246
|
+
return ["compose", ...(project ? ["-p", project] : []), "--env-file", ".env", "-f", COMPOSE_FILE, ...(override ? ["-f", COMPOSE_VALKEY_OVERRIDE] : [])];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The environment compose's Valkey reads its published port from (PR #475's review): `PI_VALKEY_PORT`, VALKEY_URL's
|
|
251
|
+
* port, so the queue stays where the worker dials it (the compose file said 6379 whatever VALKEY_URL said, and a
|
|
252
|
+
* hand-over moved a 16495 deployment's queue to 6379, measured). The compose file reads it as `${PI_VALKEY_PORT:-6379}`.
|
|
253
|
+
*/
|
|
254
|
+
export const VALKEY_PORT_KEY = "PI_VALKEY_PORT";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The compose override the wizard writes when `up` already runs the deployment's Valkey: compose's own `valkey`
|
|
258
|
+
* service mounts `up`'s volume, `pi-dispatch-valkey-data`, instead of a project volume of its own. Written once,
|
|
259
|
+
* create-only; `up` and every later command for the folder name it.
|
|
260
|
+
*/
|
|
261
|
+
export const VALKEY_HANDOVER_OVERRIDE = `# Written by /dispatch setup (the docker compose answer): this deployment's Valkey was the one \`pi-dispatch up\`
|
|
262
|
+
# started (container pi-dispatch-valkey, volume pi-dispatch-valkey-data). compose's valkey service takes over that
|
|
263
|
+
# VOLUME, so the worker and the receiver container (valkey:6379) use one Valkey and one queue. \`pi-dispatch up\` starts
|
|
264
|
+
# this Valkey (with -p <this folder's project> and this file) whenever the folder has it.
|
|
265
|
+
services:
|
|
266
|
+
valkey:
|
|
267
|
+
volumes:
|
|
268
|
+
- pi-dispatch-valkey-data:/data
|
|
269
|
+
volumes:
|
|
270
|
+
pi-dispatch-valkey-data:
|
|
271
|
+
external: true
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
275
|
+
// Whose Valkey container and volume (PR #475's review, round 3: THE simpler rule)
|
|
276
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
/** The label `up` puts on the Valkey container it creates: the deployment folder's real path. */
|
|
279
|
+
export const DEPLOYMENT_LABEL = "com.pi-dispatch.deployment";
|
|
280
|
+
|
|
281
|
+
/** The named volume `up`'s Valkey (and a handed-over compose Valkey) keeps its AOF on. */
|
|
282
|
+
export const VALKEY_VOLUME = "pi-dispatch-valkey-data";
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a container (one record of `docker container inspect`) is PROVABLY this deployment's Valkey. Nothing ever
|
|
286
|
+
* stops, removes, reuses or mounts beside a container this answers false for: the review found the wizard's hand-over
|
|
287
|
+
* removing another deployment's Valkey, and `up` starting a second Valkey on one AOF, both from treating a name or a
|
|
288
|
+
* volume as proof of ownership. Proof is one of:
|
|
289
|
+
* - `up`'s label, `com.pi-dispatch.deployment`, equal to this deployment folder (`dirs`: its real path and as given);
|
|
290
|
+
* - compose's own `com.docker.compose.project.working_dir`, this folder or its `deploy/` (a compose Valkey of this
|
|
291
|
+
* folder, the hand-over's included);
|
|
292
|
+
* - for a legacy, unlabelled `pi-dispatch-valkey` only: it publishes this deployment's VALKEY_URL port on 127.0.0.1,
|
|
293
|
+
* up's own rule since #464 (it is the Valkey this deployment's worker dials).
|
|
294
|
+
*/
|
|
295
|
+
export function valkeyContainerIsOurs(info, { dirs, port }) {
|
|
296
|
+
const labels = info?.Config?.Labels ?? {};
|
|
297
|
+
const mine = new Set(dirs.filter(Boolean).flatMap((d) => [d, join(d, "deploy")]));
|
|
298
|
+
if (typeof labels[DEPLOYMENT_LABEL] === "string") return mine.has(labels[DEPLOYMENT_LABEL]);
|
|
299
|
+
const wd = labels["com.docker.compose.project.working_dir"];
|
|
300
|
+
if (typeof wd === "string") return mine.has(wd);
|
|
301
|
+
const name = String(info?.Name ?? "").replace(/^\//, "");
|
|
302
|
+
if (name !== "pi-dispatch-valkey") return false;
|
|
303
|
+
return publishedPorts(info).includes(Number(port));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The host ports a container publishes its 6379 on, from its inspect record (HostConfig, else NetworkSettings). */
|
|
307
|
+
function publishedPorts(info) {
|
|
308
|
+
const bindings = info?.HostConfig?.PortBindings?.["6379/tcp"] ?? info?.NetworkSettings?.Ports?.["6379/tcp"] ?? [];
|
|
309
|
+
return (Array.isArray(bindings) ? bindings : []).filter((b) => !b?.HostIp || b.HostIp === "127.0.0.1").map((b) => Number(b?.HostPort)).filter(Number.isInteger);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Who a container belongs to, as a refusal names it: its label, compose's working dir, or what it publishes. */
|
|
313
|
+
function ownerShown(info) {
|
|
314
|
+
const labels = info?.Config?.Labels ?? {};
|
|
315
|
+
if (labels[DEPLOYMENT_LABEL]) return `the deployment in ${labels[DEPLOYMENT_LABEL]}`;
|
|
316
|
+
if (labels["com.docker.compose.project.working_dir"]) return `the compose project in ${labels["com.docker.compose.project.working_dir"]}`;
|
|
317
|
+
const ports = publishedPorts(info);
|
|
318
|
+
return ports.length > 0 ? `unlabelled, publishing 127.0.0.1:${ports.join(", ")}` : "unlabelled, publishing nothing on 127.0.0.1";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* One container by name: `{ absent }`, `{ ours, owner }`, or `{ unknown }` when docker did not answer (which is never
|
|
323
|
+
* taken as ours). `query(cmd, args)` resolves `{ code, stdout, stderr }`.
|
|
324
|
+
*/
|
|
325
|
+
export async function valkeyContainerOwner(name, { dirs, port, query }) {
|
|
326
|
+
const res = await query("docker", ["container", "inspect", name]);
|
|
327
|
+
if (res.code !== 0) return /no such (container|object)/i.test(String(res.stderr ?? "")) ? { absent: true } : { unknown: `docker container inspect ${name} exited ${res.code}` };
|
|
328
|
+
let info;
|
|
329
|
+
try {
|
|
330
|
+
info = JSON.parse(String(res.stdout))[0];
|
|
331
|
+
} catch {
|
|
332
|
+
return { unknown: `docker container inspect ${name} printed no record` };
|
|
333
|
+
}
|
|
334
|
+
return { ours: valkeyContainerIsOurs(info, { dirs, port }), owner: ownerShown(info) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The containers that mount `pi-dispatch-valkey-data` and are NOT provably this deployment's (PR #475's review, round
|
|
339
|
+
* 3). Asked before anything starts a Valkey on that volume: two valkey-servers appending to one AOF corrupt both queues
|
|
340
|
+
* (measured: a second deployment's `up` ran pi-dispatch-valkey on the volume a handed-over compose Valkey mounted, and
|
|
341
|
+
* both appended to /data/appendonlydir). `{ foreign: [{ name, owner }] }`, or `{ unknown }` when docker could not be
|
|
342
|
+
* asked, which callers refuse on too.
|
|
343
|
+
*/
|
|
344
|
+
export async function foreignVolumeUsers({ dirs, port, query }) {
|
|
345
|
+
const ps = await query("docker", ["ps", "-a", "--filter", `volume=${VALKEY_VOLUME}`, "--format", "{{.Names}}"]);
|
|
346
|
+
if (ps.code !== 0) return { unknown: `docker ps -a --filter volume=${VALKEY_VOLUME} exited ${ps.code}` };
|
|
347
|
+
const foreign = [];
|
|
348
|
+
for (const name of String(ps.stdout ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
349
|
+
const who = await valkeyContainerOwner(name, { dirs, port, query });
|
|
350
|
+
if (who.absent) continue;
|
|
351
|
+
if (who.unknown) foreign.push({ name, owner: who.unknown });
|
|
352
|
+
else if (!who.ours) foreign.push({ name, owner: who.owner });
|
|
353
|
+
}
|
|
354
|
+
return { foreign };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The refusal for a volume another deployment's Valkey mounts, naming it and the ways out. */
|
|
358
|
+
export function foreignVolumeRefusal(check) {
|
|
359
|
+
if (check.unknown) return `whether another Valkey mounts ${VALKEY_VOLUME} could not be read (${check.unknown}), so no Valkey is started on it`;
|
|
360
|
+
const names = check.foreign.map((f) => `${f.name} (${f.owner})`).join(", ");
|
|
361
|
+
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)`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The refusal for a `pi-dispatch-valkey` container that is not this deployment's: never stopped, removed or reused. */
|
|
365
|
+
export function foreignContainerSentence(name, owner) {
|
|
366
|
+
return `${name} is not this deployment's Valkey (${owner}), so it is never stopped, removed or reused from here`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What `.env` should say about PI_VALKEY_PORT for a deployment whose VALKEY_URL names `port` (PR #475's review, round
|
|
371
|
+
* 3): the port lived only in the env `up` and the wizard hand compose, so a plain `docker compose up -d` in the folder
|
|
372
|
+
* published its Valkey on 6379 while the worker dialled the URL's port. `assigned` is the value `.env` holds, or
|
|
373
|
+
* undefined. `{ write }` where the port is not 6379 and `.env` names none, `{ conflict }` (a sentence) where `.env`
|
|
374
|
+
* names another port, never overwritten; else `{}`.
|
|
375
|
+
*/
|
|
376
|
+
export function valkeyPortEnvDecision(assigned, port, { envPath = ".env" } = {}) {
|
|
377
|
+
const have = typeof assigned === "string" ? assigned.trim() : "";
|
|
378
|
+
if (have === "") return Number(port) === 6379 ? {} : { write: String(port) };
|
|
379
|
+
if (have === String(port)) return {};
|
|
380
|
+
return { conflict: valkeyPortConflict(have, port, envPath) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The sentence for a PI_VALKEY_PORT that disagrees with VALKEY_URL's port (shared by `up`, the wizard and doctor). */
|
|
384
|
+
export function valkeyPortConflict(assigned, port, envPath = ".env") {
|
|
385
|
+
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`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The wizard's hand-over question under the ownership rule (PR #475's review, round 3), here so the rule is one function
|
|
390
|
+
* both the wizard and a test on a real docker run. `{ handover, note }` or `{ refused }`:
|
|
391
|
+
* - `pi-dispatch-valkey` is handed to compose only when it is PROVABLY this deployment's (`valkeyContainerIsOurs`);
|
|
392
|
+
* one that is another deployment's is never stopped or removed (measured: the hand-over removed another
|
|
393
|
+
* deployment's Valkey and cut its worker off), and `note` names it;
|
|
394
|
+
* - where compose's valkey would mount `pi-dispatch-valkey-data` (the override on disk, or a hand-over), a container
|
|
395
|
+
* on that volume that is not this deployment's refuses the whole step (`foreignVolumeRefusal`), and so does a
|
|
396
|
+
* volume labelled for another folder; an unlabelled one is taken as this deployment's where its own
|
|
397
|
+
* pi-dispatch-valkey serves it (the hand-over), else `adopt` asks the caller to get consent first;
|
|
398
|
+
* - docker not answering is a refusal, never a guess.
|
|
399
|
+
* The started Valkey's `pi-dispatch:owner` marker is the caller's to check (`claimValkeyOwner`, connection.mjs).
|
|
400
|
+
*/
|
|
401
|
+
export async function composeHandoverPlan({ dirs, port, override, query, record = null }) {
|
|
402
|
+
const who = await valkeyContainerOwner("pi-dispatch-valkey", { dirs, port, query });
|
|
403
|
+
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` };
|
|
404
|
+
const handover = who.ours === true;
|
|
405
|
+
const note = who.ours === false ? `${foreignContainerSentence("pi-dispatch-valkey", who.owner)}; it is left running` : null;
|
|
406
|
+
if (override || handover) {
|
|
407
|
+
const check = await foreignVolumeUsers({ dirs, port, query });
|
|
408
|
+
if (check.unknown || check.foreign.length > 0) return { refused: foreignVolumeRefusal(check) };
|
|
409
|
+
// The volume's own owner (the volume gap): another folder's label is never used; an unlabelled one is this
|
|
410
|
+
// deployment's when this deployment's own pi-dispatch-valkey serves it now (the hand-over), else only with consent.
|
|
411
|
+
const volume = await valkeyVolumeOwner({ dirs, query });
|
|
412
|
+
if (volume.unknown) return { refused: `whose ${VALKEY_VOLUME} is could not be read (${volume.unknown}), so nothing is stopped or started` };
|
|
413
|
+
if (volume.ours === false) return { refused: foreignVolumeLabelRefusal(volume.owner) };
|
|
414
|
+
if (volume.unlabelled && !handover && !volumeRecordMatches(record, volume)) return { handover, note, adopt: true };
|
|
415
|
+
// An unlabelled volume this deployment's own container serves: the caller records it (`volumeRecordText`).
|
|
416
|
+
if (volume.unlabelled && handover && typeof volume.createdAt === "string" && volume.createdAt !== "" && !volumeRecordMatches(record, volume)) return { handover, note, record: volume };
|
|
417
|
+
}
|
|
418
|
+
return { handover, note };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The key inside a Valkey that records which deployment folder its queue is (PR #475's review, round 3's close). */
|
|
422
|
+
export const OWNER_MARKER_KEY = "pi-dispatch:owner";
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* `docker volume create` for `pi-dispatch-valkey-data`, labelled with the deployment folder that creates it (PR #475's
|
|
426
|
+
* review): a volume label cannot be changed afterwards, so the one that creates it names its owner once, for good.
|
|
427
|
+
*/
|
|
428
|
+
export function valkeyVolumeCreateArgs(deployment) {
|
|
429
|
+
return ["volume", "create", "--label", `${DEPLOYMENT_LABEL}=${deployment}`, VALKEY_VOLUME];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Whose `pi-dispatch-valkey-data` is (PR #475's review, the volume gap: after one deployment's compose Valkey was taken
|
|
434
|
+
* down, nothing mounted the volume, and a second deployment's `up` started on the first one's queue). `{ absent }`,
|
|
435
|
+
* `{ ours, owner }` by its label (exactly this folder, as given or real), `{ unlabelled }` for a volume made before the
|
|
436
|
+
* label (or by hand), which is used only with consent that `--yes` does not give, or `{ unknown }`.
|
|
437
|
+
*/
|
|
438
|
+
export async function valkeyVolumeOwner({ dirs, query }) {
|
|
439
|
+
const res = await query("docker", ["volume", "inspect", VALKEY_VOLUME]);
|
|
440
|
+
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}` };
|
|
441
|
+
let info;
|
|
442
|
+
try {
|
|
443
|
+
info = JSON.parse(String(res.stdout))[0];
|
|
444
|
+
} catch {
|
|
445
|
+
return { unknown: `docker volume inspect ${VALKEY_VOLUME} printed no record` };
|
|
446
|
+
}
|
|
447
|
+
const label = info?.Labels?.[DEPLOYMENT_LABEL];
|
|
448
|
+
if (typeof label !== "string" || label === "") return { unlabelled: true, createdAt: typeof info?.CreatedAt === "string" ? info.CreatedAt : null };
|
|
449
|
+
return { ours: dirs.filter(Boolean).includes(label), owner: label };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The refusal for a volume labelled for another folder: never used from here. */
|
|
453
|
+
export function foreignVolumeLabelRefusal(owner) {
|
|
454
|
+
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}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The question for an unlabelled volume, asked even under --yes: what adopting it means. */
|
|
458
|
+
export function adoptVolumeQuestion(deployment) {
|
|
459
|
+
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] `;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The refusal for an unlabelled volume that was not adopted. */
|
|
463
|
+
export function unadoptedVolumeRefusal() {
|
|
464
|
+
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)`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The refusal for a Valkey whose owner marker names another folder. */
|
|
468
|
+
export function foreignMarkerRefusal(owner) {
|
|
469
|
+
return `the queue on ${VALKEY_VOLUME} is the deployment's in ${owner} (${OWNER_MARKER_KEY} inside it), not this one's`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The record of an adopted, unlabelled `pi-dispatch-valkey-data` (PR #475's round-cap re-review): a volume label cannot
|
|
474
|
+
* be added after creation, so an adopted legacy volume stayed unlabelled and every later `up` asked again, and `--yes`
|
|
475
|
+
* refused. On adoption `up` writes the volume's identity, its name and `CreatedAt` from `docker volume inspect`, to this
|
|
476
|
+
* file in the deployment folder (0600, create-only, never over a different record), and an unlabelled volume whose
|
|
477
|
+
* `CreatedAt` matches it is this deployment's without asking. A volume removed and made again has a new `CreatedAt`,
|
|
478
|
+
* so it is asked about again. Not a secret, and not in `.env`, which the service loads.
|
|
479
|
+
*/
|
|
480
|
+
export const VALKEY_VOLUME_RECORD = ".pi-dispatch-valkey-volume.json";
|
|
481
|
+
|
|
482
|
+
/** The record's text for a volume `valkeyVolumeOwner` described. */
|
|
483
|
+
export function volumeRecordText(volume) {
|
|
484
|
+
return `${JSON.stringify({ name: VALKEY_VOLUME, createdAt: volume.createdAt })}\n`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The record in `dir`, parsed, or null (absent or unreadable: then nothing is taken from it). */
|
|
488
|
+
export function readVolumeRecord(dir, fs) {
|
|
489
|
+
try {
|
|
490
|
+
const path = join(dir, VALKEY_VOLUME_RECORD);
|
|
491
|
+
if (!fs.existsSync(path)) return null;
|
|
492
|
+
const r = JSON.parse(String(fs.readFileSync(path, "utf8")));
|
|
493
|
+
return r && typeof r === "object" ? r : null;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Whether `record` names exactly this unlabelled volume (its name, and a `CreatedAt` docker gave). */
|
|
500
|
+
export function volumeRecordMatches(record, volume) {
|
|
501
|
+
return Boolean(record && volume?.unlabelled && typeof volume.createdAt === "string" && volume.createdAt !== "" && record.name === VALKEY_VOLUME && record.createdAt === volume.createdAt);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The container that reads an unlabelled volume's `pi-dispatch:owner` before any Valkey on it is published. */
|
|
505
|
+
export const OWNER_CHECK_CONTAINER = "pi-dispatch-valkey-ownercheck";
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The owner check's Valkey (PR #475's round-cap re-review): the volume's queue loaded by a Valkey with NO network at all
|
|
509
|
+
* (`--network none`, no published port) and no password (nothing outside the container can reach it), so its
|
|
510
|
+
* `pi-dispatch:owner` is read through `docker exec` before a Valkey anyone can reach runs on another deployment's data.
|
|
511
|
+
* AOF on, as always, so it loads the queue as the real one will; it is stopped (SIGTERM), never killed.
|
|
512
|
+
*/
|
|
513
|
+
export function valkeyOwnerCheckArgs(deployment) {
|
|
514
|
+
return ["run", "-d", "--name", OWNER_CHECK_CONTAINER, "--label", `${DEPLOYMENT_LABEL}=${deployment}`, "--network", "none", "-v", `${VALKEY_VOLUME}:/data`, "valkey/valkey:8", "valkey-server", "--appendonly", "yes"];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** `docker exec` reading the marker inside the owner check's Valkey. */
|
|
518
|
+
export const OWNER_CHECK_EXEC = ["exec", OWNER_CHECK_CONTAINER, "valkey-cli", "GET", OWNER_MARKER_KEY];
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What one `docker exec` of `OWNER_CHECK_EXEC` answered: `{ owner }` (a folder, or "" for none) or `{ retry }` while
|
|
522
|
+
* Valkey still loads or is not yet listening, or `{ error }`.
|
|
523
|
+
*/
|
|
524
|
+
export function ownerCheckAnswer(res) {
|
|
525
|
+
const text = `${res?.stdout ?? ""}${res?.stderr ?? ""}`.trim();
|
|
526
|
+
if (/LOADING|Could not connect|Connection refused|not running|is restarting/i.test(text)) return { retry: text || `exit ${res?.code}` };
|
|
527
|
+
if (res?.code !== 0 || /^\(error\)|^ERR\b|NOAUTH|WRONGPASS/.test(text)) return { error: text || `exit ${res?.code}` };
|
|
528
|
+
return { owner: String(res.stdout ?? "").trim() };
|
|
529
|
+
}
|