@catalyst-cloud/cli 0.16.41 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/dist/runner-command.js +50 -9
- package/dist/runner-update-fleet.js +235 -30
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.42
|
|
4
|
+
|
|
5
|
+
A busy machine now takes a waiting release after one day. Before, a busy machine could keep putting off a release that cannot take over from running work in place. Now the next check asks Catalyst to send the machine no new work, waits for the running work to finish, then moves to the release and takes work again. `catalyst runner update --check` says when a machine is waiting like this, and `catalyst runner auto-update off` ends the wait. If the update cannot restore the machine's private thoughts storage, the machine keeps taking no new work, and `catalyst runner update` tells you what to do. This release also makes updates on Catalyst's own machines more reliable.
|
|
6
|
+
|
|
3
7
|
## 0.16.41
|
|
4
8
|
|
|
5
9
|
A runner's automatic update schedule now applies a newly approved runner release at its next check, instead of waiting for one of four set times of day. An owner who wants set times can still choose them with `catalyst update policy windows <HH:MM,...>`, and `catalyst update policy windows default` goes back to applying at the next check. `catalyst runner update` still applies a release at once, and `catalyst update policy` shows which setting is in effect.
|
package/README.md
CHANGED
|
@@ -346,7 +346,7 @@ The skill files themselves are written by whichever install command you ran, in
|
|
|
346
346
|
|
|
347
347
|
The new CLI is installed beside the old one, and your `catalyst` command runs it at the same path. A running local replica restarts on it and keeps its synced data; a stopped one stays stopped. CLI, SDK and image updates keep their existing policy against moving to an older version. Ordinary CLI startup never replaces skills with its bundled copy.
|
|
348
348
|
|
|
349
|
-
On a machine checking for releases every 15 minutes (`catalyst runner auto-update on`), that check applies the same component policies. Otherwise components move when you run `catalyst update`. That check moves the runner images to a newer release as soon as it finds one, with no set times of day. An update does not stop work that is already running: that work finishes on the image it started with. If you want updates only at set times, `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to none, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on.
|
|
349
|
+
On a machine checking for releases every 15 minutes (`catalyst runner auto-update on`), that check applies the same component policies. Otherwise components move when you run `catalyst update`. That check moves the runner images to a newer release as soon as it finds one, with no set times of day. An update does not stop work that is already running: that work finishes on the image it started with. When work is running and the new release cannot take over from it in place, the check leaves the release waiting. Once a release has waited a day, the next check asks Catalyst to send the machine no new work. It waits for the running work to finish, however long that takes, then moves to the release and takes work again. `catalyst runner update --check` says when a machine is waiting like this, and `catalyst runner auto-update off` ends the wait. If you want updates only at set times, `catalyst update policy windows 06:00,18:00 --time-zone America/Chicago` sets your own times, `catalyst update policy windows default` goes back to none, and `catalyst runner update` or `catalyst update` applies a waiting release at once. Each component is on by default; turn one off with `catalyst update policy <component> off` (for example `cli`), and `catalyst update policy` lists every setting. If you turned the installer's daily update off, the CLI and both skills packs start held, and `catalyst update policy cli on` turns the CLI back on.
|
|
350
350
|
|
|
351
351
|
The installer's daily job runs the same release check, so it moves the CLI and the Cloud skills only to the approved release and keeps your holds. Until the development skills join the release, it refreshes them from their repository.
|
|
352
352
|
|
package/dist/runner-command.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import { flagBool, flagString, positionals } from "./args.js";
|
|
5
5
|
import { CliError, UsageError } from "./errors.js";
|
|
6
6
|
import { autoUpdateStatus, custodyNode, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
|
|
7
|
-
import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, } from "./runner-update.js";
|
|
8
|
-
import { dockerFleetUpdateEngine, postRollLines, readPostRollResult, runFleetPostRollCheck, runFleetUpdate } from "./runner-update-fleet.js";
|
|
7
|
+
import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, lastProblem, lastProblemLines, drainLines, custodyUnattested, CUSTODY_UNATTESTED_LINE, releaseUpdateDrain, } from "./runner-update.js";
|
|
8
|
+
import { dockerFleetUpdateEngine, postRollLines, readPostRollResult, runFleetPostRollCheck, runFleetUpdate, } from "./runner-update-fleet.js";
|
|
9
9
|
import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
|
|
10
10
|
import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-policy.js";
|
|
11
11
|
import { resolveClaude } from "./update-plugin.js";
|
|
@@ -69,6 +69,18 @@ function statusLine(status) {
|
|
|
69
69
|
return "Automatic runner updates need your systemd user manager, which is not running for you. Run: sudo loginctl enable-linger $USER, then catalyst runner auto-update on";
|
|
70
70
|
return `Automatic runner updates are not available on this machine (${status.detail}). Run catalyst runner update yourself.`;
|
|
71
71
|
}
|
|
72
|
+
function autoLine(result, at) {
|
|
73
|
+
return `${at.toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`;
|
|
74
|
+
}
|
|
75
|
+
function drainReleaseLine(result) {
|
|
76
|
+
if (result === "released")
|
|
77
|
+
return "Catalyst can send this machine new phases again: it had stopped sending them so the machine could update.";
|
|
78
|
+
if (result === "pending")
|
|
79
|
+
return "The update that was waiting for running phases ends within a few seconds, and Catalyst can then send this machine new phases again.";
|
|
80
|
+
if (result === "failed")
|
|
81
|
+
return "Catalyst may still be holding new phases back from this machine. Run catalyst runner auto-update off again to retry.";
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
72
84
|
/** Why this machine's images must not move now: an owner's hold, or settings that cannot be read
|
|
73
85
|
* (which never count as "everything on"). Null when every image may move. */
|
|
74
86
|
function imageHold(ctx) {
|
|
@@ -145,7 +157,9 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
145
157
|
if (args.json)
|
|
146
158
|
ctx.stdout(JSON.stringify(extra ? { ...result, components: extra.rows } : result));
|
|
147
159
|
else if (auto) {
|
|
148
|
-
ctx.stdout(
|
|
160
|
+
ctx.stdout(autoLine(result, ctx.now()));
|
|
161
|
+
if (custodyUnattested(result))
|
|
162
|
+
ctx.stdout(CUSTODY_UNATTESTED_LINE);
|
|
149
163
|
if (extra)
|
|
150
164
|
ctx.stdout(extra.line);
|
|
151
165
|
}
|
|
@@ -160,13 +174,27 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
160
174
|
// windows; then only in a window. An owner's own run applies at once (CTC-7465, update-windows.ts).
|
|
161
175
|
if (hold !== null) {
|
|
162
176
|
const retained = { outcome: "retained", detail: hold, generation: readReleaseState(dir()).generation, exitCode: hold === "owner_setting" ? 0 : 1 };
|
|
177
|
+
if (hold === "owner_setting" && !fleet) {
|
|
178
|
+
const released = await releaseUpdateDrain(engine, dir(), { cloud: false });
|
|
179
|
+
if (released === "failed" && !auto && !args.json)
|
|
180
|
+
ctx.stdout(drainReleaseLine(released));
|
|
181
|
+
}
|
|
163
182
|
// Unreadable settings move nothing; a hold leaves the other components to the release check.
|
|
164
183
|
return print(retained, hold === "owner_setting" ? await components(undefined) : null);
|
|
165
184
|
}
|
|
166
185
|
const ownerWindows = auto && !check && !fleet ? readUpdatePolicy(ctx.home).windows : undefined;
|
|
186
|
+
// A fleet supervisor that misses its qualification deadline names each scope it had not qualified,
|
|
187
|
+
// and why, on stderr before the restore, so stdout keeps its one line or JSON object (CTC-8163).
|
|
167
188
|
const result = fleet
|
|
168
|
-
? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), {
|
|
169
|
-
|
|
189
|
+
? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), {
|
|
190
|
+
dir: dir(), check, ...deps.fleetClock,
|
|
191
|
+
onQualificationPending: (line) => ctx.stderr(`catalyst: ${line}`),
|
|
192
|
+
})
|
|
193
|
+
: await runRunnerUpdate(engine, {
|
|
194
|
+
dir: dir(), check,
|
|
195
|
+
...(ownerWindows ? { window: windowBounds(ctx.now().getTime(), ownerWindows) } : {}),
|
|
196
|
+
...(auto && !check ? { drain: true, held: () => imageHold(ctx), ...(args.json ? {} : { onPass: (pass) => ctx.stdout(autoLine(pass, ctx.now())) }) } : {}),
|
|
197
|
+
});
|
|
170
198
|
// `runner update --check` also lists every installed component against the approved release, as
|
|
171
199
|
// `catalyst update --check` does. The image fields stay where scripts already read them.
|
|
172
200
|
if (check && !fleet && !auto) {
|
|
@@ -190,10 +218,10 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
190
218
|
const state = readReleaseState(dir());
|
|
191
219
|
if (args.json) {
|
|
192
220
|
const { schemaVersion, release, components, projects } = updateReportJson(report);
|
|
193
|
-
ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects, lastProblem: lastProblem(state) }));
|
|
221
|
+
ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects, lastProblem: lastProblem(state), drain: state.drain === null ? null : { generation: state.drain.generation, acknowledgedAtMs: state.drain.acknowledgedAtMs }, deferral: state.deferral }));
|
|
194
222
|
}
|
|
195
223
|
else
|
|
196
|
-
for (const line of [...updateResultLines(result), ...lastProblemLines(state), ...updateReportLines(report)])
|
|
224
|
+
for (const line of [...updateResultLines(result), ...lastProblemLines(state), ...drainLines(state), ...updateReportLines(report)])
|
|
197
225
|
ctx.stdout(line);
|
|
198
226
|
return result.exitCode;
|
|
199
227
|
}
|
|
@@ -229,12 +257,25 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
229
257
|
if (mode === "off" || (mode === "on" && status.state === "on"))
|
|
230
258
|
for (const id of RELEASE_COMPONENTS)
|
|
231
259
|
setComponentPolicy(ctx.home, id, mode === "on");
|
|
260
|
+
let updateDrain = "none";
|
|
261
|
+
if (mode === "off" && !fleet) {
|
|
262
|
+
let runner = null;
|
|
263
|
+
try {
|
|
264
|
+
runner = dir();
|
|
265
|
+
}
|
|
266
|
+
catch { /* No runner directory to release. */ }
|
|
267
|
+
if (runner !== null && existsSync(resolve(runner, ".env")) && existsSync(resolve(runner, "compose.yaml")))
|
|
268
|
+
updateDrain = await releaseUpdateDrain(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), runner, { cloud: true });
|
|
269
|
+
}
|
|
232
270
|
if (args.json) {
|
|
233
271
|
const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
|
|
234
|
-
ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
|
|
272
|
+
ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}), ...(mode === "off" ? { updateDrain } : {}) }));
|
|
235
273
|
}
|
|
236
274
|
else {
|
|
237
275
|
ctx.stdout(statusLine(status));
|
|
276
|
+
const line = mode === "off" ? drainReleaseLine(updateDrain) : null;
|
|
277
|
+
if (line !== null)
|
|
278
|
+
ctx.stdout(line);
|
|
238
279
|
// A single runner's schedule prints what it always has; a fleet's says so, and for which directory.
|
|
239
280
|
if (status.state === "on" && status.fleet)
|
|
240
281
|
ctx.stdout(`This is a supervisor fleet's schedule: it updates the compose.json in ${status.dir}.`);
|
|
@@ -23,23 +23,45 @@ import { decideRelease, DESIRED_RELEASE_SCRIPT, HOST_ID, isRecord, readReleaseSt
|
|
|
23
23
|
// back and the supervisor is recreated on it. That scope set is saved before the recreate and removed
|
|
24
24
|
// once it qualifies or rolls back, so a run killed in between is finished by the next one.
|
|
25
25
|
//
|
|
26
|
+
// A busy host re-adopts its live sessions before it qualifies, which can take longer than four minutes
|
|
27
|
+
// (CTC-8163). Each recreated supervisor, the new release's or the restored previous one's, gets four
|
|
28
|
+
// minutes, extended to 150 seconds past each new step it logs: fleet tenure, then a saved scope
|
|
29
|
+
// re-adopted, then that scope ready. No supervisor gets more than seven minutes. A restart, another
|
|
30
|
+
// image or `host_not_ready` still ends the wait at the next poll. At the deadline each saved scope it
|
|
31
|
+
// has not qualified is reported, with the gate condition its latest log line shows failing.
|
|
32
|
+
//
|
|
33
|
+
// A team can stop being delegated while an update is in flight (CTC-7569): no supervisor, new or old,
|
|
34
|
+
// starts its scope again. The previous release, restored, is the control. When it misses exactly the
|
|
35
|
+
// scopes the new supervisor missed and qualifies the rest, those scopes are dropped from the saved set
|
|
36
|
+
// and the new release is applied again against what is left. A later run that finds a saved set beside
|
|
37
|
+
// the previous release's running supervisor drops the saved scopes that supervisor no longer qualifies.
|
|
38
|
+
// Either way the result names each dropped scope; the new release's own log never drops one.
|
|
39
|
+
//
|
|
26
40
|
// A release can qualify and then stop receiving work minutes later (CTC-6564). A successful apply
|
|
27
41
|
// arms a check on the host, outside the calling process, for 10 minutes later: it reads the supervisor's
|
|
28
42
|
// log since the roll began and, when the release was granted no work and the fleet's acquires got no
|
|
29
43
|
// answer, restores the saved previous `compose.json`. `runner update --fleet --post-roll-check` is
|
|
30
44
|
// that check; only the armed timer runs it.
|
|
45
|
+
// A check that is due while an update holds the lock judges nothing; that update arms it again
|
|
46
|
+
// for 5 minutes after it ends (CTC-7570).
|
|
31
47
|
export const FLEET_COMPOSE_FILE = "compose.json";
|
|
32
48
|
export const PREVIOUS_COMPOSE_FILE = "compose.release-previous.json";
|
|
33
49
|
const CANDIDATE_COMPOSE_FILE = "compose.release-candidate.json";
|
|
34
50
|
/** The scopes the supervisor served before a recreate, kept until that recreate is qualified or rolled
|
|
35
51
|
* back. A run that was killed in between finds it and qualifies against it, never against the new
|
|
36
|
-
* container's own log, which would read an unqualified supervisor as serving.
|
|
52
|
+
* container's own log, which would read an unqualified supervisor as serving. Only the previous
|
|
53
|
+
* release's supervisor can take a scope out of it (CTC-7569). */
|
|
37
54
|
export const IN_FLIGHT_FILE = "fleet-update-in-flight.json";
|
|
38
55
|
/** How far back a qualification counts as current before the update. The supervisor repeats it
|
|
39
56
|
* on every loop while a scope is ready, so a ready scope logs it many times in this window. */
|
|
40
57
|
const QUALIFIED_LOOKBACK_MS = 5 * 60_000;
|
|
41
58
|
const QUALIFY_MIN_MS = 90_000;
|
|
59
|
+
/** How long a recreated supervisor that shows no progress has to qualify every saved scope. */
|
|
42
60
|
const QUALIFY_DEADLINE_MS = 240_000;
|
|
61
|
+
/** How long past each new step of progress a recreated supervisor has to qualify (CTC-8163). */
|
|
62
|
+
const QUALIFY_PROGRESS_MS = 150_000;
|
|
63
|
+
/** The most one recreated supervisor is given, however it progresses (CTC-8163). */
|
|
64
|
+
const QUALIFY_CEILING_MS = 420_000;
|
|
43
65
|
const QUALIFY_POLL_MS = 10_000;
|
|
44
66
|
const CHILD_NAME_PREFIX = "catalyst-pa_";
|
|
45
67
|
/** What a successful apply leaves for the check: the bytes it wrote and when the roll began. */
|
|
@@ -47,6 +69,8 @@ export const POST_ROLL_ARMED_FILE = "post-roll-armed.json";
|
|
|
47
69
|
/** The check's last verdict, beside `release-state.json`. */
|
|
48
70
|
export const POST_ROLL_RESULT_FILE = "post-roll-result.json";
|
|
49
71
|
export const POST_ROLL_DELAY_MS = 10 * 60_000;
|
|
72
|
+
/** How long after an update run lets go of the lock a check it kept from judging runs again (CTC-7570). */
|
|
73
|
+
export const POST_ROLL_RETRY_DELAY_MS = 5 * 60_000;
|
|
50
74
|
const POST_ROLL_UNIT = "catalyst-fleet-post-roll";
|
|
51
75
|
/** No-response acquire retries, with no grants, that count a release as failing. */
|
|
52
76
|
const POST_ROLL_MAX_NO_RESPONSE = 3;
|
|
@@ -91,14 +115,83 @@ export function qualifiedScopes(text) {
|
|
|
91
115
|
/** A `host_not_ready` line for one of `scopes`, or one that names no scope. Other scopes may be
|
|
92
116
|
* unready for their own reasons (a team with no repository yet) and do not stop an update. */
|
|
93
117
|
export function notReadyScope(text, scopes) {
|
|
118
|
+
return readSupervisorLog(text, scopes).notReady;
|
|
119
|
+
}
|
|
120
|
+
const GATE_CODES = [
|
|
121
|
+
["resourcesReady", "resources_ready"],
|
|
122
|
+
["scopeEligible", "scope_eligible"],
|
|
123
|
+
["readyUntilValid", "ready_until"],
|
|
124
|
+
["sessionsReadopted", "sessions_readopted"],
|
|
125
|
+
];
|
|
126
|
+
/** One pass over the log text for everything qualify() reads. Lines for other scopes, and lines that
|
|
127
|
+
* are not JSON or lack a field, are skipped. */
|
|
128
|
+
function readSupervisorLog(text, scopes) {
|
|
129
|
+
const log = { notReady: null, qualified: new Set(), tenure: false, readopted: new Set(), ready: new Set(), gates: new Map() };
|
|
94
130
|
for (const event of logEvents(text)) {
|
|
95
|
-
if (event.event !== "host_not_ready")
|
|
96
|
-
continue;
|
|
97
131
|
const key = scopeKey(event);
|
|
98
|
-
if (key === null || scopes.has(key))
|
|
99
|
-
|
|
132
|
+
if (event.event === "host_not_ready" && log.notReady === null && (key === null || scopes.has(key)))
|
|
133
|
+
log.notReady = key ?? "unscoped";
|
|
134
|
+
if (event.event === "supervisor_fleet_tenure")
|
|
135
|
+
log.tenure = true;
|
|
136
|
+
if (key === null || !scopes.has(key))
|
|
137
|
+
continue;
|
|
138
|
+
if (event.event === "supervisor_scope_qualified")
|
|
139
|
+
log.qualified.add(key);
|
|
140
|
+
else if (event.event === "supervisor_readopted")
|
|
141
|
+
log.readopted.add(key);
|
|
142
|
+
else if (event.event === "host_ready")
|
|
143
|
+
log.ready.add(key);
|
|
144
|
+
else if (event.event === "supervisor_scope_qualification_state") {
|
|
145
|
+
const { resourcesReady, scopeEligible, readyUntilValid, sessionsReadopted } = event;
|
|
146
|
+
if (typeof resourcesReady === "boolean" && typeof scopeEligible === "boolean" && typeof readyUntilValid === "boolean" && typeof sessionsReadopted === "boolean")
|
|
147
|
+
log.gates.set(key, { resourcesReady, scopeEligible, readyUntilValid, sessionsReadopted });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return log;
|
|
151
|
+
}
|
|
152
|
+
/** Count the steps `log` shows that `budget` has not counted yet: fleet tenure, a scope re-adopted, and
|
|
153
|
+
* a re-adopted scope ready. A scope ready before its re-adoption shows counts once that does. Any new
|
|
154
|
+
* step moves the deadline to `QUALIFY_PROGRESS_MS` past `atMs`, never earlier and never past the ceiling. */
|
|
155
|
+
function countProgress(budget, log, atMs) {
|
|
156
|
+
let progressed = false;
|
|
157
|
+
if (log.tenure && !budget.tenure) {
|
|
158
|
+
budget.tenure = true;
|
|
159
|
+
progressed = true;
|
|
160
|
+
}
|
|
161
|
+
for (const scope of log.readopted) {
|
|
162
|
+
if (budget.readopted.has(scope))
|
|
163
|
+
continue;
|
|
164
|
+
budget.readopted.add(scope);
|
|
165
|
+
progressed = true;
|
|
166
|
+
}
|
|
167
|
+
for (const scope of log.ready) {
|
|
168
|
+
if (!budget.readopted.has(scope) || budget.ready.has(scope))
|
|
169
|
+
continue;
|
|
170
|
+
budget.ready.add(scope);
|
|
171
|
+
progressed = true;
|
|
100
172
|
}
|
|
101
|
-
|
|
173
|
+
if (progressed)
|
|
174
|
+
budget.deadline = Math.min(budget.ceiling, Math.max(budget.deadline, atMs + QUALIFY_PROGRESS_MS));
|
|
175
|
+
}
|
|
176
|
+
/** The longest one recreated supervisor is given to qualify, however it progresses. */
|
|
177
|
+
const qualifyCeilingMs = (options) => Math.max(QUALIFY_CEILING_MS, options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS);
|
|
178
|
+
/** A scope as printed: anything outside printable ASCII, and the backslash that starts an escape,
|
|
179
|
+
* escaped as `\uXXXX`, and at most 120 characters. */
|
|
180
|
+
const printableScope = (scope) => {
|
|
181
|
+
const escaped = scope.replace(/[^\x21-\x5b\x5d-\x7e]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
182
|
+
return escaped.length > 120 ? `${escaped.slice(0, 120)}...` : escaped;
|
|
183
|
+
};
|
|
184
|
+
/** The gate conditions a missing scope's snapshot shows failing, sorted; `diagnostic_unavailable`
|
|
185
|
+
* without one, and `qualification_event_missing` when every condition held. */
|
|
186
|
+
function failedGates(snapshot) {
|
|
187
|
+
if (snapshot === undefined)
|
|
188
|
+
return ["diagnostic_unavailable"];
|
|
189
|
+
const failed = GATE_CODES.filter(([field]) => !snapshot[field]).map(([, code]) => code).sort();
|
|
190
|
+
return failed.length > 0 ? failed : ["qualification_event_missing"];
|
|
191
|
+
}
|
|
192
|
+
/** One line per scope in `missing`, sorted by scope. `gates` null: the log could not be read. */
|
|
193
|
+
function qualificationPendingLines(roll, missing, gates) {
|
|
194
|
+
return [...missing].sort().map((scope) => `fleet qualification pending: roll=${roll} scope=${printableScope(scope)} failed=${failedGates(gates?.get(scope)).join(",")}`);
|
|
102
195
|
}
|
|
103
196
|
function readInFlight(dir) {
|
|
104
197
|
try {
|
|
@@ -112,6 +205,10 @@ function readInFlight(dir) {
|
|
|
112
205
|
return null;
|
|
113
206
|
}
|
|
114
207
|
}
|
|
208
|
+
/** Save the scopes the next recreate has to qualify, replacing any saved before. */
|
|
209
|
+
function saveInFlight(dir, scopes, atMs) {
|
|
210
|
+
writePrivateAtomic(join(dir, IN_FLIGHT_FILE), `${JSON.stringify({ version: 1, scopes: [...scopes].sort(), atMs })}\n`);
|
|
211
|
+
}
|
|
115
212
|
function clearInFlight(dir) {
|
|
116
213
|
try {
|
|
117
214
|
unlinkSync(join(dir, IN_FLIGHT_FILE));
|
|
@@ -120,6 +217,17 @@ function clearInFlight(dir) {
|
|
|
120
217
|
// Already gone.
|
|
121
218
|
}
|
|
122
219
|
}
|
|
220
|
+
/** The images a saved `compose.json` names, or null when there is none or it is not a fleet layout. */
|
|
221
|
+
function savedImages(path) {
|
|
222
|
+
try {
|
|
223
|
+
return fleetImages(JSON.parse(readFileSync(path, "utf8")));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The detail token naming scopes dropped from the requirement, sorted; empty when there are none. */
|
|
230
|
+
const droppedDetail = (scopes) => scopes.length > 0 ? `;dropped_scope:${[...scopes].sort().join(",")}` : "";
|
|
123
231
|
const removeFile = (path) => {
|
|
124
232
|
try {
|
|
125
233
|
unlinkSync(path);
|
|
@@ -226,6 +334,23 @@ async function armPostRollCheck(engine, dir, armed, signal) {
|
|
|
226
334
|
return ";post_roll_check_not_armed";
|
|
227
335
|
}
|
|
228
336
|
}
|
|
337
|
+
/** The armed check runs once, from a one-shot timer. When it was due while an update held the lock, or
|
|
338
|
+
* Docker could not be read, it judged nothing and left the armed file; a reboot loses the timer the same
|
|
339
|
+
* way (CTC-7570). The update holding the lock arms it again for a few minutes later, before it lets go:
|
|
340
|
+
* while the lock is held no check is part-way through a judgment, so the arm's stop of the check's unit
|
|
341
|
+
* interrupts nothing. The check cannot arm itself: that stop would end its own unit. A check this run
|
|
342
|
+
* armed is not due yet and is left alone. */
|
|
343
|
+
async function rearmOverdueCheck(engine, dir, now, signal) {
|
|
344
|
+
const armed = readPostRollArmed(dir);
|
|
345
|
+
if (armed === null || now() < armed.armedAtMs + POST_ROLL_DELAY_MS)
|
|
346
|
+
return;
|
|
347
|
+
try {
|
|
348
|
+
await engine.armPostRollCheck(dir, POST_ROLL_RETRY_DELAY_MS, signal);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// The next run tries again.
|
|
352
|
+
}
|
|
353
|
+
}
|
|
229
354
|
export async function runFleetUpdate(engine, options) {
|
|
230
355
|
const { dir } = options;
|
|
231
356
|
const now = options.now ?? Date.now;
|
|
@@ -243,8 +368,10 @@ export async function runFleetUpdate(engine, options) {
|
|
|
243
368
|
return result;
|
|
244
369
|
}
|
|
245
370
|
finally {
|
|
246
|
-
if (lock !== null)
|
|
371
|
+
if (lock !== null) {
|
|
372
|
+
await rearmOverdueCheck(engine, dir, now, options.signal);
|
|
247
373
|
releaseLock(dir, lock);
|
|
374
|
+
}
|
|
248
375
|
}
|
|
249
376
|
}
|
|
250
377
|
async function update(engine, options, now) {
|
|
@@ -291,6 +418,8 @@ async function update(engine, options, now) {
|
|
|
291
418
|
// The scopes the running supervisor qualifies are what the new one has to qualify again, unless an
|
|
292
419
|
// earlier run stopped mid-recreate: then the scopes it saved are the baseline.
|
|
293
420
|
let scopes = inFlight;
|
|
421
|
+
/** Saved scopes the previous release's supervisor no longer qualifies, named in the result (CTC-7569). */
|
|
422
|
+
let dropped = [];
|
|
294
423
|
if (scopes === null) {
|
|
295
424
|
if (!live?.running)
|
|
296
425
|
return done("retained", "supervisor_not_running", { ...tagged, exitCode: 1 });
|
|
@@ -301,6 +430,25 @@ async function update(engine, options, now) {
|
|
|
301
430
|
if (scopes.size === 0)
|
|
302
431
|
return done("retained", "no_qualified_scope", { ...tagged, exitCode: 1 });
|
|
303
432
|
}
|
|
433
|
+
else {
|
|
434
|
+
// An earlier run that ended back on the previous release, its rollback unqualified, left its scopes
|
|
435
|
+
// saved. Only when compose.json and the running supervisor are that previous release, up long enough
|
|
436
|
+
// to have qualified every scope it serves, does its log trim the saved set: a saved scope it no
|
|
437
|
+
// longer qualifies stopped being delegated. A `host_not_ready` for a saved scope is a scope still
|
|
438
|
+
// started, not one dropped. Anything less keeps the saved set whole. Long enough is the most any
|
|
439
|
+
// recreated supervisor is given, since this run cannot see how it progressed (CTC-8163).
|
|
440
|
+
const previous = savedImages(join(dir, PREVIOUS_COMPOSE_FILE));
|
|
441
|
+
const onPrevious = previous !== null && current.runner === previous.runner && current.supervisor === previous.supervisor && runs(previous);
|
|
442
|
+
if (live && onPrevious && now() - Date.parse(live.startedAt) >= qualifyCeilingMs(options)) {
|
|
443
|
+
const recent = await engine.logs(live.id, new Date(now() - QUALIFIED_LOOKBACK_MS).toISOString(), signal);
|
|
444
|
+
const serving = recent === null || notReadyScope(recent, scopes) !== null ? new Set() : qualifiedScopes(recent);
|
|
445
|
+
const kept = [...scopes].filter((scope) => serving.has(scope));
|
|
446
|
+
if (kept.length > 0 && kept.length < scopes.size) {
|
|
447
|
+
dropped = [...scopes].filter((scope) => !serving.has(scope));
|
|
448
|
+
scopes = new Set(kept);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
304
452
|
const record = (outcome, detail, previous, exitCode = 0) => {
|
|
305
453
|
writeReleaseState(dir, {
|
|
306
454
|
...state,
|
|
@@ -353,9 +501,15 @@ async function update(engine, options, now) {
|
|
|
353
501
|
const rollStartMs = now();
|
|
354
502
|
if (changed.length > 0)
|
|
355
503
|
writePrivateAtomic(join(dir, PREVIOUS_COMPOSE_FILE), composeText);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
504
|
+
saveInFlight(dir, scopes, now());
|
|
505
|
+
/** Write `text` as compose.json, recreate the supervisor alone on it and qualify it against `required`. */
|
|
506
|
+
const roll = async (which, text, images, required) => {
|
|
507
|
+
writePrivateAtomic(composePath, text);
|
|
508
|
+
if (!(await engine.recreate(dir, project, signal)))
|
|
509
|
+
return { ok: false, reason: "recreate_failed" };
|
|
510
|
+
return qualify(engine, dir, project, images, required, options, now, which);
|
|
511
|
+
};
|
|
512
|
+
const succeed = async (droppedScopes) => {
|
|
359
513
|
clearInFlight(dir);
|
|
360
514
|
if (changed.length > 0)
|
|
361
515
|
await engine.removeImages(changed.map((c) => current[c]), signal);
|
|
@@ -371,20 +525,34 @@ async function update(engine, options, now) {
|
|
|
371
525
|
version: 1, sinceMs: rollStartMs, composeSha256: sha256(candidateText), generation: release.generation,
|
|
372
526
|
releaseId: release.releaseId, fromGeneration: state.generation, armedAtMs: now(),
|
|
373
527
|
};
|
|
374
|
-
const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") +
|
|
528
|
+
const detail = (changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled") + droppedDetail(droppedScopes) +
|
|
529
|
+
await armPostRollCheck(engine, dir, armed, signal);
|
|
375
530
|
const previous = changed.length > 0 ? current : null;
|
|
376
531
|
return disturbed === 0
|
|
377
532
|
? record("updated", detail, previous)
|
|
378
533
|
: record("updated", `${detail};${disturbed < 0 ? "children_unreadable" : `children_changed:${disturbed}`}`, previous, 1);
|
|
379
|
-
}
|
|
534
|
+
};
|
|
535
|
+
const candidate = await roll("candidate", candidateText, target, scopes);
|
|
536
|
+
if (candidate.ok)
|
|
537
|
+
return succeed(dropped);
|
|
380
538
|
if (rollbackText === null || rollbackImages === null)
|
|
381
|
-
return done("failed",
|
|
382
|
-
|
|
383
|
-
|
|
539
|
+
return done("failed", `new_supervisor_not_qualified_no_previous${droppedDetail(dropped)}`, { ...tagged, exitCode: 1 });
|
|
540
|
+
let restored = await roll("rollback", rollbackText, rollbackImages, scopes);
|
|
541
|
+
// The previous release is the control (CTC-7569). Missing exactly the scopes the candidate missed while
|
|
542
|
+
// qualifying the rest, it shows those scopes stopped being delegated, not that the candidate broke them:
|
|
543
|
+
// the candidate is applied again without them, and recorded only once it qualifies what is left.
|
|
544
|
+
const gone = lostDelegation(candidate, restored, scopes);
|
|
545
|
+
if (gone !== null) {
|
|
546
|
+
const kept = new Set([...scopes].filter((scope) => !gone.has(scope)));
|
|
547
|
+
saveInFlight(dir, kept, now());
|
|
548
|
+
if ((await roll("candidate", candidateText, target, kept)).ok)
|
|
549
|
+
return succeed([...dropped, ...gone]);
|
|
550
|
+
restored = await roll("rollback", rollbackText, rollbackImages, kept);
|
|
551
|
+
}
|
|
384
552
|
// A rollback that did not qualify keeps the baseline for the next run.
|
|
385
|
-
if (restored)
|
|
553
|
+
if (restored.ok)
|
|
386
554
|
clearInFlight(dir);
|
|
387
|
-
return done(restored ? "rolled_back" : "failed", restored ? "new_supervisor_not_qualified" : "rollback_not_qualified"
|
|
555
|
+
return done(restored.ok ? "rolled_back" : "failed", `${restored.ok ? "new_supervisor_not_qualified" : "rollback_not_qualified"}${droppedDetail(dropped)}`, { ...tagged, exitCode: 1 });
|
|
388
556
|
}
|
|
389
557
|
/** Put `text` back as `compose.json` and recreate the supervisor alone on it. */
|
|
390
558
|
async function restoreCompose(engine, dir, project, text, signal) {
|
|
@@ -461,6 +629,11 @@ export async function runFleetPostRollCheck(engine, options) {
|
|
|
461
629
|
}
|
|
462
630
|
if (previous === null)
|
|
463
631
|
return refuse("no_previous_compose");
|
|
632
|
+
// An update that started from this release and did not finish saved this release's own file as the
|
|
633
|
+
// previous one (CTC-7570). Restoring it would move nothing and record a rollback that never happened.
|
|
634
|
+
const current = fleetImages(compose);
|
|
635
|
+
if (current !== null && previous.runner === current.runner && previous.supervisor === current.supervisor)
|
|
636
|
+
return refuse("previous_compose_is_this_release");
|
|
464
637
|
// The apply removed the images it replaced, so the previous ones may need pulling again.
|
|
465
638
|
const unprepared = await engine.prepare([previous.runner, previous.supervisor], signal);
|
|
466
639
|
if (unprepared !== null)
|
|
@@ -478,37 +651,69 @@ export async function runFleetPostRollCheck(engine, options) {
|
|
|
478
651
|
}
|
|
479
652
|
}
|
|
480
653
|
/** The recreated supervisor runs `images`, stays up with no restart, logs no `host_not_ready` for a
|
|
481
|
-
* scope it must serve, and qualifies every one of `scopes`, held for the minimum window.
|
|
482
|
-
|
|
654
|
+
* scope it must serve, and qualifies every one of `scopes`, held for the minimum window. Its deadline
|
|
655
|
+
* moves with the progress it logs (CTC-8163); the restart, image and `host_not_ready` exits come first. */
|
|
656
|
+
async function qualify(engine, dir, project, images, scopes, options, now, roll) {
|
|
483
657
|
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
484
658
|
const start = now();
|
|
485
|
-
const
|
|
659
|
+
const budget = {
|
|
660
|
+
deadline: start + (options.qualifyDeadlineMs ?? QUALIFY_DEADLINE_MS),
|
|
661
|
+
ceiling: start + qualifyCeilingMs(options),
|
|
662
|
+
tenure: false,
|
|
663
|
+
readopted: new Set(),
|
|
664
|
+
ready: new Set(),
|
|
665
|
+
};
|
|
486
666
|
const minimum = start + (options.qualifyMinMs ?? QUALIFY_MIN_MS);
|
|
487
667
|
let id = null;
|
|
488
668
|
for (;;) {
|
|
489
669
|
if (options.signal?.aborted)
|
|
490
|
-
return false;
|
|
670
|
+
return { ok: false, reason: "aborted" };
|
|
491
671
|
const live = await engine.supervisor(dir, project, options.signal);
|
|
672
|
+
// What this poll's log has not qualified yet, and its gate lines; null when the supervisor or its
|
|
673
|
+
// log could not be read.
|
|
674
|
+
let missing = null;
|
|
675
|
+
let gates = null;
|
|
492
676
|
if (live) {
|
|
493
677
|
id ??= live.id;
|
|
494
678
|
if (live.id !== id || !live.running || live.restartCount !== 0)
|
|
495
|
-
return false;
|
|
679
|
+
return { ok: false, reason: "restarted" };
|
|
496
680
|
if (live.image !== images.supervisor || live.runnerImage !== images.runner)
|
|
497
|
-
return false;
|
|
681
|
+
return { ok: false, reason: "wrong_image" };
|
|
498
682
|
const text = await engine.logs(live.id, live.startedAt, options.signal);
|
|
499
683
|
if (text !== null) {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
684
|
+
const log = readSupervisorLog(text, scopes);
|
|
685
|
+
if (log.notReady !== null)
|
|
686
|
+
return { ok: false, reason: "host_not_ready" };
|
|
687
|
+
missing = new Set([...scopes].filter((scope) => !log.qualified.has(scope)));
|
|
688
|
+
if (now() >= minimum && missing.size === 0)
|
|
689
|
+
return { ok: true };
|
|
690
|
+
countProgress(budget, log, now());
|
|
691
|
+
gates = log.gates;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (now() >= budget.deadline) {
|
|
695
|
+
for (const line of qualificationPendingLines(roll, missing ?? scopes, gates)) {
|
|
696
|
+
try {
|
|
697
|
+
options.onQualificationPending?.(line);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
// A line that could not be written never holds up the other lines or the restore.
|
|
701
|
+
}
|
|
505
702
|
}
|
|
703
|
+
return missing !== null && missing.size > 0 ? { ok: false, reason: "missing", missing } : { ok: false, reason: "deadline" };
|
|
506
704
|
}
|
|
507
|
-
if (now() >= deadline)
|
|
508
|
-
return false;
|
|
509
705
|
await sleep(QUALIFY_POLL_MS);
|
|
510
706
|
}
|
|
511
707
|
}
|
|
708
|
+
/** The scopes to drop when the candidate and the previous release, its control, both failed only by
|
|
709
|
+
* missing scopes, the same ones, and the control qualified at least one other `required` scope. Null
|
|
710
|
+
* otherwise: then the candidate is what failed, or nothing shows which. */
|
|
711
|
+
function lostDelegation(candidate, control, required) {
|
|
712
|
+
if (candidate.ok || control.ok || candidate.reason !== "missing" || control.reason !== "missing")
|
|
713
|
+
return null;
|
|
714
|
+
const same = candidate.missing.size === control.missing.size && [...candidate.missing].every((scope) => control.missing.has(scope));
|
|
715
|
+
return same && control.missing.size < required.size ? control.missing : null;
|
|
716
|
+
}
|
|
512
717
|
const CONTAINER_ID = /^[0-9a-f]{12,64}$/;
|
|
513
718
|
/** The one volume or bind in `service.volumes` that holds `file`, as a read-only `--mount` value. */
|
|
514
719
|
export function credentialMount(compose, service, file) {
|