@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
package/src/connection.mjs
CHANGED
|
@@ -1,4 +1,129 @@
|
|
|
1
|
-
import { Redis } from "ioredis";
|
|
1
|
+
import { AbstractConnector, Command, Redis } from "ioredis";
|
|
2
|
+
import { Socket, connect as netConnect } from "node:net";
|
|
3
|
+
import { connect as tlsConnect } from "node:tls";
|
|
4
|
+
import { valkeyAuthRefusal } from "./valkey-auth.mjs";
|
|
5
|
+
import { valkeySchemeOf } from "./podman-stack.mjs";
|
|
6
|
+
import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
|
|
7
|
+
|
|
8
|
+
// Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468).
|
|
9
|
+
export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor, valkeyUrlProblem };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The VALKEY_URL a CLI command uses from `cwd` (`valkeyUrlFor`), with a disagreement or an unreadable `.env` written as
|
|
13
|
+
* one warning line through `warn`. The one resolver every CLI verb and `service restart --drain` share.
|
|
14
|
+
*/
|
|
15
|
+
export function cliValkeyUrl(env, { cwd, warn = (line) => process.stderr.write(line) } = {}) {
|
|
16
|
+
const resolved = valkeyUrlFor(valkeyClientContext({ env, ...(cwd ? { cwd } : {}) }));
|
|
17
|
+
if (resolved.note) warn(`warning: ${resolved.note}\n`);
|
|
18
|
+
return resolved.url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* An error a Valkey client reports, without the command it failed on (issue #468, PR #475's review). ioredis hangs
|
|
23
|
+
* `command: { name, args }` on every reply error, and for a failed AUTH the args ARE the password: after a rotation,
|
|
24
|
+
* a client still sending the old one emitted `WRONGPASS` with it attached, and BullMQ, whose Queue and Worker had no
|
|
25
|
+
* `error` listener, fell back to `console.error(err)`, which prints the whole object, password included (measured
|
|
26
|
+
* against a real Redis with CONFIG SET requirepass and CLIENT KILL). The args also carry job data (task text) for any
|
|
27
|
+
* other failed command. So only the command's NAME is kept. Mutates and returns `err`; anything else passes through.
|
|
28
|
+
*/
|
|
29
|
+
export function scrubValkeyError(err) {
|
|
30
|
+
if (err && typeof err === "object" && err.command && typeof err.command === "object") {
|
|
31
|
+
try {
|
|
32
|
+
err.command = { name: err.command.name };
|
|
33
|
+
} catch {
|
|
34
|
+
// A frozen error: nothing this client made.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* WHERE the scrub runs (PR #475's review, round 2 corrected the first version of this comment, which claimed the emit
|
|
42
|
+
* hook alone covered every path). ioredis hands an error to three kinds of consumer, and a failed AUTH reaches all
|
|
43
|
+
* three with the password in `command.args`:
|
|
44
|
+
* - a listener, through `emit("error")`;
|
|
45
|
+
* - nobody, through `silentEmit("error")`, which SKIPS `emit()` when the client has no `error` listener (the worker's
|
|
46
|
+
* shared client, a CLI probe): ioredis then prints only `error.stack`, but the same object was already handed
|
|
47
|
+
* - to every command waiting on the connection, through that command's `reject` (`flushQueue`, `abortError`, a
|
|
48
|
+
* reply error): an awaited or unhandled rejection that anything may print whole (measured by the review: two
|
|
49
|
+
* occurrences of the password from a listener-less client after a rotation).
|
|
50
|
+
* So the hook sits where each of them receives the object: `Redis.prototype.emit` and `silentEmit` for the events, and
|
|
51
|
+
* every `Command`'s `reject` (wrapped as `initPromise` makes it) for the rejections. BullMQ and this module share the
|
|
52
|
+
* one ioredis, so its clients are covered by the same hooks.
|
|
53
|
+
*/
|
|
54
|
+
const SCRUBS = Symbol.for("pi-dispatch.valkey-error-scrub");
|
|
55
|
+
if (!Redis.prototype[SCRUBS]) {
|
|
56
|
+
const emit = Redis.prototype.emit;
|
|
57
|
+
Redis.prototype.emit = function emitScrubbed(event, ...args) {
|
|
58
|
+
if (event === "error") scrubValkeyError(args[0]);
|
|
59
|
+
return emit.call(this, event, ...args);
|
|
60
|
+
};
|
|
61
|
+
const silentEmit = Redis.prototype.silentEmit;
|
|
62
|
+
Redis.prototype.silentEmit = function silentEmitScrubbed(event, arg, ...rest) {
|
|
63
|
+
if (event === "error") scrubValkeyError(arg);
|
|
64
|
+
return silentEmit.call(this, event, arg, ...rest);
|
|
65
|
+
};
|
|
66
|
+
const initPromise = Command.prototype.initPromise;
|
|
67
|
+
Command.prototype.initPromise = function initPromiseScrubbed(...args) {
|
|
68
|
+
const out = initPromise.apply(this, args);
|
|
69
|
+
const reject = this.reject;
|
|
70
|
+
if (typeof reject === "function") this.reject = (err) => reject(scrubValkeyError(err));
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
Redis.prototype[SCRUBS] = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A database the server refuses is a REFUSAL, never database 0 (gate round 2 of PR #478). ioredis 5.11.1 applies `db`
|
|
78
|
+
* with a SELECT in its connect handler (redis/event_handler.js), before the ready check, and on a failure only
|
|
79
|
+
* `silentEmit`s the error and carries on: the client went ready on database 0, so `status`, `cancel`, the worker and
|
|
80
|
+
* doctor used database 0 of a Valkey whose `/16` does not exist, possibly another deployment's queue (measured against
|
|
81
|
+
* Valkey: "ERR DB index is out of range", then a write through the `/16` client read back from database 0).
|
|
82
|
+
*
|
|
83
|
+
* Here, where that error surfaces, the client is stopped before its ready check can finish: the refusal
|
|
84
|
+
* (`valkeyRefusal`, a configuration error naming the index) rejects every command queued so far, the client closes
|
|
85
|
+
* without reconnecting, `JudgedConnector.check` fails the ready check that was already in flight, and a later connect
|
|
86
|
+
* rejects with the same refusal. Every client of the project is built on that connector, so none goes ready on
|
|
87
|
+
* another database. The start judgements (`judgeValkeyAtStart`, the worker's `refuseValkeyAuth`) and doctor read the
|
|
88
|
+
* server's `databases` count for their sentence (`valkeyAuthState`).
|
|
89
|
+
*/
|
|
90
|
+
export const DB_REFUSED = Symbol.for("pi-dispatch.valkey-db-refused");
|
|
91
|
+
const SELECT_HOOK = Symbol.for("pi-dispatch.valkey-select-refusal");
|
|
92
|
+
/** Whether `err` is a server's refusal of a SELECT for a database it does not have. */
|
|
93
|
+
function isSelectRangeError(err) {
|
|
94
|
+
return Boolean(err) && typeof err === "object" && String(err.command?.name ?? "").toLowerCase() === "select" && /DB index is out of range|invalid DB index/i.test(String(err.message ?? ""));
|
|
95
|
+
}
|
|
96
|
+
if (!Redis.prototype[SELECT_HOOK]) {
|
|
97
|
+
const silentEmit = Redis.prototype.silentEmit;
|
|
98
|
+
Redis.prototype.silentEmit = function silentEmitSelectRefused(event, arg, ...rest) {
|
|
99
|
+
if (event === "error" && isSelectRangeError(arg)) {
|
|
100
|
+
if (!this[DB_REFUSED]) {
|
|
101
|
+
const refusal = Object.assign(valkeyRefusal(valkeyDbRangeSentence(this.options?.[JUDGE]?.url ?? "", this.condition?.select ?? this.options?.db)), { valkeyDbRange: true, cause: scrubValkeyError(arg) });
|
|
102
|
+
this[DB_REFUSED] = refusal;
|
|
103
|
+
if (this.connector) this.connector[DB_REFUSED] = refusal;
|
|
104
|
+
try {
|
|
105
|
+
this.flushQueue(refusal);
|
|
106
|
+
} catch {}
|
|
107
|
+
this.disconnect();
|
|
108
|
+
}
|
|
109
|
+
// Said to a listener only: without one, every command of the client already rejects with the refusal, and
|
|
110
|
+
// ioredis' own "Unhandled error event" print would add a stack trace to that sentence.
|
|
111
|
+
return this.listeners("error").length > 0 ? silentEmit.call(this, event, this[DB_REFUSED], ...rest) : false;
|
|
112
|
+
}
|
|
113
|
+
return silentEmit.call(this, event, arg, ...rest);
|
|
114
|
+
};
|
|
115
|
+
Redis.prototype[SELECT_HOOK] = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The `error` listener every BullMQ Queue and Worker of the project carries (issue #468): without one, BullMQ prints
|
|
120
|
+
* the whole error object with `console.error`. This writes one line, the message only (scrubbed of any command), and
|
|
121
|
+
* `what` naming whose error it is.
|
|
122
|
+
*/
|
|
123
|
+
export function onValkeyError(emitter, what, write = (line) => process.stderr.write(line)) {
|
|
124
|
+
emitter.on?.("error", (err) => write(`[pi-dispatch] Valkey error (${what}): ${String(scrubValkeyError(err)?.message ?? err)}\n`));
|
|
125
|
+
return emitter;
|
|
126
|
+
}
|
|
2
127
|
|
|
3
128
|
/**
|
|
4
129
|
* Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
|
|
@@ -15,14 +140,39 @@ import { Redis } from "ioredis";
|
|
|
15
140
|
* error in a couple of seconds with a clear message, not hang forever. The long-running WORKER
|
|
16
141
|
* uses the default (persistent) options -- it should ride out a Valkey restart, not give up.
|
|
17
142
|
*/
|
|
18
|
-
export function parseConnection(url, { failFast = false } = {}) {
|
|
143
|
+
export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false } = {}) {
|
|
19
144
|
const u = new URL(url);
|
|
145
|
+
// PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
|
|
146
|
+
// every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
|
|
147
|
+
// configuration refusal it is; `judgeValkeyAtStart` says it first at each start.
|
|
148
|
+
const dbProblem = valkeyUrlProblem(url);
|
|
149
|
+
if (dbProblem) throw valkeyRefusal(dbProblem);
|
|
150
|
+
const where = context ?? defaultValkeyContext();
|
|
151
|
+
// Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from
|
|
152
|
+
// the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the
|
|
153
|
+
// project (the worker, the CLI, the receiver, the admin panel, doctor) authenticates the same way. The URL's userinfo
|
|
154
|
+
// is percent-decoded: it was handed over raw before, so a password with `@` written `%40` never matched.
|
|
155
|
+
const { password } = valkeyPasswordFor(url, where, { withoutPassword });
|
|
20
156
|
return {
|
|
21
|
-
|
|
157
|
+
// Issue #464 (gate round 2 follow-up): EVERY client made from these options connects through `JudgedConnector`,
|
|
158
|
+
// which judges and pins the address (valkey-endpoint.mjs) before each connect; `host` below is only what ioredis
|
|
159
|
+
// reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
|
|
160
|
+
Connector: JudgedConnector,
|
|
161
|
+
// `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
|
|
162
|
+
[JUDGE]: { url, context: where, ...(judge ? { judge } : {}) },
|
|
163
|
+
// WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
|
|
164
|
+
// "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
|
|
165
|
+
// Fedora 44: ETIMEDOUT). The address is the part inside them.
|
|
166
|
+
host: u.hostname.replace(/^\[(.*)\]$/, "$1") || "127.0.0.1",
|
|
22
167
|
port: Number(u.port || 6379),
|
|
23
|
-
...(
|
|
24
|
-
...(u.username ? { username: u.username } : {}),
|
|
168
|
+
...(password ? { password } : {}),
|
|
169
|
+
...(u.username && !withoutPassword ? { username: decodedUserinfo(u.username) } : {}),
|
|
25
170
|
...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}),
|
|
171
|
+
// TLS for `rediss:` (issue #464, gate round 2). Host and port alone dropped it, so a `rediss:` VALKEY_URL reached
|
|
172
|
+
// BullMQ as plaintext. `servername` is the name a certificate is checked against when the worker connects to a
|
|
173
|
+
// pinned address rather than to that name (`pinnedValkeyUrl`).
|
|
174
|
+
// `valkeys:` is `rediss:`'s alias (`valkeySchemeOf`, gate round 3): TLS as well.
|
|
175
|
+
...(valkeySchemeOf(u.protocol) === "rediss:" ? { tls: servername ? { servername } : {} } : {}),
|
|
26
176
|
maxRetriesPerRequest: null, // required for BullMQ blocking connections
|
|
27
177
|
...(failFast
|
|
28
178
|
? {
|
|
@@ -34,7 +184,223 @@ export function parseConnection(url, { failFast = false } = {}) {
|
|
|
34
184
|
};
|
|
35
185
|
}
|
|
36
186
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
187
|
+
/**
|
|
188
|
+
* A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
|
|
189
|
+
* `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
|
|
190
|
+
* `failFast` as ioredis and `parseConnection` read them.
|
|
191
|
+
*/
|
|
192
|
+
export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false } = {}) {
|
|
193
|
+
return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}) });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How the Valkey at `url` answers this client's credential (issue #468), through a fail-fast client built like every
|
|
198
|
+
* other: `{ state }`, one of
|
|
199
|
+
* "ok" PING answered PONG with what this client sends (a password or none);
|
|
200
|
+
* "noauth" it requires a password and this client sent none (NOAUTH);
|
|
201
|
+
* "wrongpass" it refused the password this client sent (WRONGPASS, or "invalid password");
|
|
202
|
+
* "unreachable" anything else (nothing answers, a refused owner), with its `error` text.
|
|
203
|
+
* `withoutPassword` asks as a client that sends none would: "ok" then means this Valkey answers ANY local account.
|
|
204
|
+
* The password itself never leaves this function; the answer names only which state it is. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
export async function valkeyAuthState(url, { context = null, servername = null, withoutPassword = false, timeoutMs = 5000, makeClient = makeRedisClient } = {}) {
|
|
207
|
+
const seen = [];
|
|
208
|
+
let client;
|
|
209
|
+
try {
|
|
210
|
+
client = makeClient(url, { failFast: true, lazyConnect: true, context, servername, withoutPassword });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
return { state: "unreachable", error: err?.message ?? String(err) };
|
|
213
|
+
}
|
|
214
|
+
// ioredis reports an AUTH or ready-check refusal as an "error" event and then reconnects (bounded by failFast), while
|
|
215
|
+
// connect() itself may reject with only "Connection is closed.", so the events are kept and read with the reply.
|
|
216
|
+
client.on("error", (err) => seen.push(err?.message ?? String(err)));
|
|
217
|
+
let timer;
|
|
218
|
+
try {
|
|
219
|
+
const reply = await Promise.race([
|
|
220
|
+
client.connect().then(() => client.ping()),
|
|
221
|
+
new Promise((_, reject) => {
|
|
222
|
+
timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs);
|
|
223
|
+
}),
|
|
224
|
+
]);
|
|
225
|
+
return reply === "PONG" ? { state: "ok" } : { state: "unreachable", error: `PING answered ${String(reply).slice(0, 40)}` };
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (client[DB_REFUSED]) return { state: "dbrange", error: await dbRangeSentenceFor(url, client, { context, servername, makeClient, timeoutMs }) };
|
|
228
|
+
const all = [err?.message ?? String(err), ...seen].join("\n");
|
|
229
|
+
if (/\bWRONGPASS\b|invalid password/i.test(all)) return { state: "wrongpass" };
|
|
230
|
+
if (/\bNOAUTH\b/.test(all)) return { state: "noauth" };
|
|
231
|
+
return { state: "unreachable", error: err?.message ?? String(err) };
|
|
232
|
+
} finally {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
client.disconnect();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The refusal sentence for a client whose SELECT was refused, with the server's `databases` count read by a second
|
|
240
|
+
* client on database 0 (`CONFIG GET databases`; null where that is refused, a managed Valkey say). The second client
|
|
241
|
+
* only reads that setting and never touches a key.
|
|
242
|
+
*/
|
|
243
|
+
async function dbRangeSentenceFor(url, client, { context, servername, makeClient, timeoutMs }) {
|
|
244
|
+
let databases = null;
|
|
245
|
+
let probe;
|
|
246
|
+
let timer;
|
|
247
|
+
try {
|
|
248
|
+
const u = new URL(url);
|
|
249
|
+
u.pathname = "";
|
|
250
|
+
probe = makeClient(u.toString(), { failFast: true, lazyConnect: true, context, servername });
|
|
251
|
+
probe.on?.("error", () => {});
|
|
252
|
+
const reply = await Promise.race([
|
|
253
|
+
probe.connect().then(() => probe.config("GET", "databases")),
|
|
254
|
+
new Promise((_, reject) => {
|
|
255
|
+
timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
|
256
|
+
}),
|
|
257
|
+
]);
|
|
258
|
+
const n = Array.isArray(reply) ? Number(reply[1]) : NaN;
|
|
259
|
+
databases = Number.isInteger(n) && n > 0 ? n : null;
|
|
260
|
+
} catch {
|
|
261
|
+
// Unreadable: the sentence says so.
|
|
262
|
+
} finally {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
probe?.disconnect?.();
|
|
265
|
+
}
|
|
266
|
+
return valkeyDbRangeSentence(url, client.options?.db ?? client.condition?.select, databases);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `judgeValkeyAtStart` of valkey-endpoint.mjs (the owner judgement, retried while nothing answers), then this client's
|
|
271
|
+
* credential asked once (issue #468): a Valkey that refuses it is a refusal (a configError, exit 2, like a refused
|
|
272
|
+
* owner), since a restart sends the same credential, and names VALKEY_PASSWORD without its value. Nothing answering
|
|
273
|
+
* stays what it was: the command's own connect says it could not reach Valkey. `checkAuth` is a seam, the real check
|
|
274
|
+
* unless the caller injected its own `judge` (a test standing in for the host, which has no Valkey to ask).
|
|
275
|
+
*/
|
|
276
|
+
export async function judgeValkeyAtStart(url, context, opts = {}) {
|
|
277
|
+
const endpoint = await judgeEndpointAtStart(url, context, opts);
|
|
278
|
+
const checkAuth = opts.checkAuth !== undefined ? opts.checkAuth : opts.judge ? null : valkeyAuthState;
|
|
279
|
+
if (checkAuth) {
|
|
280
|
+
const where = context ?? defaultValkeyContext();
|
|
281
|
+
const { state, error } = await checkAuth(url, { context: where, servername: endpoint?.servername ?? null });
|
|
282
|
+
// Gate round 2 of PR #478: a database the server does not have is a refusal too, never database 0.
|
|
283
|
+
if (state === "dbrange") throw Object.assign(new Error(error), { piDispatchConfig: true, valkeyRefused: true, valkeyDbRange: true });
|
|
284
|
+
const refusal = state === "noauth" || state === "wrongpass" ? authRefusalFor(state, url, where) : null;
|
|
285
|
+
if (refusal) throw Object.assign(new Error(refusal), { piDispatchConfig: true, valkeyRefused: true, valkeyAuth: state });
|
|
286
|
+
}
|
|
287
|
+
return endpoint;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The refusal sentence for an auth state, naming where the password would come from (never its value). */
|
|
291
|
+
export function authRefusalFor(state, url, context) {
|
|
292
|
+
const { password, from } = valkeyPasswordFor(url, context);
|
|
293
|
+
return valkeyAuthRefusal(new Error(state === "noauth" ? "NOAUTH" : "WRONGPASS"), { passwordSet: Boolean(password), from, envPath: context?.envPath ?? "the deployment's .env" });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The key under which a client's options carry what `JudgedConnector` judges: the URL as written and the context. A
|
|
298
|
+
* string, not a Symbol: ioredis merges its options with lodash `defaults`, which copies string keys only.
|
|
299
|
+
*/
|
|
300
|
+
const JUDGE = "piDispatchValkeyJudge";
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
|
|
304
|
+
* `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
|
|
305
|
+
* exactly as ioredis' own StandaloneConnector dials a host.
|
|
306
|
+
*
|
|
307
|
+
* A judgement that throws (nothing answers while the Valkey restarts, a refusal, a name that does not resolve) must NOT
|
|
308
|
+
* reject `connect()`: ioredis 5.11.1 then sets the client's status to "end" and never retries, so one Valkey restart
|
|
309
|
+
* killed every client for good (gate round 3, measured: "Connection is closed." on the next job, never retried). So
|
|
310
|
+
* `connect()` resolves a socket already destroyed, with the error as `firstError`: ioredis' own branch for a stream that
|
|
311
|
+
* failed before it was handed over, which reports that error and takes the close path, retrying by the client's
|
|
312
|
+
* strategy and judging again on the next connect. A client with a bounded strategy (the CLI's failFast) then ends with
|
|
313
|
+
* that error. Destroyed before it is returned, never on a later tick: a socket that is neither connecting nor destroyed
|
|
314
|
+
* when ioredis looks is taken as connected, and its ready check then fails on a dead stream (measured on the VMs:
|
|
315
|
+
* "Stream isn't writeable" printed beside every failed judgement).
|
|
316
|
+
*/
|
|
317
|
+
export class JudgedConnector extends AbstractConnector {
|
|
318
|
+
constructor(options) {
|
|
319
|
+
super(options.disconnectTimeout);
|
|
320
|
+
this.options = options;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** ioredis' ready check: failed for a client whose SELECT was refused (`DB_REFUSED`), so it never goes ready. */
|
|
324
|
+
check() {
|
|
325
|
+
return !this[DB_REFUSED];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
connect() {
|
|
329
|
+
const { options } = this;
|
|
330
|
+
// A database the server refused stays refused: no reconnect goes ready on another one.
|
|
331
|
+
if (this[DB_REFUSED]) return Promise.reject(this[DB_REFUSED]);
|
|
332
|
+
this.connecting = true;
|
|
333
|
+
const judge = options[JUDGE];
|
|
334
|
+
if (!judge) return Promise.reject(new Error("a Valkey client was built without parseConnection's judged options"));
|
|
335
|
+
return (judge.judge ?? judgedEndpoint)(judge.url, judge.context).then(
|
|
336
|
+
(endpoint) => {
|
|
337
|
+
if (!this.connecting) throw new Error("Connection is closed.");
|
|
338
|
+
const target = { host: endpoint.host, port: options.port, ...(options.family != null ? { family: options.family } : {}) };
|
|
339
|
+
if (options.tls) Object.assign(target, options.tls, endpoint.servername ? { servername: endpoint.servername } : {});
|
|
340
|
+
this.stream = options.tls ? JudgedConnector.dial.tls(target) : JudgedConnector.dial.net(target);
|
|
341
|
+
this.stream.once("error", (err) => {
|
|
342
|
+
this.firstError = err;
|
|
343
|
+
});
|
|
344
|
+
return this.stream;
|
|
345
|
+
},
|
|
346
|
+
(err) => {
|
|
347
|
+
if (!this.connecting) throw new Error("Connection is closed.");
|
|
348
|
+
const stream = new Socket();
|
|
349
|
+
this.stream = stream;
|
|
350
|
+
this.firstError = err;
|
|
351
|
+
// The destroyed socket's own "error" event comes on a later tick; ioredis reports firstError instead.
|
|
352
|
+
stream.on("error", () => {});
|
|
353
|
+
stream.destroy(err);
|
|
354
|
+
return stream;
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
|
|
361
|
+
JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
|
|
365
|
+
* worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
|
|
366
|
+
*/
|
|
367
|
+
export function isJudgedConnection(connection) {
|
|
368
|
+
const options = connection?.options ?? connection;
|
|
369
|
+
return options?.Connector === JudgedConnector && Boolean(options?.[JUDGE]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Throws unless `connection` is judged (`isJudgedConnection`). */
|
|
373
|
+
export function assertJudgedConnection(connection) {
|
|
374
|
+
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)");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Record, then read back, which deployment folder the queue in a Valkey belongs to (PR #475's review, the volume gap):
|
|
379
|
+
* `SET pi-dispatch:owner <folder> NX`, then `GET`. Docker volume labels cannot be added after creation, so a volume made
|
|
380
|
+
* before the label (adopted with consent) carries its owner here, inside the data it describes, and every later start
|
|
381
|
+
* on it checks this key. Retried for `waitMs` while a Valkey that was just started comes up. `{ owner, claimed }` where
|
|
382
|
+
* `owner` is what the key holds (this folder, or another one's), or `{ error }` when it could not be read.
|
|
383
|
+
*/
|
|
384
|
+
export async function claimValkeyOwner(url, folder, { context = null, waitMs = 15000, makeClient = makeRedisClient, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
385
|
+
const { OWNER_MARKER_KEY } = await import("./valkey-auth.mjs");
|
|
386
|
+
const until = Date.now() + waitMs;
|
|
387
|
+
let last = "no attempt";
|
|
388
|
+
for (;;) {
|
|
389
|
+
let client;
|
|
390
|
+
try {
|
|
391
|
+
client = makeClient(url, { failFast: true, lazyConnect: true, context });
|
|
392
|
+
client.on("error", () => {});
|
|
393
|
+
await client.connect();
|
|
394
|
+
const set = await client.set(OWNER_MARKER_KEY, folder, "NX");
|
|
395
|
+
const owner = await client.get(OWNER_MARKER_KEY);
|
|
396
|
+
return { owner, claimed: set === "OK" };
|
|
397
|
+
} catch (err) {
|
|
398
|
+
last = err?.message ?? String(err);
|
|
399
|
+
// NOAUTH and WRONGPASS will not change by waiting.
|
|
400
|
+
if (/\bNOAUTH\b|\bWRONGPASS\b/.test(last) || Date.now() >= until) return { error: last };
|
|
401
|
+
} finally {
|
|
402
|
+
client?.disconnect();
|
|
403
|
+
}
|
|
404
|
+
await sleep(500);
|
|
405
|
+
}
|
|
40
406
|
}
|
package/src/container-spec.mjs
CHANGED
|
@@ -45,6 +45,59 @@ export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
|
45
45
|
export const CONTAINER_SESSION_DIR = "/session";
|
|
46
46
|
export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The job image's home, and the uid its `USER` directive runs as (issue #341, `DES-JOB-USER-INFERRED-READ-BACK-ON-
|
|
50
|
+
* REQUEST`). A job run under `--user` needs `HOME=CONTAINER_HOME` beside it, because such a uid has no passwd entry in
|
|
51
|
+
* the image: Docker gives it `HOME=/`, Podman `HOME=/workspace` (both measured). The builder does not pair them; a job
|
|
52
|
+
* or sandbox path that passes a `user` must. `SHIPPED_IMAGE_UID` is the one uid that needs no `--user` at all, since
|
|
53
|
+
* the image already runs as it; a test pins it against `image/Dockerfile`'s `useradd` so the two cannot drift.
|
|
54
|
+
*/
|
|
55
|
+
export const CONTAINER_HOME = "/home/pi";
|
|
56
|
+
export const SHIPPED_IMAGE_UID = 1001;
|
|
57
|
+
|
|
58
|
+
// "<uid>:<gid>", both non-zero decimal. A bare uid is refused because docker then takes the group from the image's
|
|
59
|
+
// passwd, which is gid 0 for a uid it has no entry for; a name is refused because the image decides what it means.
|
|
60
|
+
const JOB_USER_RE = /^[1-9]\d{0,9}:[1-9]\d{0,9}$/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The host path `docker run` writes the container's ID to (issue #345), or `null`. Absolute (a POSIX path, or a Windows
|
|
64
|
+
* drive path for a worker on Windows), no `..` segment and no control byte, because it rides a single
|
|
65
|
+
* `--cidfile=<path>` token (the CLI splits on the first `=`) and names a file the worker later reads and removes. The worker puts it BESIDE the job
|
|
66
|
+
* directory, never inside the `/job:ro` mount.
|
|
67
|
+
*/
|
|
68
|
+
export function assertCidFile(cidFile) {
|
|
69
|
+
if (cidFile === null || cidFile === undefined) return;
|
|
70
|
+
const absolute = typeof cidFile === "string" && (cidFile.startsWith("/") || /^[A-Za-z]:[\\/]/.test(cidFile));
|
|
71
|
+
if (!absolute || cidFile.split(/[\\/]/).includes("..") || /[\u0000-\u001f\u007f]/.test(cidFile)) {
|
|
72
|
+
throw new Error(`docker run: refusing a cidfile that is not a plain absolute path: ${JSON.stringify(cidFile)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Throws unless `user` is null/undefined or a non-root "<uid>:<gid>" (`nonRoot`, issue #341). */
|
|
77
|
+
export function assertJobUser(user) {
|
|
78
|
+
if (user === null || user === undefined) return;
|
|
79
|
+
if (typeof user !== "string" || !JOB_USER_RE.test(user)) {
|
|
80
|
+
throw new Error(`docker run: refusing a job user that is not "<uid>:<gid>" with both parts non-zero: ${JSON.stringify(user)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The user-namespace modes a spec may ask for (issue #354): `null`, the runtime's default, or `"keep-id"`, which maps the
|
|
86
|
+
* worker's own uid and gid to the SAME ids inside a rootless Podman container, so a job run as the worker's own
|
|
87
|
+
* "<uid>:<gid>" owns its bind mounts exactly as the worker does. A CLOSED set, and refused rather than passed through,
|
|
88
|
+
* because the value reaches an argv as one token and every other spelling Podman accepts (`host`, `auto`, `nomap`, a
|
|
89
|
+
* path to another process's namespace) changes which uids a job can reach on the host.
|
|
90
|
+
*/
|
|
91
|
+
export const USERNS_MODES = Object.freeze(["keep-id"]);
|
|
92
|
+
|
|
93
|
+
/** Throws unless `userns` is null/undefined or a member of `USERNS_MODES` (issue #354). */
|
|
94
|
+
export function assertUserns(userns) {
|
|
95
|
+
if (userns === null || userns === undefined) return;
|
|
96
|
+
if (typeof userns !== "string" || !USERNS_MODES.includes(userns)) {
|
|
97
|
+
throw new Error(`container spec: refusing a userns other than null or ${USERNS_MODES.map((m) => JSON.stringify(m)).join(", ")}: ${JSON.stringify(userns)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
/**
|
|
49
102
|
* WHAT the box is, with no Docker vocabulary in it.
|
|
50
103
|
*
|
|
@@ -57,8 +110,8 @@ export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
|
57
110
|
* strings, because the flattening IS the Docker part: a runtime that does not bind-mount has to be able to
|
|
58
111
|
* see which host path becomes which container path, and what may be written.
|
|
59
112
|
*
|
|
60
|
-
* `dockerExtra` is named for what it is. It carries raw Docker flags (`-i -t --entrypoint bash
|
|
61
|
-
*
|
|
113
|
+
* `dockerExtra` is named for what it is. It carries raw Docker flags (`-i -t --entrypoint bash`), so it is the one
|
|
114
|
+
* field a non-Docker consumer must refuse rather than translate. The job user is NOT in it: it is `user`, a field.
|
|
62
115
|
* Calling it `extraFlags` at the boundary would have hidden that.
|
|
63
116
|
*
|
|
64
117
|
* @param image pinned job image tag/digest
|
|
@@ -73,7 +126,18 @@ export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
|
73
126
|
* @param memory e.g. "4g"; cpus e.g. "2"
|
|
74
127
|
* @param network the per-job egress network this container joins (REQ-EGRESS-ALLOWLIST); null = the
|
|
75
128
|
* docker default bridge, which is what every job did before that requirement existed
|
|
76
|
-
* @param
|
|
129
|
+
* @param user "<uid>:<gid>" the job runs as (issue #341), or null for the image's own USER. Portable: it says WHO
|
|
130
|
+
* runs the box, which a non-docker runtime must honour too.
|
|
131
|
+
* @param userns null (the runtime's own default) or "keep-id" (issue #354): how the job user's ids map to the host's.
|
|
132
|
+
* Portable in meaning, but only one runtime spells it: `dockerArgsFromSpec` REFUSES a non-null one,
|
|
133
|
+
* because the docker CLI rejects `--userns=keep-id` client-side (exit 125, measured in issue #345), and
|
|
134
|
+
* dropping it instead would run the job under a uid mapping nobody asked for.
|
|
135
|
+
* @param extraFlags raw docker flags for the few callers that need them (the sandbox's -i -t --entrypoint bash)
|
|
136
|
+
* @param relabel true where the daemon confines containers with SELinux (`relabelsPrivateMounts`, issue #355): the
|
|
137
|
+
* worker's own per-job mounts then carry `relabel: "private"`. Portable: it says WHICH host paths the
|
|
138
|
+
* runtime may re-own for this one container, which a non-docker runtime must honour or refuse.
|
|
139
|
+
* @param workspaceOwned true when `workspace` is the worker's own per-job directory (a forge job's clone, a sandbox's
|
|
140
|
+
* retained copy), false when it is the operator's folder. Read only when `relabel` is true.
|
|
77
141
|
*/
|
|
78
142
|
export function containerSpec({
|
|
79
143
|
image,
|
|
@@ -87,21 +151,41 @@ export function containerSpec({
|
|
|
87
151
|
memory = "4g",
|
|
88
152
|
cpus = "2",
|
|
89
153
|
network = null,
|
|
154
|
+
user = null,
|
|
155
|
+
userns = null,
|
|
156
|
+
cidFile = null,
|
|
90
157
|
extraFlags = [],
|
|
158
|
+
relabel = false,
|
|
159
|
+
workspaceOwned = false,
|
|
91
160
|
}) {
|
|
92
161
|
if (!image) throw new Error("docker run: image is required");
|
|
162
|
+
assertJobUser(user);
|
|
163
|
+
assertUserns(userns);
|
|
164
|
+
assertCidFile(cidFile);
|
|
93
165
|
if (!name) throw new Error("docker run: container name is required");
|
|
94
166
|
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
167
|
+
// Booleans, strictly: a truthy string from a caller that forwarded an option bag must not re-own host directories.
|
|
168
|
+
if (typeof relabel !== "boolean") throw new Error(`docker run: relabel must be a boolean; got ${typeof relabel}`);
|
|
169
|
+
if (typeof workspaceOwned !== "boolean") throw new Error(`docker run: workspaceOwned must be a boolean; got ${typeof workspaceOwned}`);
|
|
95
170
|
|
|
171
|
+
// Issue #355. A SPREAD rather than a `relabel: null` field, so a spec built without it has mount objects byte-identical
|
|
172
|
+
// to every one built before this feature existed. PRIVATE, never shared: each of these directories is made for this
|
|
173
|
+
// one container, and the shared form (`:z`) would leave it readable by every other container on the host, which is
|
|
174
|
+
// the job-to-job reach the per-job directory exists to deny.
|
|
175
|
+
const own = relabel ? { relabel: "private" } : {};
|
|
96
176
|
const mounts = [];
|
|
97
177
|
// The WHOLE /job dir is read-only (INT-CONTAINER-JOB-INPUTS): it holds prompt.md and pi/, and
|
|
98
178
|
// the agent cannot rewrite any of it. /workspace is the only writable mount.
|
|
99
|
-
if (jobDir) mounts.push({ host: jobDir, container: "/job", readOnly: true });
|
|
100
|
-
|
|
179
|
+
if (jobDir) mounts.push({ host: jobDir, container: "/job", readOnly: true, ...own });
|
|
180
|
+
// A local job's /workspace IS the operator's folder, and `:Z` would re-own it for this one container: measured, a
|
|
181
|
+
// second container mounting it afterwards is denied, and the label an operator chose is gone. So only a workspace the
|
|
182
|
+
// worker made is relabelled; an operator's folder needs the `semanage fcontext` fix doctor names, and the runner
|
|
183
|
+
// refuses one it cannot read before any spend.
|
|
184
|
+
mounts.push({ host: workspace, container: "/workspace", readOnly: false, ...(workspaceOwned ? own : {}) });
|
|
101
185
|
// Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
|
|
102
186
|
// (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
|
|
103
187
|
// them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
|
|
104
|
-
if (outboxDir) mounts.push({ host: outboxDir, container: "/outbox", readOnly: false });
|
|
188
|
+
if (outboxDir) mounts.push({ host: outboxDir, container: "/outbox", readOnly: false, ...own });
|
|
105
189
|
|
|
106
190
|
// This job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT).
|
|
107
191
|
// Writable, because pi appends to it as the agent works -- and per-job, exactly like jobDir, which is
|
|
@@ -110,11 +194,14 @@ export function containerSpec({
|
|
|
110
194
|
// read and rewrite every other branch's and every other repository's transcripts, which is not a
|
|
111
195
|
// weakening of that constraint but its inversion. Absent unless the trigger armed run.resume AND a key
|
|
112
196
|
// resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
|
|
113
|
-
if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false });
|
|
197
|
+
if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false, ...own });
|
|
114
198
|
|
|
115
199
|
// The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
|
|
116
200
|
// persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
|
|
117
201
|
// the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
|
|
202
|
+
// NEVER relabelled (issue #355): every job mounts this one directory, so a private label would lock each job out of
|
|
203
|
+
// it in turn, and a shared one would re-own an operator's directory on every run. It needs the operator's
|
|
204
|
+
// `semanage fcontext` fix, which doctor names.
|
|
118
205
|
if (globalPiDir) mounts.push({ host: globalPiDir, container: CONTAINER_GLOBAL_PI_DIR, readOnly: true });
|
|
119
206
|
|
|
120
207
|
return {
|
|
@@ -123,6 +210,14 @@ export function containerSpec({
|
|
|
123
210
|
memory,
|
|
124
211
|
cpus,
|
|
125
212
|
network,
|
|
213
|
+
user,
|
|
214
|
+
// Issue #354. Always present and `null` by default (the parameter default turns an `undefined` into it, so a spec
|
|
215
|
+
// has ONE spelling of "the runtime's default"), beside `user` because it qualifies it: the same "<uid>:<gid>" names
|
|
216
|
+
// a different host identity under a different mapping.
|
|
217
|
+
userns,
|
|
218
|
+
// Issue #345: where the runtime writes the container ID once it creates one, so a CLI that exits as if nothing
|
|
219
|
+
// started (a lost API connection) can be told apart from a container that runs on without it. null = absent.
|
|
220
|
+
cidFile,
|
|
126
221
|
// UNCONDITIONALLY true, and there is deliberately no parameter that can unset it. The boundary is
|
|
127
222
|
// not a thing a caller opts into -- CONST-ISOLATION-CONTAINER-PER-JOB is why every other flag here
|
|
128
223
|
// exists -- so the spec is simply unable to describe an unisolated container, and the builder in
|