@catalyst-cloud/cli 0.16.24 → 0.16.27

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.
@@ -0,0 +1,442 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { dockerRunnerEngine, prepareRunnerImages, spawnDocker } from "./onboard-runner.js";
4
+ import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseState, releaseLock, takeLock, writePrivateAtomic, writeReleaseState, } from "./runner-update.js";
5
+ // `catalyst runner update --fleet <dir>`: the same approved release, applied to a multi-team
6
+ // supervisor fleet (CTC-6530). One supervisor container serves several scopes from a Compose file
7
+ // written as JSON (`compose.json`), under one operator enrollment, and every host credential on the
8
+ // machine reads the same release. The fleet differs from a single-team runner in three ways:
9
+ //
10
+ // - the images live in `compose.json`, not `.env`: `services.supervisor.image` and its
11
+ // `environment.CATALYST_RUNNER_IMAGE`. Only those two fields change;
12
+ // - phase containers are never waited out. The supervisor is recreated alone and re-adopts the
13
+ // running ones, which keep the image they were granted. So there is no idle gate;
14
+ // - the deadline watchdog is a separate Compose project and is left alone. The release's watchdog
15
+ // image is not applied here.
16
+ //
17
+ // The release is read from a one-off container of the current supervisor image that mounts only the
18
+ // credential, read-only. Readiness is per scope: every scope that logged `supervisor_scope_qualified`
19
+ // in the minutes before the update must log it again from the new container, which must stay up with
20
+ // no restart and log no `host_not_ready` for those scopes. Otherwise the previous `compose.json` comes
21
+ // back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
22
+ // once it qualifies or rolls back, so a run killed in between is finished by the next one.
23
+ export const FLEET_COMPOSE_FILE = "compose.json";
24
+ export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
25
+ const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
26
+ /** The scopes the supervisor served before a recreate, kept until that recreate is qualified or rolled
27
+ * back. A run that was killed in between finds it and qualifies against it, never against the new
28
+ * container's own log, which would read an unqualified supervisor as serving. */
29
+ export const IN_FLIGHT_FILE = "fleet-update-in-flight.json";
30
+ /** How far back a qualification counts as current before the update. The supervisor repeats it
31
+ * on every loop while a scope is ready, so a ready scope logs it many times in this window. */
32
+ const QUALIFIED_LOOKBACK_MS = 5 * 60_000;
33
+ const QUALIFY_MIN_MS = 90_000;
34
+ const QUALIFY_DEADLINE_MS = 240_000;
35
+ const QUALIFY_POLL_MS = 10_000;
36
+ const CHILD_NAME_PREFIX = "catalyst-pa_";
37
+ /** The two image fields in a fleet `compose.json`, or null when the file is not a fleet layout. */
38
+ export function fleetImages(compose) {
39
+ const supervisor = isRecord(compose) && isRecord(compose.services) ? compose.services.supervisor : null;
40
+ if (!isRecord(supervisor) || typeof supervisor.image !== "string" || !isRecord(supervisor.environment))
41
+ return null;
42
+ const runner = supervisor.environment.CATALYST_RUNNER_IMAGE;
43
+ return typeof runner === "string" ? { supervisor: supervisor.image, runner } : null;
44
+ }
45
+ /** `compose` with only the two image fields replaced. */
46
+ export function withFleetImages(compose, images) {
47
+ const next = structuredClone(compose);
48
+ next.services.supervisor.image = images.supervisor;
49
+ next.services.supervisor.environment.CATALYST_RUNNER_IMAGE = images.runner;
50
+ return next;
51
+ }
52
+ const logEvents = (text) => text.split("\n").flatMap((line) => {
53
+ const start = line.indexOf("{");
54
+ if (start === -1)
55
+ return [];
56
+ try {
57
+ const parsed = JSON.parse(line.slice(start));
58
+ return isRecord(parsed) ? [parsed] : [];
59
+ }
60
+ catch {
61
+ return [];
62
+ }
63
+ });
64
+ const scopeKey = (event) => typeof event.tenant === "string" && typeof event.team === "string" ? `${event.tenant}/${event.team}` : null;
65
+ /** The scopes (`tenant/team`) that logged `supervisor_scope_qualified` in this log text. */
66
+ export function qualifiedScopes(text) {
67
+ const scopes = new Set();
68
+ for (const event of logEvents(text)) {
69
+ const key = event.event === "supervisor_scope_qualified" ? scopeKey(event) : null;
70
+ if (key)
71
+ scopes.add(key);
72
+ }
73
+ return scopes;
74
+ }
75
+ /** A `host_not_ready` line for one of `scopes`, or one that names no scope. Other scopes may be
76
+ * unready for their own reasons (a team with no repository yet) and do not stop an update. */
77
+ export function notReadyScope(text, scopes) {
78
+ for (const event of logEvents(text)) {
79
+ if (event.event !== "host_not_ready")
80
+ continue;
81
+ const key = scopeKey(event);
82
+ if (key === null || scopes.has(key))
83
+ return key ?? "unscoped";
84
+ }
85
+ return null;
86
+ }
87
+ function readInFlight(dir) {
88
+ try {
89
+ const parsed = JSON.parse(readFileSync(join(dir, IN_FLIGHT_FILE), "utf8"));
90
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.scopes))
91
+ return null;
92
+ const scopes = parsed.scopes.filter((scope) => typeof scope === "string");
93
+ return scopes.length > 0 ? new Set(scopes) : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ function clearInFlight(dir) {
100
+ try {
101
+ unlinkSync(join(dir, IN_FLIGHT_FILE));
102
+ }
103
+ catch {
104
+ // Already gone.
105
+ }
106
+ }
107
+ export async function runFleetUpdate(engine, options) {
108
+ const { dir } = options;
109
+ const now = options.now ?? Date.now;
110
+ if (!existsSync(join(dir, FLEET_COMPOSE_FILE)))
111
+ return { outcome: "failed", detail: "fleet_not_installed", generation: null, exitCode: 1 };
112
+ const lock = options.check ? null : takeLock(dir, now());
113
+ if (!options.check && lock === null)
114
+ return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
115
+ try {
116
+ const result = await update(engine, options, now);
117
+ if (!options.check) {
118
+ const state = readReleaseState(dir);
119
+ writeReleaseState(dir, { ...state, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
120
+ }
121
+ return result;
122
+ }
123
+ finally {
124
+ if (lock !== null)
125
+ releaseLock(dir, lock);
126
+ }
127
+ }
128
+ async function update(engine, options, now) {
129
+ const { dir, signal } = options;
130
+ const composePath = join(dir, FLEET_COMPOSE_FILE);
131
+ const state = readReleaseState(dir);
132
+ const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
133
+ const composeText = readFileSync(composePath, "utf8");
134
+ let compose;
135
+ try {
136
+ compose = JSON.parse(composeText);
137
+ }
138
+ catch {
139
+ compose = null;
140
+ }
141
+ const current = fleetImages(compose);
142
+ const project = isRecord(compose) && typeof compose.name === "string" ? compose.name : "";
143
+ if (!isRecord(compose) || current === null || !/^[a-z0-9][a-z0-9_-]*$/.test(project))
144
+ return done("failed", "fleet_compose_unrecognized", { exitCode: 1 });
145
+ const read = await engine.desiredRelease(dir, compose, signal);
146
+ const decision = decideRelease(state.generation, read);
147
+ if (decision.action === "retain")
148
+ return decision.reason === "not_newer"
149
+ ? done("current", "not_newer")
150
+ : done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
151
+ const { release } = decision;
152
+ const target = { runner: release.images.runner.reference, supervisor: release.images.supervisor.reference };
153
+ const changed = ["runner", "supervisor"].filter((c) => current[c] !== target[c]);
154
+ const tagged = { releaseId: release.releaseId };
155
+ const live = await engine.supervisor(dir, project, signal);
156
+ if (live === undefined)
157
+ return done("retained", "docker_unavailable", tagged);
158
+ const runs = (images) => !!live?.running && live.image === images.supervisor && live.runnerImage === images.runner;
159
+ const inFlight = readInFlight(dir);
160
+ if (inFlight === null && decision.action === "verify" && changed.length === 0 && runs(target))
161
+ return done("current", "not_newer");
162
+ if (options.check)
163
+ return done("planned", changed.length > 0 ? `would_update:${changed.join(",")}` : "already_configured", { ...tagged, generation: release.generation });
164
+ // The scopes the running supervisor qualifies are what the new one has to qualify again, unless an
165
+ // earlier run stopped mid-recreate: then the scopes it saved are the baseline.
166
+ let scopes = inFlight;
167
+ if (scopes === null) {
168
+ if (!live?.running)
169
+ return done("retained", "supervisor_not_running", { ...tagged, exitCode: 1 });
170
+ const recent = await engine.logs(live.id, new Date(now() - QUALIFIED_LOOKBACK_MS).toISOString(), signal);
171
+ if (recent === null)
172
+ return done("retained", "docker_unavailable", tagged);
173
+ scopes = qualifiedScopes(recent);
174
+ if (scopes.size === 0)
175
+ return done("retained", "no_qualified_scope", { ...tagged, exitCode: 1 });
176
+ }
177
+ const record = (outcome, detail, previous, exitCode = 0) => {
178
+ writeReleaseState(dir, {
179
+ ...state,
180
+ generation: release.generation,
181
+ releaseId: release.releaseId,
182
+ appliedAtMs: now(),
183
+ // The watchdog is not this updater's to move, so it records none.
184
+ previous: previous ? { ...previous, watchdog: "" } : state.previous,
185
+ });
186
+ return { outcome, detail, generation: release.generation, exitCode, ...tagged };
187
+ };
188
+ if (inFlight === null && changed.length === 0 && runs(target))
189
+ return record("recorded", "already_running_release", null);
190
+ // What a failure goes back to: today's file, or for a half-applied file the one saved before it.
191
+ let rollbackText = composeText;
192
+ if (changed.length === 0) {
193
+ try {
194
+ rollbackText = readFileSync(join(dir, PREVIOUS_COMPOSE_FILE), "utf8");
195
+ }
196
+ catch {
197
+ rollbackText = null;
198
+ }
199
+ }
200
+ let rollbackImages = null;
201
+ try {
202
+ rollbackImages = rollbackText === null ? null : fleetImages(JSON.parse(rollbackText));
203
+ }
204
+ catch {
205
+ rollbackImages = null;
206
+ }
207
+ const unprepared = await engine.prepare([target.runner, target.supervisor], signal);
208
+ if (unprepared !== null)
209
+ return done("retained", unprepared, tagged);
210
+ const candidateText = `${JSON.stringify(withFleetImages(compose, target), null, 2)}\n`;
211
+ const candidatePath = join(dir, CANDIDATE_COMPOSE_FILE);
212
+ writePrivateAtomic(candidatePath, candidateText);
213
+ const valid = await engine.configValid(dir, project, candidatePath, signal);
214
+ try {
215
+ unlinkSync(candidatePath);
216
+ }
217
+ catch {
218
+ // Already gone.
219
+ }
220
+ if (!valid)
221
+ return done("retained", "compose_config_invalid", { ...tagged, exitCode: 1 });
222
+ const childrenBefore = await engine.children(signal);
223
+ if (childrenBefore === null)
224
+ return done("retained", "docker_unavailable", tagged);
225
+ // From here the machine changes. The previous file is the rollback.
226
+ if (changed.length > 0)
227
+ writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
228
+ writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs: now() })}\n`);
229
+ writePrivateAtomic(composePath, candidateText);
230
+ if ((await engine.recreate(dir, project, signal)) && (await qualify(engine, dir, project, target, scopes, options, now))) {
231
+ clearInFlight(dir);
232
+ if (changed.length > 0)
233
+ await engine.removeImages(changed.map((c) => current[c]), signal);
234
+ // The recreate must leave every running phase container as it was: it is re-adopted, not restarted.
235
+ const childrenAfter = await engine.children(signal);
236
+ const disturbed = childrenAfter === null
237
+ ? -1
238
+ : Object.entries(childrenBefore).filter(([id, before]) => {
239
+ const after = childrenAfter[id];
240
+ return before.running && after?.running === true && after.fingerprint !== before.fingerprint;
241
+ }).length;
242
+ const detail = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
243
+ const previous = changed.length > 0 ? current : null;
244
+ return disturbed === 0
245
+ ? record("updated", detail, previous)
246
+ : record("updated", `${detail};${disturbed < 0 ? "children_unreadable" : `children_changed:${disturbed}`}`, previous, 1);
247
+ }
248
+ if (rollbackText === null || rollbackImages === null)
249
+ return done("failed", "new_supervisor_not_qualified_no_previous", { ...tagged, exitCode: 1 });
250
+ writePrivateAtomic(composePath, rollbackText);
251
+ const restored = (await engine.recreate(dir, project, signal)) &&
252
+ (await qualify(engine, dir, project, rollbackImages, scopes, options, now));
253
+ // A rollback that did not qualify keeps the baseline for the next run.
254
+ if (restored)
255
+ clearInFlight(dir);
256
+ return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified", { ...tagged, exitCode: 1 });
257
+ }
258
+ /** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
259
+ * scope it must serve, and qualifies every one of `scopes`, held for the minimum window. */
260
+ async function qualify(engine, dir, project, images, scopes, options, now) {
261
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
262
+ const start = now();
263
+ const deadline = start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
264
+ const minimum = start + (options.qualifyMinMs ?? QUALIFY_MIN_MS);
265
+ let id = null;
266
+ for (;;) {
267
+ if (options.signal?.aborted)
268
+ return false;
269
+ const live = await engine.supervisor(dir, project, options.signal);
270
+ if (live) {
271
+ id ??= live.id;
272
+ if (live.id !== id || !live.running || live.restartCount !== 0)
273
+ return false;
274
+ if (live.image !== images.supervisor || live.runnerImage !== images.runner)
275
+ return false;
276
+ const text = await engine.logs(live.id, live.startedAt, options.signal);
277
+ if (text !== null) {
278
+ if (notReadyScope(text, scopes) !== null)
279
+ return false;
280
+ const seen = qualifiedScopes(text);
281
+ if (now() >= minimum && [...scopes].every((scope) => seen.has(scope)))
282
+ return true;
283
+ }
284
+ }
285
+ if (now() >= deadline)
286
+ return false;
287
+ await sleep(QUALIFY_POLL_MS);
288
+ }
289
+ }
290
+ const CONTAINER_ID = /^[0-9a-f]{12,64}$/;
291
+ /** The one volume or bind in `service.volumes` that holds `file`, as a read-only `--mount` value. */
292
+ export function credentialMount(compose, service, file) {
293
+ const project = typeof compose.name === "string" ? compose.name : "";
294
+ const topLevel = isRecord(compose.volumes) ? compose.volumes : {};
295
+ let best = null;
296
+ for (const entry of Array.isArray(service.volumes) ? service.volumes : []) {
297
+ let source;
298
+ let target;
299
+ let type;
300
+ if (typeof entry === "string") {
301
+ [source, target] = entry.split(":");
302
+ type = typeof source === "string" && source.startsWith("/") ? "bind" : "volume";
303
+ }
304
+ else if (isRecord(entry)) {
305
+ ({ source, target, type } = entry);
306
+ }
307
+ if (typeof source !== "string" || typeof target !== "string" || source === "")
308
+ continue;
309
+ if (file !== target && !file.startsWith(`${target.replace(/\/$/, "")}/`))
310
+ continue;
311
+ // The deepest mount holding the file is the one the container reads it from.
312
+ if (best !== null && best.target.length >= target.length)
313
+ continue;
314
+ const declared = topLevel[source];
315
+ const name = isRecord(declared) && typeof declared.name === "string" ? declared.name
316
+ : isRecord(declared) && declared.external === true ? source : `${project}_${source}`;
317
+ const mount = type === "bind" ? `type=bind,src=${source},dst=${target},readonly`
318
+ : type === "volume" && /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) ? `type=volume,src=${name},dst=${target},readonly` : null;
319
+ best = { target, mount };
320
+ }
321
+ return best?.mount ?? null;
322
+ }
323
+ /** The real engine: the docker CLI, and the setup engine's own pull. */
324
+ export function dockerFleetUpdateEngine(deps = {}) {
325
+ const exec = deps.exec ?? spawnDocker;
326
+ const env = Object.fromEntries(Object.entries(deps.env ?? process.env)
327
+ .filter(([key]) => !key.startsWith("CATALYST_") && !key.startsWith("DOCKER_SOCKET_")));
328
+ const setup = deps.setup ?? dockerRunnerEngine({ exec, env: deps.env });
329
+ const docker = async (args, options = {}) => {
330
+ try {
331
+ return await exec(args, { env, ...options });
332
+ }
333
+ catch {
334
+ return { code: 1, stdout: "", stderr: "" };
335
+ }
336
+ };
337
+ const compose = (dir, project, file = join(dir, FLEET_COMPOSE_FILE)) => ["compose", "--project-name", project, "--file", file];
338
+ return {
339
+ // Not `compose run`: that would mount the Docker socket and every scope's state. This container
340
+ // gets the credential alone, read-only, and the two variables the script reads.
341
+ async desiredRelease(_dir, file, signal) {
342
+ const service = isRecord(file.services) && isRecord(file.services.supervisor) ? file.services.supervisor : null;
343
+ const environment = service && isRecord(service.environment) ? service.environment : null;
344
+ const credential = environment?.CATALYST_HOST_CREDENTIAL_FILE;
345
+ const mirror = environment?.CATALYST_MIRROR_URL;
346
+ if (!service || typeof service.image !== "string" || typeof credential !== "string" || typeof mirror !== "string")
347
+ return { kind: "no_credential" };
348
+ const mount = credentialMount(file, service, credential);
349
+ if (!mount)
350
+ return { kind: "no_credential" };
351
+ const user = typeof service.user === "string" && /^[0-9]+(:[0-9]+)?$/.test(service.user) ? ["--user", service.user] : [];
352
+ const read = await docker(["run", "--rm", "--network", "bridge", "--read-only", "--tmpfs", "/tmp", "--cap-drop", "ALL",
353
+ "--security-opt", "no-new-privileges", ...user, "--mount", mount,
354
+ "--env", `CATALYST_HOST_CREDENTIAL_FILE=${credential}`, "--env", `CATALYST_MIRROR_URL=${mirror}`,
355
+ "--entrypoint", "bun", service.image, "-e", DESIRED_RELEASE_SCRIPT], { signal, timeoutMs: 120_000 });
356
+ if (read.code !== 0)
357
+ return { kind: "no_credential" };
358
+ try {
359
+ const parsed = JSON.parse(read.stdout);
360
+ if (!isRecord(parsed))
361
+ return { kind: "no_credential" };
362
+ const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
363
+ if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
364
+ return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
365
+ if (parsed.kind === "unreachable" && hostId)
366
+ return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
367
+ return { kind: "no_credential" };
368
+ }
369
+ catch {
370
+ return { kind: "no_credential" };
371
+ }
372
+ },
373
+ async supervisor(dir, project, signal) {
374
+ const listed = await docker([...compose(dir, project), "ps", "--all", "--quiet", "supervisor"], { cwd: dir, signal });
375
+ if (listed.code !== 0)
376
+ return undefined;
377
+ const id = listed.stdout.trim();
378
+ if (id === "")
379
+ return null;
380
+ if (!CONTAINER_ID.test(id))
381
+ return undefined;
382
+ const inspected = await docker(["inspect", id], { signal });
383
+ try {
384
+ const [c] = JSON.parse(inspected.stdout);
385
+ const runner = c.Config.Env.find((line) => line.startsWith("CATALYST_RUNNER_IMAGE="));
386
+ return {
387
+ id: c.Id,
388
+ image: c.Config.Image,
389
+ runnerImage: runner ? runner.slice("CATALYST_RUNNER_IMAGE=".length) : "",
390
+ running: c.State.Running,
391
+ restartCount: c.RestartCount,
392
+ startedAt: c.State.StartedAt,
393
+ };
394
+ }
395
+ catch {
396
+ return undefined;
397
+ }
398
+ },
399
+ async logs(id, since, signal) {
400
+ if (!CONTAINER_ID.test(id))
401
+ return null;
402
+ const read = await docker(["logs", "--since", since, id], { signal, timeoutMs: 30_000 });
403
+ return read.code === 0 ? `${read.stdout}\n${read.stderr ?? ""}` : null;
404
+ },
405
+ async children(signal) {
406
+ const listed = await docker(["ps", "--all", "--quiet", "--no-trunc", "--filter", `name=${CHILD_NAME_PREFIX}`], { signal });
407
+ if (listed.code !== 0)
408
+ return null;
409
+ const ids = listed.stdout.split("\n").map((line) => line.trim()).filter((line) => CONTAINER_ID.test(line));
410
+ if (ids.length === 0)
411
+ return {};
412
+ const inspected = await docker(["inspect", ...ids], { signal });
413
+ try {
414
+ const containers = JSON.parse(inspected.stdout);
415
+ return Object.fromEntries(containers.map((c) => [c.Id, {
416
+ running: c.State.Running,
417
+ fingerprint: `${c.State.StartedAt}|${c.State.Pid}|${c.Image}|${c.RestartCount}`,
418
+ }]));
419
+ }
420
+ catch {
421
+ return null;
422
+ }
423
+ },
424
+ async prepare(images, signal) {
425
+ const info = await setup.info(signal);
426
+ if (!info)
427
+ return "docker_unavailable";
428
+ const refused = await prepareRunnerImages(setup, images, info.arch, signal);
429
+ return refused === null ? null : `${refused.reason}${refused.image ? `:${refused.image}` : ""}`;
430
+ },
431
+ async configValid(dir, project, file, signal) {
432
+ return (await docker([...compose(dir, project, file), "config", "--quiet"], { cwd: dirname(file), signal })).code === 0;
433
+ },
434
+ async recreate(dir, project, signal) {
435
+ return (await docker([...compose(dir, project), "up", "--detach", "--no-deps", "--force-recreate", "supervisor"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
436
+ },
437
+ async removeImages(images, signal) {
438
+ for (const ref of images)
439
+ await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 });
440
+ },
441
+ };
442
+ }
@@ -42,7 +42,7 @@ export const RELEASE_ENV_KEYS = {
42
42
  supervisor: "CATALYST_SUPERVISOR_IMAGE",
43
43
  watchdog: "CATALYST_WATCHDOG_IMAGE",
44
44
  };
45
- const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
45
+ export const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
46
46
  const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
47
47
  const DIGEST = /^sha256:[0-9a-f]{64}$/;
48
48
  const SHA40 = /^[0-9a-f]{40}$/;
@@ -122,12 +122,12 @@ export function readReleaseState(dir) {
122
122
  }
123
123
  }
124
124
  /** Write a private file by rename, so a reader never sees half of it. */
125
- function writePrivateAtomic(path, text) {
125
+ export function writePrivateAtomic(path, text) {
126
126
  const temp = `${path}.${process.pid}.tmp`;
127
127
  writeFileSync(temp, text, { mode: 0o600 });
128
128
  renameSync(temp, path);
129
129
  }
130
- function writeReleaseState(dir, state) {
130
+ export function writeReleaseState(dir, state) {
131
131
  writePrivateAtomic(join(dir, RELEASE_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
132
132
  }
133
133
  /** `.env` with each `KEY=` line for `values` replaced in place (or appended), every other line kept. */
@@ -309,7 +309,7 @@ const alive = (pid) => {
309
309
  };
310
310
  /** An exclusive lock file holding the owner's pid. It is taken over only when that process is gone,
311
311
  * never by age: one run can legitimately take longer than any fixed limit (three image pulls). */
312
- function takeLock(dir, nowMs) {
312
+ export function takeLock(dir, nowMs) {
313
313
  const path = join(dir, LOCK_FILE);
314
314
  const token = `${process.pid} ${nowMs}\n`;
315
315
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -333,7 +333,7 @@ function takeLock(dir, nowMs) {
333
333
  }
334
334
  return null;
335
335
  }
336
- function releaseLock(dir, token) {
336
+ export function releaseLock(dir, token) {
337
337
  const path = join(dir, LOCK_FILE);
338
338
  try {
339
339
  if (readFileSync(path, "utf8") === token)
@@ -345,7 +345,7 @@ function releaseLock(dir, token) {
345
345
  }
346
346
  /** Runs inside the supervisor image with its own environment. Prints one JSON object, never the
347
347
  * secret: the bearer is built and sent from inside the container. */
348
- const DESIRED_RELEASE_SCRIPT = `(async () => {
348
+ export const DESIRED_RELEASE_SCRIPT = `(async () => {
349
349
  const out = (v) => process.stdout.write(JSON.stringify(v));
350
350
  let state;
351
351
  try { state = JSON.parse(require("node:fs").readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE, "utf8")); }
@@ -367,7 +367,7 @@ const DESIRED_RELEASE_SCRIPT = `(async () => {
367
367
  out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
368
368
  }
369
369
  })();`;
370
- const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
370
+ export const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
371
371
  /** The real engine: the docker CLI and the setup engine's own pull, custody and Compose steps. */
372
372
  export function dockerRunnerUpdateEngine(deps = {}) {
373
373
  const exec = deps.exec ?? spawnDocker;