@catalyst-cloud/cli 0.16.40 → 0.16.42

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.
@@ -1,5 +1,6 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { readManifest } from "./config.js";
4
5
  import { CliError } from "./errors.js";
5
6
  import { onboardStateRoot } from "./onboard.js";
@@ -26,6 +27,11 @@ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages
26
27
  // format that differs, or is unknown on either side, waits for an idle machine as above, because an
27
28
  // older supervisor that meets a newer journal refuses to start.
28
29
  //
30
+ // A drain (CTC-7749): after a day of busy deferrals, an automatic check asks the cloud to stop
31
+ // granting this host new work. It keeps the supervisor and every running phase alive while it
32
+ // waits for local containers and cloud grants to reach zero, then applies and releases the drain.
33
+ // The two-hour soft limit reports progress; it never ends a phase.
34
+ //
29
35
  // Then it rewrites the three image lines in `.env`, keeps the previous file beside it, and runs
30
36
  // `docker compose up`. The release counts as applied only when the running containers are its
31
37
  // images and the supervisor logged `host_ready` since it started; a `.env` that already names the
@@ -39,7 +45,16 @@ const PENDING_REMOVAL_KEPT = 20;
39
45
  const READY_DEADLINE_MS = 5 * 60_000;
40
46
  const READY_POLL_MS = 5_000;
41
47
  const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
48
+ const ATTEMPT_ID_LABEL = "dev.catalystcloud.attempt.id";
42
49
  const DESIRED_RELEASE_PATH = "/api/v1/hosts/desired-release";
50
+ const UPDATE_DRAIN_PATH = "/api/v1/hosts/update-drain";
51
+ /** CTC-7749: a release that has waited this long for running phases drains the machine. */
52
+ export const DRAIN_AFTER_MS = 24 * 3_600_000;
53
+ /** A drain this old reports once and keeps waiting. It never stops a phase. */
54
+ export const DRAIN_SOFT_LIMIT_MS = 2 * 3_600_000;
55
+ const DRAIN_POLL_MS = 5_000;
56
+ const DRAIN_PASS_EVERY_MS = 15 * 60_000;
57
+ const DRAIN_READ_RETRY_MS = 60_000;
43
58
  export const RELEASE_COMPONENTS = ["runner", "supervisor", "watchdog"];
44
59
  /** The only repositories a release may name, exactly as catalyst-cloud's HOST_RELEASE_REPOSITORIES. */
45
60
  export const RELEASE_REPOSITORIES = {
@@ -52,10 +67,47 @@ export const RELEASE_ENV_KEYS = {
52
67
  supervisor: "CATALYST_SUPERVISOR_IMAGE",
53
68
  watchdog: "CATALYST_WATCHDOG_IMAGE",
54
69
  };
70
+ /** Strictly parse the host-authenticated update-drain response. */
71
+ export function parseDrainAnswer(read) {
72
+ if (read.kind === "no_credential")
73
+ return { refused: "no_credential" };
74
+ if (read.kind === "unreachable")
75
+ return { refused: "unreachable" };
76
+ if (read.status !== 200) {
77
+ const refusal = isRecord(read.body) ? read.body.refusal : null;
78
+ return { refused: typeof refusal === "string" && /^[a-z_]{1,40}$/.test(refusal) ? refusal : `http_${read.status}` };
79
+ }
80
+ const body = read.body;
81
+ if (!isRecord(body) || body.ok !== true || !Number.isSafeInteger(body.revision) || body.revision < 0 ||
82
+ !Number.isSafeInteger(body.liveGenerations) || body.liveGenerations < 0)
83
+ return { refused: "response_invalid" };
84
+ const drain = body.drain;
85
+ if (drain !== null && (!isRecord(drain) || typeof drain.requestId !== "string" || !UUID.test(drain.requestId) ||
86
+ !posInt(drain.generation) || !posInt(drain.requestedAtMs) || drain.admissionBlocked !== true))
87
+ return { refused: "response_invalid" };
88
+ return { status: { revision: body.revision, liveGenerations: body.liveGenerations, drain: drain } };
89
+ }
90
+ /** The parsed answer, with the HTTP status when the cloud answered at all. */
91
+ async function drainAnswer(engine, dir, request, signal) {
92
+ try {
93
+ const read = await engine.updateDrain(dir, request, signal);
94
+ return { ...parseDrainAnswer(read), http: read.kind === "answered" ? read.status : null };
95
+ }
96
+ catch {
97
+ return { refused: "unreachable", http: null };
98
+ }
99
+ }
100
+ /** The busy-release clock begins at approval or the first busy look, whichever came first. */
101
+ export function advanceDeferral(previous, release, nowMs) {
102
+ if (previous?.offered === release.generation)
103
+ return previous;
104
+ return { sinceMs: previous?.sinceMs ?? Math.min(nowMs, release.approvedAtMs), offered: release.generation, offeredSinceMs: nowMs };
105
+ }
55
106
  export const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
56
107
  const posInt = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
57
108
  const DIGEST = /^sha256:[0-9a-f]{64}$/;
58
109
  const SHA40 = /^[0-9a-f]{40}$/;
110
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
59
111
  /** A reference this machine may pull for `component`: its repository, at a digest, with no tag. */
60
112
  export function releaseReference(component, value) {
61
113
  if (typeof value !== "string" || value.length > 300)
@@ -121,7 +173,7 @@ export function decideRelease(appliedGeneration, read) {
121
173
  return { action: "update", release };
122
174
  }
123
175
  export function readReleaseState(dir) {
124
- const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], windowSettledMs: null, last: null };
176
+ const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], windowSettledMs: null, deferral: null, drain: null, last: null };
125
177
  try {
126
178
  const parsed = JSON.parse(readFileSync(join(dir, RELEASE_STATE_FILE), "utf8"));
127
179
  if (!isRecord(parsed) || parsed.version !== 1)
@@ -139,7 +191,13 @@ export function readReleaseState(dir) {
139
191
  ? parsed.pendingRemoval.filter((ref) => typeof ref === "string").slice(0, PENDING_REMOVAL_KEPT)
140
192
  : [],
141
193
  windowSettledMs: posInt(parsed.windowSettledMs) ? parsed.windowSettledMs : null,
142
- last: isRecord(parsed.last) ? parsed.last : null,
194
+ deferral: isRecord(parsed.deferral) && posInt(parsed.deferral.sinceMs) && posInt(parsed.deferral.offered) && posInt(parsed.deferral.offeredSinceMs)
195
+ ? { sinceMs: parsed.deferral.sinceMs, offered: parsed.deferral.offered, offeredSinceMs: parsed.deferral.offeredSinceMs } : null,
196
+ drain: isRecord(parsed.drain) && typeof parsed.drain.requestId === "string" && UUID.test(parsed.drain.requestId) &&
197
+ posInt(parsed.drain.generation) && Number.isSafeInteger(parsed.drain.revision) && parsed.drain.revision >= 0 &&
198
+ (parsed.drain.acknowledgedAtMs === null || posInt(parsed.drain.acknowledgedAtMs)) && typeof parsed.drain.softLimitReported === "boolean"
199
+ ? parsed.drain : null,
200
+ last: isRecord(parsed.last) ? { ...parsed.last, offered: posInt(parsed.last.offered) ? parsed.last.offered : null } : null,
143
201
  };
144
202
  }
145
203
  catch {
@@ -178,6 +236,87 @@ export function rewriteEnvText(text, values) {
178
236
  /** Outcomes that settle a window: nothing was pending, or the machine restarted onto (or tried) a
179
237
  * release. A busy deferral or a passing refusal leaves the window open for the next check. */
180
238
  const SETTLES_WINDOW = new Set(["current", "recorded", "updated", "rolled_back", "failed"]);
239
+ const REPORTED_AT_ONCE = new Set(["deferred_busy", "draining_for_update", "drain_soft_limit", "drain_unavailable"]);
240
+ const WAITS = new Set(["draining_for_update", "drain_soft_limit"]);
241
+ /** CTC-8076: Mac private thoughts storage guards running phases, not only this update. Its native
242
+ * producer answers every phase storage request on the attested supervisor image and checks the engine
243
+ * binding on each one, and the supervisor mounts that channel. A pass that could not attest storage
244
+ * back onto the images the machine keeps is failed: it keeps any drain, so no new phase reaches
245
+ * storage that refuses it, and it is reported at once so the owner hears of it. */
246
+ export const custodyUnattested = (result) => result.outcome === "failed" && result.detail.startsWith("custody_restore_failed:");
247
+ const sleepDefault = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
248
+ /** Release a known drain, re-reading after a revision conflict. A failed release remains recorded. */
249
+ async function releaseDrain(engine, dir, state, signal, sleep = sleepDefault) {
250
+ const local = state.drain;
251
+ if (local === null)
252
+ return true;
253
+ for (let attempt = 0; attempt < 3; attempt++) {
254
+ try {
255
+ const answer = parseDrainAnswer(await engine.updateDrain(dir, { action: "release", requestId: local.requestId, expectedRevision: local.revision }, signal));
256
+ if ("status" in answer && (answer.status.drain === null || answer.status.drain.requestId !== local.requestId))
257
+ return true;
258
+ const looked = parseDrainAnswer(await engine.updateDrain(dir, null, signal));
259
+ if ("status" in looked) {
260
+ if (looked.status.drain === null || looked.status.drain.requestId !== local.requestId)
261
+ return true;
262
+ local.revision = looked.status.revision;
263
+ }
264
+ }
265
+ catch { /* The next attempt may reach the cloud. */ }
266
+ if (attempt < 2)
267
+ await sleep(DRAIN_POLL_MS);
268
+ }
269
+ return false;
270
+ }
271
+ /** End an update drain even when local state was lost. The lock prevents racing a waiting updater. */
272
+ export async function releaseUpdateDrain(engine, dir, { cloud, sleep = sleepDefault }) {
273
+ let lock = null;
274
+ for (let elapsed = 0; elapsed <= (cloud ? 10_000 : 0); elapsed += DRAIN_POLL_MS) {
275
+ lock = takeLock(dir, Date.now());
276
+ if (lock !== null)
277
+ break;
278
+ if (elapsed < 10_000 && cloud)
279
+ await sleep(DRAIN_POLL_MS);
280
+ }
281
+ // A running update owns release-state.json and ends its own wait on the hold: never write over it.
282
+ if (lock === null)
283
+ return "pending";
284
+ try {
285
+ const state = readReleaseState(dir);
286
+ if (!cloud && state.drain === null) {
287
+ if (state.deferral !== null)
288
+ writeReleaseState(dir, { ...state, deferral: null });
289
+ return "none";
290
+ }
291
+ const looked = parseDrainAnswer(await engine.updateDrain(dir, null));
292
+ if ("refused" in looked)
293
+ return "failed";
294
+ if (looked.status.drain === null) {
295
+ writeReleaseState(dir, { ...state, drain: null, deferral: null });
296
+ return "none";
297
+ }
298
+ const drain = looked.status.drain;
299
+ const adopted = { ...state, drain: { requestId: drain.requestId, generation: drain.generation, revision: looked.status.revision,
300
+ acknowledgedAtMs: drain.requestedAtMs, softLimitReported: state.drain?.softLimitReported ?? false } };
301
+ writeReleaseState(dir, adopted);
302
+ if (!(await releaseDrain(engine, dir, adopted, undefined, sleep)))
303
+ return "failed";
304
+ writeReleaseState(dir, { ...readReleaseState(dir), drain: null, deferral: null });
305
+ return "released";
306
+ }
307
+ catch {
308
+ return "failed";
309
+ }
310
+ finally {
311
+ releaseLock(dir, lock);
312
+ }
313
+ }
314
+ /** The read-only check explains why a machine is currently fenced from new work. */
315
+ export function drainLines(state) {
316
+ const drain = state.drain;
317
+ return drain?.acknowledgedAtMs === null || !drain ? [] :
318
+ [`Catalyst has sent this machine no new phases since ${new Date(drain.acknowledgedAtMs).toISOString()}, so it can move to generation ${drain.generation} once its running phases finish. To stop waiting and turn automatic updates off: catalyst runner auto-update off`];
319
+ }
181
320
  /** One serialized update run. A second concurrent run returns `retained` with `update_running`. */
182
321
  export async function runRunnerUpdate(engine, options) {
183
322
  const { dir } = options;
@@ -191,13 +330,66 @@ export async function runRunnerUpdate(engine, options) {
191
330
  try {
192
331
  if (!options.check)
193
332
  await retryPendingRemoval(engine, dir, options.signal);
194
- const result = await update(engine, options, now);
195
- if (!options.check) {
196
- const state = readReleaseState(dir);
333
+ for (let pass = 0;; pass++) {
334
+ let hostId = "";
335
+ let result = await update(engine, options, now, pass > 0, (id) => { hostId = id; });
336
+ if (options.check)
337
+ return result;
338
+ let state = readReleaseState(dir);
339
+ let deferral = state.deferral;
340
+ if (["updated", "recorded", "current"].includes(result.outcome))
341
+ deferral = null;
342
+ else if (["rolled_back", "failed"].includes(result.outcome) && result.offeredGeneration !== undefined)
343
+ deferral = { sinceMs: now(), offered: result.offeredGeneration, offeredSinceMs: now() };
344
+ else if (state.drain && result.outcome === "retained")
345
+ deferral = result.offeredGeneration === undefined ? null : { sinceMs: now(), offered: result.offeredGeneration, offeredSinceMs: now() };
197
346
  const windowSettledMs = options.window && SETTLES_WINDOW.has(result.outcome) ? options.window.lastOpenedMs : state.windowSettledMs;
198
- writeReleaseState(dir, { ...state, windowSettledMs, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
347
+ writeReleaseState(dir, { ...state, deferral, windowSettledMs, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation, offered: result.offeredGeneration ?? null } });
348
+ state = readReleaseState(dir);
349
+ // CTC-8077: the schedule's applied release with no local drain record reads the cloud once, so
350
+ // a drain whose record was lost with release-state.json is still released.
351
+ if ((result.outcome === "recorded" || (result.outcome === "updated" && options.drain)) && state.drain === null) {
352
+ const looked = await drainAnswer(engine, dir, null, options.signal);
353
+ if ("status" in looked && looked.status.drain !== null) {
354
+ const active = looked.status.drain;
355
+ state = { ...state, drain: { requestId: active.requestId, generation: active.generation, revision: looked.status.revision,
356
+ acknowledgedAtMs: active.requestedAtMs, softLimitReported: false } };
357
+ writeReleaseState(dir, state);
358
+ }
359
+ }
360
+ if (state.drain !== null && ["updated", "recorded", "current", "rolled_back", "retained"].includes(result.outcome)) {
361
+ const released = await releaseDrain(engine, dir, state, options.signal, options.sleep);
362
+ if (released)
363
+ writeReleaseState(dir, { ...readReleaseState(dir), drain: null });
364
+ else if (result.outcome === "updated") {
365
+ result = { ...result, detail: `${result.detail}:drain_release_pending` };
366
+ writeReleaseState(dir, { ...readReleaseState(dir), last: { atMs: now(), outcome: result.outcome, detail: result.detail,
367
+ generation: result.generation, offered: result.offeredGeneration ?? null } });
368
+ }
369
+ }
370
+ if (REPORTED_AT_ONCE.has(result.outcome) || custodyUnattested(result)) {
371
+ try {
372
+ await engine.desiredRelease(dir, options.signal, updateReport(readReleaseState(dir), readManifest().version), true);
373
+ }
374
+ catch { /* The next read carries the same report. */ }
375
+ }
376
+ if (!options.drain || !WAITS.has(result.outcome))
377
+ return result;
378
+ options.onPass?.(result);
379
+ const woke = await waitForIdle(engine, options, now, hostId, result);
380
+ if (woke === "aborted")
381
+ return result;
382
+ if (woke === "held") {
383
+ const heldState = readReleaseState(dir);
384
+ if (heldState.drain !== null && await releaseDrain(engine, dir, heldState, options.signal, options.sleep))
385
+ writeReleaseState(dir, { ...heldState, drain: null, deferral: null });
386
+ const ended = { outcome: "retained", detail: "owner_setting", generation: heldState.generation,
387
+ offeredGeneration: result.offeredGeneration, releaseId: result.releaseId, exitCode: 0 };
388
+ writeReleaseState(dir, { ...readReleaseState(dir), last: { atMs: now(), outcome: ended.outcome, detail: ended.detail,
389
+ generation: ended.generation, offered: ended.offeredGeneration ?? null } });
390
+ return ended;
391
+ }
199
392
  }
200
- return result;
201
393
  }
202
394
  finally {
203
395
  if (lock !== null)
@@ -239,29 +431,168 @@ export function updateReport(state, cliVersion) {
239
431
  outcome: state.last.outcome,
240
432
  detail: state.last.detail.toLowerCase().replace(REPORT_DETAIL, "_").slice(0, 160),
241
433
  applied: state.generation,
242
- offered: state.last.generation,
434
+ offered: state.last.offered ?? state.last.generation,
243
435
  atMs: state.last.atMs,
244
436
  cli: /^\d{1,4}\.\d{1,4}\.\d{1,6}$/.test(cliVersion) ? cliVersion : null,
245
437
  });
246
438
  }
247
- async function update(engine, options, now) {
439
+ function drainingResult(dir, state, atMs, phases, live, suffix, tagged = {}) {
440
+ const drain = state.drain;
441
+ const drainingFor = Math.max(0, atMs - (drain?.acknowledgedAtMs ?? atMs));
442
+ const soft = drain?.acknowledgedAtMs != null && drainingFor >= DRAIN_SOFT_LIMIT_MS && !drain.softLimitReported;
443
+ if (soft && drain)
444
+ writeReleaseState(dir, { ...state, drain: { ...drain, softLimitReported: true } });
445
+ const detail = waitDetail(phases, state.deferral, atMs, `draining_for_ms:${drainingFor}` +
446
+ (live !== undefined ? `,live_generations:${live}` : "") + (suffix ? `,${suffix}` : ""));
447
+ return { outcome: soft ? "drain_soft_limit" : "draining_for_update", detail, generation: state.generation,
448
+ offeredGeneration: state.deferral?.offered, exitCode: 0, ...tagged };
449
+ }
450
+ function waitDetail(phases, deferral, atMs, extra) {
451
+ const deferredFor = Math.max(0, atMs - (deferral?.offeredSinceMs ?? atMs));
452
+ return `phases_running:${phases ?? "unknown"},deferred_for_ms:${deferredFor}` + (extra ? `,${extra}` : "");
453
+ }
454
+ function deferBusy(dir, state, release, atMs, phases, done, tagged) {
455
+ const deferral = advanceDeferral(state.deferral, release, atMs);
456
+ writeReleaseState(dir, { ...readReleaseState(dir), deferral });
457
+ return done("deferred_busy", waitDetail(phases, deferral, atMs), tagged);
458
+ }
459
+ async function waitForIdle(engine, options, now, hostId, result) {
460
+ const sleep = options.sleep ?? sleepDefault;
461
+ const retry = result.detail.includes(",read:");
462
+ const passAt = now() + (retry ? DRAIN_READ_RETRY_MS : DRAIN_PASS_EVERY_MS);
463
+ for (;;) {
464
+ if (options.signal?.aborted)
465
+ return "aborted";
466
+ if (options.held?.() === "owner_setting")
467
+ return "held";
468
+ if (now() >= passAt)
469
+ return "idle";
470
+ const phases = await engine.attemptContainers(options.dir, hostId, options.signal);
471
+ if (phases === 0) {
472
+ const looked = await drainAnswer(engine, options.dir, null, options.signal);
473
+ if ("status" in looked) {
474
+ const ours = readReleaseState(options.dir).drain;
475
+ if (looked.status.drain?.requestId !== ours?.requestId) {
476
+ const state = readReleaseState(options.dir);
477
+ writeReleaseState(options.dir, { ...state, drain: null, deferral: state.deferral === null ? null : { ...state.deferral, sinceMs: now(), offeredSinceMs: now() } });
478
+ return "gone";
479
+ }
480
+ // After a failed read, an idle machine still waits for the retry time before reading again.
481
+ if (looked.status.liveGenerations === 0 && !retry)
482
+ return "idle";
483
+ }
484
+ }
485
+ await sleep(DRAIN_POLL_MS);
486
+ }
487
+ }
488
+ /** The non-in-place path checks local phases and the cloud fence before any supervisor stop. */
489
+ async function drainedLook({ engine, options, state, release, target, hostId, now, done, tagged }) {
490
+ const { dir, signal } = options;
491
+ const busy = await engine.attemptContainers(dir, hostId, signal);
492
+ if (state.drain?.acknowledgedAtMs != null) {
493
+ if (busy !== 0)
494
+ return drainingResult(dir, state, now(), busy, undefined, undefined, tagged);
495
+ const looked = await drainAnswer(engine, dir, null, signal);
496
+ if ("refused" in looked)
497
+ return drainingResult(dir, state, now(), 0, undefined, `read:${looked.refused}`, tagged);
498
+ if (looked.status.drain?.requestId === state.drain.requestId && looked.status.liveGenerations > 0)
499
+ return drainingResult(dir, state, now(), 0, looked.status.liveGenerations, undefined, tagged);
500
+ if (looked.status.drain === null || looked.status.drain.requestId !== state.drain.requestId) {
501
+ writeReleaseState(dir, { ...state, drain: null, deferral: { sinceMs: now(), offered: release.generation, offeredSinceMs: now() } });
502
+ return done("deferred_busy", "phases_running:0,deferred_for_ms:0", tagged);
503
+ }
504
+ }
505
+ else if (busy === null)
506
+ return done("retained", "docker_unavailable", tagged);
507
+ else if (busy > 0) {
508
+ const deferral = advanceDeferral(state.deferral, release, now());
509
+ if (!options.check)
510
+ writeReleaseState(dir, { ...state, deferral });
511
+ const detail = waitDetail(busy, deferral, now());
512
+ if (!options.drain || now() - deferral.sinceMs < DRAIN_AFTER_MS || options.check)
513
+ return done("deferred_busy", detail, tagged);
514
+ const unprepared = await engine.prepare(RELEASE_COMPONENTS.map((c) => target[c]), signal);
515
+ if (unprepared !== null)
516
+ return done("retained", unprepared, tagged);
517
+ const looked = await drainAnswer(engine, dir, null, signal);
518
+ if ("refused" in looked)
519
+ return done("drain_unavailable", `${detail},drain:${looked.refused}`, tagged);
520
+ let drain = looked.status.drain;
521
+ let revision = looked.status.revision;
522
+ const requestId = state.drain?.requestId ?? randomUUID();
523
+ if (drain === null) {
524
+ const pending = { requestId, generation: release.generation, revision, acknowledgedAtMs: null, softLimitReported: false };
525
+ writeReleaseState(dir, { ...readReleaseState(dir), drain: pending });
526
+ const requested = await drainAnswer(engine, dir, { action: "request", requestId, expectedRevision: revision, generation: release.generation }, signal);
527
+ if ("refused" in requested) {
528
+ const conflict = requested.refused === "update_drain_conflict" ? await drainAnswer(engine, dir, null, signal) : null;
529
+ if (conflict && "status" in conflict && conflict.status.drain !== null) {
530
+ drain = conflict.status.drain;
531
+ revision = conflict.status.revision;
532
+ }
533
+ else {
534
+ // Only a 4xx answer shows the cloud did not record the request. No answer, any 5xx (such as
535
+ // 503 update_drain_unavailable) or a malformed 200 may follow a commit: keep the pending id
536
+ // so a later check adopts or releases it.
537
+ if (requested.http !== null && requested.http >= 400 && requested.http < 500)
538
+ writeReleaseState(dir, { ...readReleaseState(dir), drain: null });
539
+ return done("drain_unavailable", `${detail},drain:${requested.refused}`, tagged);
540
+ }
541
+ }
542
+ else {
543
+ drain = requested.status.drain;
544
+ revision = requested.status.revision;
545
+ }
546
+ }
547
+ if (drain === null)
548
+ return done("drain_unavailable", `${detail},drain:response_invalid`, tagged);
549
+ const heldDrain = { requestId: drain.requestId, generation: drain.generation, revision,
550
+ acknowledgedAtMs: drain.requestedAtMs, softLimitReported: false };
551
+ writeReleaseState(dir, { ...readReleaseState(dir), drain: heldDrain });
552
+ return drainingResult(dir, readReleaseState(dir), now(), busy, looked.status.liveGenerations, undefined, tagged);
553
+ }
554
+ return null;
555
+ }
556
+ async function update(engine, options, now, fresh = false, onHostId) {
248
557
  const { dir, signal } = options;
249
558
  const envPath = join(dir, ".env");
250
- const state = readReleaseState(dir);
559
+ let state = readReleaseState(dir);
251
560
  const done = (outcome, detail, extra = {}) => ({ outcome, detail, generation: state.generation, exitCode: 0, ...extra });
252
- const read = await engine.desiredRelease(dir, signal, options.check ? undefined : updateReport(state, readManifest().version));
561
+ const read = await engine.desiredRelease(dir, signal, options.check ? undefined : updateReport(state, readManifest().version), fresh);
562
+ const hostId = read.kind === "no_credential" ? "" : read.hostId;
563
+ onHostId?.(hostId);
253
564
  const decision = decideRelease(state.generation, read);
254
- if (decision.action === "retain")
565
+ const offered = read.kind === "answered" && read.status === 200 && isRecord(read.body) && read.body.ok === true
566
+ ? parseDesiredRelease(read.body.release) : null;
567
+ if (decision.action === "retain") {
568
+ if (!options.check && state.drain?.acknowledgedAtMs != null && decision.reason !== "not_newer")
569
+ return drainingResult(dir, state, now(), null, undefined, `read:${decision.reason}`);
255
570
  return decision.reason === "not_newer"
256
- ? done("current", "not_newer")
571
+ ? done("current", "not_newer", offered ? { releaseId: offered.releaseId, offeredGeneration: offered.generation } : {})
257
572
  : done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
573
+ }
258
574
  const { release } = decision;
259
- const tagged = { releaseId: release.releaseId };
575
+ const tagged = { releaseId: release.releaseId, offeredGeneration: release.generation };
576
+ if (!options.check && state.drain?.acknowledgedAtMs === null) {
577
+ const looked = await drainAnswer(engine, dir, null, signal);
578
+ if ("refused" in looked)
579
+ return done("drain_unavailable", waitDetail(null, state.deferral, now(), `drain:${looked.refused}`), tagged);
580
+ if ("status" in looked && looked.status.drain !== null) {
581
+ const active = looked.status.drain;
582
+ state = { ...state, drain: { requestId: active.requestId, generation: active.generation, revision: looked.status.revision,
583
+ acknowledgedAtMs: active.requestedAtMs, softLimitReported: state.drain.softLimitReported } };
584
+ writeReleaseState(dir, state);
585
+ }
586
+ }
587
+ if (!options.check && state.drain !== null && state.deferral?.offered !== release.generation) {
588
+ state = { ...state, deferral: advanceDeferral(state.deferral, release, now()) };
589
+ writeReleaseState(dir, state);
590
+ }
260
591
  // A newer release on the schedule waits for a window no run has settled. A machine that has
261
592
  // settled none yet (its first run on this CLI) takes it now, and drift on the applied generation
262
593
  // is put right at once, as before.
263
594
  const { window } = options;
264
- if (window && !options.check && decision.action === "update" && state.windowSettledMs !== null && window.lastOpenedMs <= state.windowSettledMs)
595
+ if (window && state.drain?.acknowledgedAtMs == null && !options.check && decision.action === "update" && state.windowSettledMs !== null && window.lastOpenedMs <= state.windowSettledMs)
265
596
  return done("waiting_window", `next_window:${new Date(window.nextOpensMs).toISOString()}`, tagged);
266
597
  const target = Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, release.images[c].reference]));
267
598
  const envText = readFileSync(envPath, "utf8");
@@ -278,7 +609,7 @@ async function update(engine, options, now) {
278
609
  // place instead of waiting for one more applied generation.
279
610
  if (!options.check && state.journal === null && release.journalFormat !== null)
280
611
  writeReleaseState(dir, { ...state, journal: { format: release.journalFormat, supervisor: target.supervisor } });
281
- return done("current", "not_newer");
612
+ return done("current", "not_newer", tagged);
282
613
  }
283
614
  }
284
615
  const record = (outcome, detail, previous, unremoved = []) => {
@@ -305,15 +636,12 @@ async function update(engine, options, now) {
305
636
  }
306
637
  const rollbackImages = rollbackText === null ? null : imagesOf(parseEnvText(rollbackText));
307
638
  // In place only when the supervisor a failure goes back to reads the release's journal format.
308
- const inPlace = release.journalFormat !== null && state.journal !== null && rollbackImages !== null
639
+ const inPlace = state.drain === null && release.journalFormat !== null && state.journal !== null && rollbackImages !== null
309
640
  && state.journal.format === release.journalFormat && state.journal.supervisor === rollbackImages.supervisor;
310
- const hostId = read.kind === "answered" ? read.hostId : "";
311
641
  if (!inPlace) {
312
- const busy = await engine.attemptContainers(dir, hostId, signal);
313
- if (busy === null)
314
- return done("retained", "docker_unavailable");
315
- if (busy > 0)
316
- return done("deferred_busy", `attempt_containers:${busy}`, tagged);
642
+ const looked = await drainedLook({ engine, options, state, release, target, hostId, now, done, tagged });
643
+ if (looked !== null)
644
+ return looked;
317
645
  }
318
646
  const unprepared = await engine.prepare(RELEASE_COMPONENTS.map((c) => target[c]), signal);
319
647
  if (unprepared !== null)
@@ -341,18 +669,21 @@ async function update(engine, options, now) {
341
669
  }
342
670
  // The boundary between phases: stop the supervisor so it admits nothing, then look again. A phase
343
671
  // that started since the first look keeps running, the supervisor comes back, and the update waits.
672
+ const unattested = (refused) => done("failed", `custody_restore_failed:${refused}`, { ...tagged, exitCode: 1 });
344
673
  if (!(await engine.stopSupervisor(dir, signal))) {
345
674
  await engine.composeUp(dir, signal);
346
- await restoreCustody(rollbackImages ?? current);
347
- return done("retained", "supervisor_stop_failed", tagged);
675
+ const restoreRefused = await restoreCustody(rollbackImages ?? current);
676
+ return restoreRefused === null ? done("retained", "supervisor_stop_failed", tagged) : unattested(restoreRefused);
348
677
  }
349
678
  const busyAfterStop = inPlace ? 0 : await engine.attemptContainers(dir, hostId, signal);
350
679
  if (busyAfterStop !== 0) {
351
680
  await engine.composeUp(dir, signal);
352
- await restoreCustody(rollbackImages ?? current);
353
- return busyAfterStop === null
681
+ const restoreRefused = await restoreCustody(rollbackImages ?? current);
682
+ if (restoreRefused !== null)
683
+ return unattested(restoreRefused);
684
+ return state.drain?.acknowledgedAtMs != null ? drainingResult(dir, readReleaseState(dir), now(), busyAfterStop, undefined, undefined, tagged) : busyAfterStop === null
354
685
  ? done("retained", "docker_unavailable", tagged)
355
- : done("deferred_busy", `attempt_containers:${busyAfterStop}`, tagged);
686
+ : deferBusy(dir, state, release, now(), busyAfterStop, done, tagged);
356
687
  }
357
688
  // From here the machine changes. The previous file is the rollback.
358
689
  if (changed.length > 0)
@@ -461,7 +792,53 @@ export const DESIRED_RELEASE_SCRIPT = `(async () => {
461
792
  out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
462
793
  }
463
794
  })();`;
795
+ /** Sibling of the desired-release script: the host bearer and POST body stay inside Docker. */
796
+ export const UPDATE_DRAIN_SCRIPT = `(async () => {
797
+ const out = (v) => process.stdout.write(JSON.stringify(v));
798
+ let state;
799
+ try { state = JSON.parse(require("node:fs").readFileSync(process.env.CATALYST_HOST_CREDENTIAL_FILE, "utf8")); }
800
+ catch { return out({ kind: "no_credential" }); }
801
+ const e = state && state.enrollment;
802
+ const base = process.env.CATALYST_MIRROR_URL;
803
+ if (!state || state.version !== 1 || typeof state.secret !== "string" || !e ||
804
+ typeof e.tenant !== "string" || typeof e.hostId !== "string" || !base) return out({ kind: "no_credential" });
805
+ const tenant = Buffer.from(e.tenant, "utf8").toString("base64url");
806
+ const body = process.env.CATALYST_UPDATE_DRAIN_BODY || "";
807
+ if (body.length > 512) return out({ kind: "unreachable", detail: "body_too_large", hostId: e.hostId });
808
+ try {
809
+ const response = await fetch(new URL(${JSON.stringify(UPDATE_DRAIN_PATH)}, base).href, {
810
+ method: body ? "POST" : "GET", redirect: "error", signal: AbortSignal.timeout(15000),
811
+ headers: { authorization: "Bearer ctc_host_" + tenant + "." + e.hostId + "." + state.secret,
812
+ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
813
+ ...(body ? { body } : {}),
814
+ });
815
+ let responseBody = null;
816
+ try { responseBody = await response.json(); } catch {}
817
+ out({ kind: "answered", status: response.status, body: responseBody, hostId: e.hostId });
818
+ } catch (error) {
819
+ out({ kind: "unreachable", detail: String((error && error.name) || "fetch failed"), hostId: e.hostId });
820
+ }
821
+ })();`;
464
822
  export const HOST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
823
+ /** The one JSON line the one-off supervisor returns for either host-authenticated route. */
824
+ function hostAnswer(read) {
825
+ if (read.code !== 0)
826
+ return { kind: "no_credential" };
827
+ try {
828
+ const parsed = JSON.parse(read.stdout);
829
+ if (!isRecord(parsed))
830
+ return { kind: "no_credential" };
831
+ const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
832
+ if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
833
+ return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
834
+ if (parsed.kind === "unreachable" && hostId)
835
+ return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
836
+ return { kind: "no_credential" };
837
+ }
838
+ catch {
839
+ return { kind: "no_credential" };
840
+ }
841
+ }
465
842
  /** The real engine: the docker CLI and the setup engine's own pull, custody and Compose steps. */
466
843
  export function dockerRunnerUpdateEngine(deps = {}) {
467
844
  const exec = deps.exec ?? spawnDocker;
@@ -483,28 +860,24 @@ export function dockerRunnerUpdateEngine(deps = {}) {
483
860
  return { kind: "no_credential" };
484
861
  const reportEnv = report ? ["-e", `CATALYST_UPDATE_REPORT=${report}`] : [];
485
862
  const read = await docker([...compose, "run", "--rm", "-T", "--no-deps", ...reportEnv, "--entrypoint", "bun", "supervisor", "-e", DESIRED_RELEASE_SCRIPT], { cwd: dir, signal, timeoutMs: 120_000 });
486
- if (read.code !== 0)
487
- return { kind: "no_credential" };
488
- try {
489
- const parsed = JSON.parse(read.stdout);
490
- if (!isRecord(parsed))
491
- return { kind: "no_credential" };
492
- const hostId = typeof parsed.hostId === "string" && HOST_ID.test(parsed.hostId) ? parsed.hostId : null;
493
- if (parsed.kind === "answered" && hostId && typeof parsed.status === "number")
494
- return { kind: "answered", status: parsed.status, body: parsed.body ?? null, hostId };
495
- if (parsed.kind === "unreachable" && hostId)
496
- return { kind: "unreachable", detail: String(parsed.detail ?? "fetch failed").slice(0, 80), hostId };
497
- return { kind: "no_credential" };
498
- }
499
- catch {
863
+ return hostAnswer(read);
864
+ },
865
+ async updateDrain(dir, request, signal) {
866
+ const compose = runnerComposeArgs(dir);
867
+ if (!compose)
500
868
  return { kind: "no_credential" };
501
- }
869
+ const body = request === null ? [] : ["-e", `CATALYST_UPDATE_DRAIN_BODY=${JSON.stringify(request)}`];
870
+ const read = await docker([...compose, "run", "--rm", "-T", "--no-deps", ...body, "--entrypoint", "bun", "supervisor", "-e", UPDATE_DRAIN_SCRIPT], { cwd: dir, signal, timeoutMs: 120_000 });
871
+ return hostAnswer(read);
502
872
  },
503
873
  async attemptContainers(_dir, hostId, signal) {
504
874
  if (!HOST_ID.test(hostId))
505
875
  return null;
506
- const listed = await docker(["ps", "--all", "--quiet", "--filter", `label=${ATTEMPT_HOST_LABEL}=${hostId}`], { signal });
507
- return listed.code === 0 ? listed.stdout.split("\n").filter((line) => line.trim() !== "").length : null;
876
+ const listed = await docker(["ps", "--all", "--filter", `label=${ATTEMPT_HOST_LABEL}=${hostId}`, "--format", `{{.ID}}\t{{.Label "${ATTEMPT_ID_LABEL}"}}`], { signal });
877
+ return listed.code === 0 ? new Set(listed.stdout.split("\n").filter(Boolean).map((line) => {
878
+ const [id, attempt] = line.trim().split("\t");
879
+ return attempt || id;
880
+ })).size : null;
508
881
  },
509
882
  async prepare(images, signal) {
510
883
  const info = await setup.info(signal);
@@ -599,6 +972,9 @@ const OUTCOME_TEXT = {
599
972
  updated: "The runner moved to the approved release.",
600
973
  planned: "Checked the approved release; nothing was changed.",
601
974
  deferred_busy: "A phase is running here, so the update waits for the next check.",
975
+ draining_for_update: "Phases are running here, so Catalyst sends this machine no new ones until they finish. Then it moves to the approved release.",
976
+ drain_soft_limit: "Phases have run here for over 2 hours since Catalyst stopped sending new ones. They keep running, and the update still waits for them.",
977
+ drain_unavailable: "A phase is running here and Catalyst could not be asked to stop sending new ones, so the update waits for the next check.",
602
978
  waiting_window: "An approved release waits for this machine's next update window.",
603
979
  retained: "Kept the current images.",
604
980
  rolled_back: "The new release was rolled back, so the previous images were restored.",
@@ -616,11 +992,16 @@ export function lastProblemLines(state) {
616
992
  if (last === null)
617
993
  return [];
618
994
  const what = last.outcome === "rolled_back" ? "was rolled back" : "failed";
619
- return [`The last update, at ${new Date(last.atMs).toISOString()}, ${what} (${last.detail}; offered generation ${last.generation ?? "none"}).`];
995
+ return [`The last update, at ${new Date(last.atMs).toISOString()}, ${what} (${last.detail}; offered generation ${last.offered ?? last.generation ?? "none"}).`,
996
+ ...(custodyUnattested(last) ? [CUSTODY_UNATTESTED_LINE] : [])];
620
997
  }
998
+ /** The owner's line for a pass that left private thoughts storage unattested (CTC-8076). */
999
+ export const CUSTODY_UNATTESTED_LINE = "Private thoughts storage on this Mac is not attested to the images it runs, so phases that use it are refused. " +
1000
+ "If Catalyst had stopped sending this machine new phases for an update, it still sends none. To repair storage: catalyst onboard --runner";
621
1001
  export function updateResultLines(result) {
622
1002
  return [
623
1003
  OUTCOME_TEXT[result.outcome],
624
1004
  `Release: ${result.releaseId ?? "none"}, generation ${result.generation ?? "none"} (${result.detail}).`,
1005
+ ...(custodyUnattested(result) ? [CUSTODY_UNATTESTED_LINE] : []),
625
1006
  ];
626
1007
  }