@catalyst-cloud/cli 0.16.41 → 0.16.43
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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/dist/ask.js +26 -10
- package/dist/cli.js +1 -1
- package/dist/runner-command.js +50 -9
- package/dist/runner-update-fleet.js +242 -35
- package/dist/runner-update.js +427 -46
- package/dist/update.js +14 -9
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
package/dist/runner-update.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
if (
|
|
314
|
-
return
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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", "--
|
|
507
|
-
return listed.code === 0 ? listed.stdout.split("\n").filter((line) =>
|
|
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
|
}
|