@bli-cockpit/cli 0.2.94 → 0.2.96
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/dist/commands/heartbeat-token.js +80 -0
- package/dist/commands/heartbeat.js +24 -12
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup-receipt-lines.js +17 -0
- package/dist/commands/sync-followups-autostart.js +99 -0
- package/dist/commands/sync-followups-memory.js +123 -0
- package/dist/commands/sync-followups-self-update.js +127 -0
- package/dist/commands/sync-followups-staging.js +202 -0
- package/dist/commands/sync-followups.js +30 -511
- package/dist/commands/sync-heartbeat.js +36 -0
- package/dist/commands/sync-receipt.js +138 -0
- package/dist/commands/sync-report.js +153 -0
- package/dist/commands/sync-roots.js +28 -0
- package/dist/commands/sync-run.js +52 -0
- package/dist/commands/sync-types.js +1 -0
- package/dist/commands/sync.js +94 -357
- package/dist/disk-usage-classify.js +109 -0
- package/dist/disk-usage-facts.js +4 -0
- package/dist/disk-usage-files.js +99 -0
- package/dist/disk-usage-footprint.js +45 -0
- package/dist/disk-usage-ledger.js +49 -0
- package/dist/disk-usage-scan.js +80 -0
- package/dist/disk-usage-totals.js +83 -0
- package/dist/disk-usage.js +33 -396
- package/dist/local-state-pairing.js +41 -1
- package/dist/local-state.js +2 -2
- package/dist/upload-envelope-build.js +7 -0
- package/package.json +4 -4
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describeError } from "../health-detail.js";
|
|
2
|
+
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, recordDeviceTokenExpiry, toSessionReference, } from "../local-state.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read the session and decide whether this tick may check in.
|
|
5
|
+
*
|
|
6
|
+
* The check-in goes out while the machine believes it is EXPIRED, as long as it
|
|
7
|
+
* still holds a device token. That is not a loosening: the SERVER decides
|
|
8
|
+
* whether a token is accepted, and a collector that refused to knock could
|
|
9
|
+
* never be let back in. Uploads are unaffected — they still gate on `valid`
|
|
10
|
+
* (`upload-envelope-build.ts`), so a machine past the grace window spends no
|
|
11
|
+
* bandwidth on evidence that will be refused.
|
|
12
|
+
*
|
|
13
|
+
* Both refusing branches say something, because "the dashboard shows this
|
|
14
|
+
* machine as quiet" has two very different causes.
|
|
15
|
+
*/
|
|
16
|
+
export async function readHeartbeatSession(homeDir) {
|
|
17
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
18
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
19
|
+
const state = session ? toSessionReference(session).session_state : "missing";
|
|
20
|
+
const usable = state === "valid" || state === "expired";
|
|
21
|
+
if (!session ||
|
|
22
|
+
!usable ||
|
|
23
|
+
typeof session.device_token !== "string" ||
|
|
24
|
+
!session.device_token) {
|
|
25
|
+
console.error("[heartbeat] no usable device session on this machine; the dashboard will show it as quiet", JSON.stringify({
|
|
26
|
+
reason: "no_device_session",
|
|
27
|
+
session_state: state,
|
|
28
|
+
next_action: "run `cockpit do-everything` to pair this machine again",
|
|
29
|
+
}));
|
|
30
|
+
return { usable: false };
|
|
31
|
+
}
|
|
32
|
+
if (state === "expired") {
|
|
33
|
+
console.error("[heartbeat] this machine's session looks expired; checking in anyway so the dashboard can renew it", JSON.stringify({
|
|
34
|
+
reason: "session_expired_locally",
|
|
35
|
+
expires_at: session.expires_at ?? null,
|
|
36
|
+
next_action: "the dashboard renews a token that lapsed inside its 30-day grace window on this call",
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
return { usable: true, session, state: state };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Record the `token_expires_at` the heartbeat door answered with.
|
|
43
|
+
*
|
|
44
|
+
* The LOCAL copy is what decides whether this machine even attempts an upload,
|
|
45
|
+
* so a machine the server has just renewed would otherwise go on refusing its
|
|
46
|
+
* own uploads until somebody signed in by hand.
|
|
47
|
+
*
|
|
48
|
+
* A body that is not JSON, or that carries no such field, is not an error and
|
|
49
|
+
* is not logged: an older dashboard answers `{ ok: true }` and a proxy can
|
|
50
|
+
* answer anything. It simply means "this reply said nothing about the expiry",
|
|
51
|
+
* and the machine keeps the date it already holds. Best-effort throughout — a
|
|
52
|
+
* heartbeat never fails a sync.
|
|
53
|
+
*/
|
|
54
|
+
export async function recordHeartbeatTokenExpiry(homeDir, response) {
|
|
55
|
+
const expiresAt = await readTokenExpiry(response);
|
|
56
|
+
if (!expiresAt)
|
|
57
|
+
return;
|
|
58
|
+
await recordDeviceTokenExpiry({
|
|
59
|
+
paths: getCollectorRuntimePaths(homeDir),
|
|
60
|
+
expiresAt,
|
|
61
|
+
}).catch((error) => {
|
|
62
|
+
console.error("[heartbeat] the token expiry the dashboard reported was not recorded", JSON.stringify({
|
|
63
|
+
reason: "token_expiry_writeback_threw",
|
|
64
|
+
...describeError(error),
|
|
65
|
+
}));
|
|
66
|
+
return null;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function readTokenExpiry(response) {
|
|
70
|
+
try {
|
|
71
|
+
const body = (await response.json());
|
|
72
|
+
if (!body || typeof body !== "object")
|
|
73
|
+
return null;
|
|
74
|
+
const value = body["token_expires_at"];
|
|
75
|
+
return typeof value === "string" && value ? value : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -28,7 +28,9 @@ import { readCachedSetupReceipt } from "./setup-receipt.js";
|
|
|
28
28
|
import { readStagingInventory } from "../disk-usage.js";
|
|
29
29
|
import { describeError } from "../health-detail.js";
|
|
30
30
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
31
|
-
import { getCollectorRuntimePaths,
|
|
31
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
32
|
+
import { readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
|
|
33
|
+
import { classifySyncHealthError } from "../sync-health-class.js";
|
|
32
34
|
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
33
35
|
import { readMemoryHookCounts } from "./memory-hook-counts.js";
|
|
34
36
|
import { readMemoryHookPerformance } from "./memory-hook-performance.js";
|
|
@@ -232,18 +234,14 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
232
234
|
}));
|
|
233
235
|
return false;
|
|
234
236
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
console.error("[heartbeat] no valid device session on this machine; the dashboard will show it as quiet", JSON.stringify({
|
|
242
|
-
reason: "no_device_session",
|
|
243
|
-
next_action: "run `cockpit do-everything` to pair this machine again",
|
|
244
|
-
}));
|
|
237
|
+
// BLI-4019. Whether this machine may knock at all, and the two log lines
|
|
238
|
+
// that go with it, live in `heartbeat-token.ts` — the check-in goes out even
|
|
239
|
+
// when the local copy says `expired`, because that is the door the server
|
|
240
|
+
// renews a lapsed token through.
|
|
241
|
+
const reading = await readHeartbeatSession(options.homeDir);
|
|
242
|
+
if (!reading.usable)
|
|
245
243
|
return false;
|
|
246
|
-
}
|
|
244
|
+
const { session } = reading;
|
|
247
245
|
const memory = await readHeartbeatMemoryReceipt({
|
|
248
246
|
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
249
247
|
...(options.now ? { now: options.now } : {}),
|
|
@@ -287,13 +285,27 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
287
285
|
});
|
|
288
286
|
if (!response.ok) {
|
|
289
287
|
// A heartbeat is not retried, so the reason has to be said once, here.
|
|
288
|
+
// BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
|
|
289
|
+
// that decides what a failure is — a 401/403 is `auth_failed` and
|
|
290
|
+
// anything else is not, read off the observed status and never off the
|
|
291
|
+
// words in a message (BLI-3551's rule).
|
|
290
292
|
console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
|
|
291
293
|
reason: "heartbeat_rejected",
|
|
294
|
+
health_class: classifySyncHealthError({ httpStatus: response.status }),
|
|
292
295
|
http_status: response.status,
|
|
293
296
|
root_count: heartbeat.roots.length,
|
|
297
|
+
...(response.status === 401 || response.status === 403
|
|
298
|
+
? {
|
|
299
|
+
next_action: "past the 30-day grace window this needs `cockpit login` on this machine",
|
|
300
|
+
}
|
|
301
|
+
: {}),
|
|
294
302
|
}));
|
|
295
303
|
return false;
|
|
296
304
|
}
|
|
305
|
+
// BLI-4019. The server slides `token_expires_at` out on every call it
|
|
306
|
+
// authenticates and answers with the result; the sibling records it, so
|
|
307
|
+
// this machine's own copy follows the server rather than drifting.
|
|
308
|
+
await recordHeartbeatTokenExpiry(options.homeDir, response);
|
|
297
309
|
console.error("[heartbeat] checked in", JSON.stringify({
|
|
298
310
|
reason: "heartbeat_recorded",
|
|
299
311
|
http_status: response.status,
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.96");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -21,6 +21,20 @@ const FIXES = {
|
|
|
21
21
|
"codex.hooks": "Run `cockpit memory install`.",
|
|
22
22
|
"collector.autostart": "Run `cockpit autostart install`.",
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* A fix keyed on the piece's REASON rather than the piece, for the cases where
|
|
26
|
+
* the same gap has two different next actions (BLI-4019).
|
|
27
|
+
*
|
|
28
|
+
* `device` says "run `cockpit login`" for every lapse, and for an expired
|
|
29
|
+
* session that is now the wrong instruction most of the time: the dashboard
|
|
30
|
+
* slides a device token's expiry out on every call it authenticates and renews
|
|
31
|
+
* one that lapsed inside a 30-day grace window, so the ordinary answer is to do
|
|
32
|
+
* nothing and let the next tick fix it. Telling somebody to sign in again on a
|
|
33
|
+
* machine that is about to heal itself is how a receipt stops being believed.
|
|
34
|
+
*/
|
|
35
|
+
const REASON_FIXES = {
|
|
36
|
+
session_expired: "Renews on the next sync tick while inside the 30-day grace window; past it, run `cockpit login`.",
|
|
37
|
+
};
|
|
24
38
|
/** Codex hooks a person switched OFF is a decision, not a fault. */
|
|
25
39
|
const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
|
|
26
40
|
/**
|
|
@@ -64,6 +78,9 @@ function fixFor(key, piece) {
|
|
|
64
78
|
if (piece.status === "skipped") {
|
|
65
79
|
return "Skipped on purpose; nothing to do.";
|
|
66
80
|
}
|
|
81
|
+
// The reason wins over the piece when it has its own next action.
|
|
82
|
+
if (piece.reason && REASON_FIXES[piece.reason])
|
|
83
|
+
return REASON_FIXES[piece.reason];
|
|
67
84
|
return FIXES[key] ?? "Run `cockpit doctor`.";
|
|
68
85
|
}
|
|
69
86
|
function reasonSuffix(piece) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { resolveAutostartRoots } from "./autostart-command.js";
|
|
2
|
+
import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
3
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
4
|
+
import { runAutostartSelfHeal, } from "../autostart-self-heal.js";
|
|
5
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
6
|
+
/**
|
|
7
|
+
* BLI-2721: after the tick's collection and self-update are done and
|
|
8
|
+
* reported, repair a broken/legacy autostart registration in place (Windows
|
|
9
|
+
* only — see autostart-self-heal.ts for why macOS is excluded). Every error
|
|
10
|
+
* path is swallowed like the self-update's: heal outcomes are their own
|
|
11
|
+
* receipts, never a sync failure.
|
|
12
|
+
*/
|
|
13
|
+
export async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
const rawExec = io.exec;
|
|
17
|
+
if (!rawExec) {
|
|
18
|
+
sayNoProcessRunner();
|
|
19
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, runnerUnavailableOutcome());
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
result = await repairAutostartRegistration(command, io, rawExec);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
result = {
|
|
26
|
+
status: "fail",
|
|
27
|
+
reason: "autostart_self_heal_threw",
|
|
28
|
+
detail: redactedSyncErrorDetail(error),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// Steady state (healthy, absent, non-Windows, no roots) and the daily
|
|
32
|
+
// throttle are silent; an actual repair attempt reports either way.
|
|
33
|
+
if (!result || result.reason === "repair_throttled_recent_attempt")
|
|
34
|
+
return;
|
|
35
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Attempt the repair with a runner that can find node.
|
|
39
|
+
*
|
|
40
|
+
* Every spawn in the scheduled path carries the running node's bin dir on
|
|
41
|
+
* PATH (`envWithNodeRuntimeOnPath`); the scheduler's stripped environment
|
|
42
|
+
* needs it. Returns null when there was nothing to repair.
|
|
43
|
+
*/
|
|
44
|
+
async function repairAutostartRegistration(command, io, rawExec) {
|
|
45
|
+
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
46
|
+
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
47
|
+
return runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
|
|
48
|
+
homeDir: command.homeDir,
|
|
49
|
+
repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
|
|
50
|
+
dashboardUrl: command.dashboardUrl,
|
|
51
|
+
exec,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Say out loud that the repair was never attempted.
|
|
56
|
+
*
|
|
57
|
+
* BLI-3483: this was a bare `return`. On Windows the self-heal is the only
|
|
58
|
+
* thing that puts a broken scheduler back, so abandoning it here meant a
|
|
59
|
+
* machine could stop collecting forever and leave no receipt anywhere — the
|
|
60
|
+
* exact shape the fleet contract forbids. The packed CLI always supplies a
|
|
61
|
+
* runner (`commands/cli-io.ts`), so this fires only for an embedder that built
|
|
62
|
+
* its own `io`; it costs one line either way.
|
|
63
|
+
*/
|
|
64
|
+
function sayNoProcessRunner() {
|
|
65
|
+
console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
|
|
66
|
+
reason: "runner_unavailable",
|
|
67
|
+
platform: process.platform,
|
|
68
|
+
next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/** The same fact as a receipt, so the fleet table can see it too. */
|
|
72
|
+
function runnerUnavailableOutcome() {
|
|
73
|
+
return {
|
|
74
|
+
status: "skipped",
|
|
75
|
+
reason: "runner_unavailable",
|
|
76
|
+
detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** One receipt for the repair, whichever branch above produced the outcome. */
|
|
80
|
+
async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
|
|
81
|
+
await reportInstallEventsBestEffort({
|
|
82
|
+
homeDir: command.homeDir,
|
|
83
|
+
dashboardUrl,
|
|
84
|
+
command: "sync",
|
|
85
|
+
events: [
|
|
86
|
+
{
|
|
87
|
+
// Windows repairs in place and keeps the name already in the receipts
|
|
88
|
+
// and the runbook; the macOS path only SCHEDULES a detached repair, so
|
|
89
|
+
// it reports under its own step (BLI-3553).
|
|
90
|
+
step: result.step ?? "autostart_repair",
|
|
91
|
+
status: result.status,
|
|
92
|
+
...(result.status === "ok" ? {} : { error_code: result.reason }),
|
|
93
|
+
...(result.detail ? { error_detail: result.detail } : {}),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
json: command.json,
|
|
97
|
+
io,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-up three: keep BLI Memory registered with both agent hosts (BLI-3580).
|
|
3
|
+
*
|
|
4
|
+
* Nobody is going to be asked to install a hook. `do-everything` registers it
|
|
5
|
+
* on the way through, and this puts it back if a host config is edited,
|
|
6
|
+
* replaced, or restored from a machine that never had it — at most once a day,
|
|
7
|
+
* because the steady state is "already current" and re-proving that every
|
|
8
|
+
* fifteen minutes is four file reads a tick for no new information.
|
|
9
|
+
*
|
|
10
|
+
* Same rule as the other follow-ups: it runs only once collection's own
|
|
11
|
+
* outcome has been decided and reported, it never throws, and its outcome is
|
|
12
|
+
* its own named receipt rather than a sync failure.
|
|
13
|
+
*
|
|
14
|
+
* Split out of `sync-followups.ts` (BLI-3988). The registration itself, and
|
|
15
|
+
* every target it writes, are `./memory-install.ts`.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
20
|
+
import { installMemoryIntegration, } from "./memory-install.js";
|
|
21
|
+
import { getCollectorRuntimePaths, } from "../local-state.js";
|
|
22
|
+
export const MEMORY_INSTALL_THROTTLE_MARKER = ".last-memory-install";
|
|
23
|
+
const MEMORY_INSTALL_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
/**
|
|
25
|
+
* BLI-3580: BLI Memory's registration converges on its own, once a day.
|
|
26
|
+
*
|
|
27
|
+
* The daily cadence and the swallowed error paths are the whole contract; see
|
|
28
|
+
* this module's header for why each of them is what it is.
|
|
29
|
+
*/
|
|
30
|
+
export async function runMemoryInstallAfterSync(command, io, dashboardUrl, options = {}) {
|
|
31
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
32
|
+
const now = options.now ?? new Date();
|
|
33
|
+
if (await triedWithinTheLastDay(paths, now))
|
|
34
|
+
return;
|
|
35
|
+
await markInstallAttempted(paths, now);
|
|
36
|
+
const event = await installMemoryOrSayWhyNot(command, io, dashboardUrl);
|
|
37
|
+
await reportInstallEventsBestEffort({
|
|
38
|
+
homeDir: command.homeDir,
|
|
39
|
+
dashboardUrl,
|
|
40
|
+
command: "sync",
|
|
41
|
+
events: [event],
|
|
42
|
+
json: command.json,
|
|
43
|
+
io,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Has this machine already had its one attempt today? */
|
|
47
|
+
async function triedWithinTheLastDay(paths, now) {
|
|
48
|
+
const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
|
|
49
|
+
const lastAttempt = await fs.stat(marker).catch(() => null);
|
|
50
|
+
if (!lastAttempt)
|
|
51
|
+
return false;
|
|
52
|
+
return now.getTime() - lastAttempt.mtimeMs < MEMORY_INSTALL_MIN_INTERVAL_MS;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Spend today's attempt before making it.
|
|
56
|
+
*
|
|
57
|
+
* Written for the ATTEMPT, not the outcome — the same idiom the self-update
|
|
58
|
+
* and autostart repair use, so a machine that cannot write a host config does
|
|
59
|
+
* not retry it every fifteen minutes.
|
|
60
|
+
*/
|
|
61
|
+
async function markInstallAttempted(paths, now) {
|
|
62
|
+
const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
|
|
63
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
64
|
+
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
65
|
+
}
|
|
66
|
+
/** The registration attempt, as a receipt either way; it cannot throw. */
|
|
67
|
+
async function installMemoryOrSayWhyNot(command, io, dashboardUrl) {
|
|
68
|
+
try {
|
|
69
|
+
const outcome = await installMemoryIntegration({
|
|
70
|
+
kind: "memory",
|
|
71
|
+
action: "install",
|
|
72
|
+
homeDir: command.homeDir,
|
|
73
|
+
dashboardUrl,
|
|
74
|
+
dryRun: false,
|
|
75
|
+
json: command.json,
|
|
76
|
+
}, io,
|
|
77
|
+
// Undefined on the real CLI (no io literal sets it); a test io can
|
|
78
|
+
// override the bin lookup so `bin_missing` is a fixture rather than a
|
|
79
|
+
// property of the machine the suite runs on (BLI-3630).
|
|
80
|
+
io.memoryInstallDeps);
|
|
81
|
+
return memoryInstallEvent(outcome);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return {
|
|
85
|
+
step: "memory_install",
|
|
86
|
+
status: "fail",
|
|
87
|
+
error_code: "memory_install_threw",
|
|
88
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Target names and reason labels only. A target's `path` names a person's home
|
|
94
|
+
* directory and a `write_failed` detail can carry one, so neither travels: the
|
|
95
|
+
* receipt says `claude_hooks:read_back_mismatch`, which is the part an operator
|
|
96
|
+
* can act on.
|
|
97
|
+
*/
|
|
98
|
+
function memoryInstallEvent(outcome) {
|
|
99
|
+
const detail = [
|
|
100
|
+
`source=${outcome.config_source}`,
|
|
101
|
+
...outcome.targets.map((target) => `${target.target}:${target.status}/${target.reason}`),
|
|
102
|
+
].join("; ");
|
|
103
|
+
if (outcome.status === "failed") {
|
|
104
|
+
return {
|
|
105
|
+
step: "memory_install",
|
|
106
|
+
status: "fail",
|
|
107
|
+
error_code: outcome.reason,
|
|
108
|
+
error_detail: detail,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (outcome.status === "skipped") {
|
|
112
|
+
// Nothing was written, on purpose (`no_bin_no_write`). A fleet-wide
|
|
113
|
+
// `bin_missing` is the receipt that says the server package has not
|
|
114
|
+
// reached the machines yet — a fact, not a fault.
|
|
115
|
+
return {
|
|
116
|
+
step: "memory_install",
|
|
117
|
+
status: "skipped",
|
|
118
|
+
error_code: outcome.reason,
|
|
119
|
+
error_detail: detail,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { step: "memory_install", status: "ok", error_detail: detail };
|
|
123
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
2
|
+
import { runSelfUpdate, SelfUpdateError } from "./install-update.js";
|
|
3
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
4
|
+
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
5
|
+
/**
|
|
6
|
+
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
7
|
+
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
|
8
|
+
* runs AFTER `runSync` has already decided and reported collection's own
|
|
9
|
+
* outcome above — a stuck or failing self-update can never block or delay
|
|
10
|
+
* collection, and a collection failure never blocks the chance to
|
|
11
|
+
* self-update. Every error path here is swallowed on purpose: a failure is
|
|
12
|
+
* reported as its own named `update` receipt, never surfaced as a `sync`
|
|
13
|
+
* failure or thrown from this function.
|
|
14
|
+
*/
|
|
15
|
+
export async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
|
|
16
|
+
let event;
|
|
17
|
+
try {
|
|
18
|
+
event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// The throttle/probe/install machinery below is defensive already; this
|
|
22
|
+
// is the last-resort net so an update crash truly cannot touch the sync
|
|
23
|
+
// result above.
|
|
24
|
+
event = {
|
|
25
|
+
step: "update",
|
|
26
|
+
status: "fail",
|
|
27
|
+
error_code: "self_update_threw",
|
|
28
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!event)
|
|
32
|
+
return;
|
|
33
|
+
await reportInstallEventsBestEffort({
|
|
34
|
+
homeDir: command.homeDir,
|
|
35
|
+
dashboardUrl,
|
|
36
|
+
command: "update",
|
|
37
|
+
events: [event],
|
|
38
|
+
json: command.json,
|
|
39
|
+
io,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
|
|
43
|
+
const rawExec = io.exec;
|
|
44
|
+
if (!rawExec) {
|
|
45
|
+
// Only the real production `defaultIo()` supplies a process runner. A
|
|
46
|
+
// caller that omitted one gets a silent no-op rather than this reaching
|
|
47
|
+
// for a real npm binary it was never given — never observed in
|
|
48
|
+
// production, where `defaultIo()` always sets `exec`.
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
// Every spawn in the scheduled path carries the running node's bin dir on
|
|
52
|
+
// PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
|
|
53
|
+
// this; the scheduler's stripped environment does.
|
|
54
|
+
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
55
|
+
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
56
|
+
const scheduledIo = { ...io, exec };
|
|
57
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
58
|
+
const result = await runScheduledSelfUpdate(paths, {
|
|
59
|
+
exec,
|
|
60
|
+
currentVersion: LOCAL_COLLECTOR_VERSION,
|
|
61
|
+
install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
|
|
62
|
+
}, { env: io.env, minVersion: minCliVersion });
|
|
63
|
+
return scheduledSelfUpdateInstallEvent(result);
|
|
64
|
+
}
|
|
65
|
+
async function attemptScheduledSelfUpdateInstall(io, tag) {
|
|
66
|
+
try {
|
|
67
|
+
// Reuses the exact npm-install machinery `cockpit doctor`'s
|
|
68
|
+
// `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
|
|
69
|
+
// knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
|
|
70
|
+
// this call never re-execs — see runScheduledSelfUpdate's doc comment.
|
|
71
|
+
await runSelfUpdate(io, { json: true, tag });
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!(error instanceof SelfUpdateError))
|
|
76
|
+
throw error;
|
|
77
|
+
return { ok: false, eacces: error.eacces };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function scheduledSelfUpdateInstallEvent(result) {
|
|
81
|
+
// The steady-state "already checked today" case is a pure no-op; reporting
|
|
82
|
+
// it would post a receipt on ~95 of every 96 sync ticks for no new
|
|
83
|
+
// information. Only a real attempt (ok, fail, or an explicit disable)
|
|
84
|
+
// produces a receipt.
|
|
85
|
+
if (result.reason === "throttled_recent_attempt")
|
|
86
|
+
return null;
|
|
87
|
+
// A forced attempt names its trigger in the receipt either way, so the
|
|
88
|
+
// ledger can tell "converged on the daily cadence" from "the floor pulled
|
|
89
|
+
// this machine forward" (BLI-2678).
|
|
90
|
+
const forcedDetail = result.forced && result.min_version
|
|
91
|
+
? `forced_min_version ${result.min_version}`
|
|
92
|
+
: null;
|
|
93
|
+
if (result.status === "ok") {
|
|
94
|
+
// BLI-3551: this used to be `update ok` with an empty detail unless the
|
|
95
|
+
// floor forced it. One machine posted that receipt daily for nine releases
|
|
96
|
+
// while sitting on 0.2.37, and nobody could tell "already current" from
|
|
97
|
+
// "installed something" from "npm answered nothing" — three different
|
|
98
|
+
// situations wearing one word. The success branch names itself now.
|
|
99
|
+
const okDetail = [
|
|
100
|
+
forcedDetail,
|
|
101
|
+
result.reason === "updated" && result.previous_version && result.installed_version
|
|
102
|
+
? `installed ${result.previous_version}→${result.installed_version}`
|
|
103
|
+
: result.reason,
|
|
104
|
+
result.target_version ? `target ${result.target_version}` : null,
|
|
105
|
+
]
|
|
106
|
+
.filter((part) => Boolean(part))
|
|
107
|
+
.join("; ");
|
|
108
|
+
return {
|
|
109
|
+
step: "update",
|
|
110
|
+
status: "ok",
|
|
111
|
+
...(okDetail ? { error_detail: okDetail } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const detail = [
|
|
115
|
+
forcedDetail,
|
|
116
|
+
result.target_version ? `target ${result.target_version}` : null,
|
|
117
|
+
result.installed_version ? `installed ${result.installed_version}` : null,
|
|
118
|
+
]
|
|
119
|
+
.filter((part) => Boolean(part))
|
|
120
|
+
.join("; ");
|
|
121
|
+
return {
|
|
122
|
+
step: "update",
|
|
123
|
+
status: result.status,
|
|
124
|
+
error_code: result.reason,
|
|
125
|
+
...(detail ? { error_detail: detail } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|