@bli-cockpit/cli 0.2.93 → 0.2.95
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/public-root.js +1 -1
- 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/package.json +4 -4
|
@@ -1,513 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What the sync tick does AFTER collection's own outcome is decided and
|
|
3
|
-
* reported
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* receipts, never a sync failure.
|
|
34
|
-
*/
|
|
35
|
-
export async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
36
|
-
let result;
|
|
37
|
-
try {
|
|
38
|
-
const rawExec = io.exec;
|
|
39
|
-
if (!rawExec) {
|
|
40
|
-
// BLI-3483: this was a bare `return`. On Windows the self-heal is the
|
|
41
|
-
// only thing that puts a broken scheduler back, so abandoning it here
|
|
42
|
-
// meant a machine could stop collecting forever and leave no receipt
|
|
43
|
-
// anywhere — the exact shape the fleet contract forbids. The packed CLI
|
|
44
|
-
// always supplies a runner (`commands/cli-io.ts`), so this fires only for
|
|
45
|
-
// an embedder that built its own `io`; it costs one line either way.
|
|
46
|
-
console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
|
|
47
|
-
reason: "runner_unavailable",
|
|
48
|
-
platform: process.platform,
|
|
49
|
-
next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
|
|
50
|
-
}));
|
|
51
|
-
result = {
|
|
52
|
-
status: "skipped",
|
|
53
|
-
reason: "runner_unavailable",
|
|
54
|
-
detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
|
|
55
|
-
};
|
|
56
|
-
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
60
|
-
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
61
|
-
result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
|
|
62
|
-
homeDir: command.homeDir,
|
|
63
|
-
repoRoots: await resolveAutostartRoots(command.homeDir, undefined),
|
|
64
|
-
dashboardUrl: command.dashboardUrl,
|
|
65
|
-
exec,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
result = {
|
|
70
|
-
status: "fail",
|
|
71
|
-
reason: "autostart_self_heal_threw",
|
|
72
|
-
detail: redactedSyncErrorDetail(error),
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
// Steady state (healthy, absent, non-Windows, no roots) and the daily
|
|
76
|
-
// throttle are silent; an actual repair attempt reports either way.
|
|
77
|
-
if (!result || result.reason === "repair_throttled_recent_attempt")
|
|
78
|
-
return;
|
|
79
|
-
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
80
|
-
}
|
|
81
|
-
/** One receipt for the repair, whichever branch above produced the outcome. */
|
|
82
|
-
async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
|
|
83
|
-
await reportInstallEventsBestEffort({
|
|
84
|
-
homeDir: command.homeDir,
|
|
85
|
-
dashboardUrl,
|
|
86
|
-
command: "sync",
|
|
87
|
-
events: [
|
|
88
|
-
{
|
|
89
|
-
// Windows repairs in place and keeps the name already in the receipts
|
|
90
|
-
// and the runbook; the macOS path only SCHEDULES a detached repair, so
|
|
91
|
-
// it reports under its own step (BLI-3553).
|
|
92
|
-
step: result.step ?? "autostart_repair",
|
|
93
|
-
status: result.status,
|
|
94
|
-
...(result.status === "ok" ? {} : { error_code: result.reason }),
|
|
95
|
-
...(result.detail ? { error_detail: result.detail } : {}),
|
|
96
|
-
},
|
|
97
|
-
],
|
|
98
|
-
json: command.json,
|
|
99
|
-
io,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
export const MEMORY_INSTALL_THROTTLE_MARKER = ".last-memory-install";
|
|
103
|
-
const MEMORY_INSTALL_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
104
|
-
/**
|
|
105
|
-
* BLI-3580: BLI Memory's registration converges on its own.
|
|
106
|
-
*
|
|
107
|
-
* Nobody is going to be asked to install a hook. `do-everything` registers it
|
|
108
|
-
* on the way through, and this puts it back if a host config is edited,
|
|
109
|
-
* replaced, or restored from a machine that never had it — at most once a day,
|
|
110
|
-
* because the steady state is "already current" and re-proving that every
|
|
111
|
-
* fifteen minutes is four file reads a tick for no new information.
|
|
112
|
-
*
|
|
113
|
-
* Same rule as the two follow-ups above: it runs only once collection's own
|
|
114
|
-
* outcome has been decided and reported, it never throws, and its outcome is
|
|
115
|
-
* its own named receipt rather than a sync failure.
|
|
116
|
-
*/
|
|
117
|
-
export async function runMemoryInstallAfterSync(command, io, dashboardUrl, options = {}) {
|
|
118
|
-
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
119
|
-
const now = options.now ?? new Date();
|
|
120
|
-
const marker = path.join(paths.state_dir, MEMORY_INSTALL_THROTTLE_MARKER);
|
|
121
|
-
const lastAttempt = await fs.stat(marker).catch(() => null);
|
|
122
|
-
if (lastAttempt &&
|
|
123
|
-
now.getTime() - lastAttempt.mtimeMs < MEMORY_INSTALL_MIN_INTERVAL_MS) {
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
// Written for the ATTEMPT, not the outcome — the same idiom the self-update
|
|
127
|
-
// and autostart repair use, so a machine that cannot write a host config does
|
|
128
|
-
// not retry it every fifteen minutes.
|
|
129
|
-
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
130
|
-
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
131
|
-
let event;
|
|
132
|
-
try {
|
|
133
|
-
const outcome = await installMemoryIntegration({
|
|
134
|
-
kind: "memory",
|
|
135
|
-
action: "install",
|
|
136
|
-
homeDir: command.homeDir,
|
|
137
|
-
dashboardUrl,
|
|
138
|
-
dryRun: false,
|
|
139
|
-
json: command.json,
|
|
140
|
-
}, io,
|
|
141
|
-
// Undefined on the real CLI (no io literal sets it); a test io can
|
|
142
|
-
// override the bin lookup so `bin_missing` is a fixture rather than a
|
|
143
|
-
// property of the machine the suite runs on (BLI-3630).
|
|
144
|
-
io.memoryInstallDeps);
|
|
145
|
-
event = memoryInstallEvent(outcome);
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
event = {
|
|
149
|
-
step: "memory_install",
|
|
150
|
-
status: "fail",
|
|
151
|
-
error_code: "memory_install_threw",
|
|
152
|
-
error_detail: redactedSyncErrorDetail(error),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
await reportInstallEventsBestEffort({
|
|
156
|
-
homeDir: command.homeDir,
|
|
157
|
-
dashboardUrl,
|
|
158
|
-
command: "sync",
|
|
159
|
-
events: [event],
|
|
160
|
-
json: command.json,
|
|
161
|
-
io,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Target names and reason labels only. A target's `path` names a person's home
|
|
166
|
-
* directory and a `write_failed` detail can carry one, so neither travels: the
|
|
167
|
-
* receipt says `claude_hooks:read_back_mismatch`, which is the part an operator
|
|
168
|
-
* can act on.
|
|
169
|
-
*/
|
|
170
|
-
function memoryInstallEvent(outcome) {
|
|
171
|
-
const detail = [
|
|
172
|
-
`source=${outcome.config_source}`,
|
|
173
|
-
...outcome.targets.map((target) => `${target.target}:${target.status}/${target.reason}`),
|
|
174
|
-
].join("; ");
|
|
175
|
-
if (outcome.status === "failed") {
|
|
176
|
-
return {
|
|
177
|
-
step: "memory_install",
|
|
178
|
-
status: "fail",
|
|
179
|
-
error_code: outcome.reason,
|
|
180
|
-
error_detail: detail,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (outcome.status === "skipped") {
|
|
184
|
-
// Nothing was written, on purpose (`no_bin_no_write`). A fleet-wide
|
|
185
|
-
// `bin_missing` is the receipt that says the server package has not
|
|
186
|
-
// reached the machines yet — a fact, not a fault.
|
|
187
|
-
return {
|
|
188
|
-
step: "memory_install",
|
|
189
|
-
status: "skipped",
|
|
190
|
-
error_code: outcome.reason,
|
|
191
|
-
error_detail: detail,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
return { step: "memory_install", status: "ok", error_detail: detail };
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* BLI-3619: staged raw evidence Tower has already accepted stops living on the
|
|
198
|
-
* laptop forever.
|
|
199
|
-
*
|
|
200
|
-
* Same shape as the memory install above and for the same reasons: it runs only
|
|
201
|
-
* once collection's own outcome is decided and reported, at most once a day, it
|
|
202
|
-
* cannot throw, and its outcome is its own named receipt rather than a sync
|
|
203
|
-
* failure. A machine that cannot prune is a machine short of disk, and that
|
|
204
|
-
* must never also be a machine that stops collecting.
|
|
205
|
-
*
|
|
206
|
-
* The rule itself is `disk-retention.ts`: only objects the upload ledger
|
|
207
|
-
* vouches for are ever deleted, and anything undelivered is counted, aged and
|
|
208
|
-
* named instead.
|
|
209
|
-
*
|
|
210
|
-
* BLI-3619's second half runs FIRST, every tick, bounded to
|
|
211
|
-
* `RECONCILE_BATCHES_PER_TICK` (one batch of up to 500 hashes): the local
|
|
212
|
-
* ledger's own memory is capped, so a laptop that keeps accumulating
|
|
213
|
-
* `unknown` state needs SOMETHING asking the server on a schedule, not only
|
|
214
|
-
* when a person happens to type `cockpit clean --reconcile`. One batch a tick
|
|
215
|
-
* is not throttled to once a day like the prune below — an `unknown` backlog
|
|
216
|
-
* drains at up to 500 hashes/15 min, and the ordinary case (nothing unknown)
|
|
217
|
-
* costs one cheap local read and no network call at all, so it never competes
|
|
218
|
-
* with collection for the tick's time. A reconcile failure never blocks the
|
|
219
|
-
* prune that follows it.
|
|
220
|
-
*
|
|
221
|
-
* BLI-3797 sits between them: the reconcile has just established which staged
|
|
222
|
-
* objects the SERVER says it never received, so the redelivery drain re-offers
|
|
223
|
-
* exactly those, bounded, before the prune runs and can delete whatever landed.
|
|
224
|
-
* Neither of the two can block the prune, and none of the three can fail a tick.
|
|
225
|
-
*/
|
|
226
|
-
export async function runStagingPruneAfterSync(command, io, dashboardUrl, options = {}) {
|
|
227
|
-
const events = [];
|
|
228
|
-
const reconciled = await runReconcileFollowUp(command, io, dashboardUrl, options);
|
|
229
|
-
if (reconciled)
|
|
230
|
-
events.push(reconcileEvent(reconciled));
|
|
231
|
-
const redelivered = await runRedeliveryFollowUp(command, io, dashboardUrl, options);
|
|
232
|
-
if (redelivered)
|
|
233
|
-
events.push(redeliveryEvent(redelivered));
|
|
234
|
-
let result;
|
|
235
|
-
try {
|
|
236
|
-
result = await runStagingPrune(getCollectorRuntimePaths(command.homeDir), {
|
|
237
|
-
env: io.env,
|
|
238
|
-
...(options.now ? { now: options.now } : {}),
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
catch (error) {
|
|
242
|
-
// runStagingPrune already catches everything it can reach; this is the
|
|
243
|
-
// last-resort net so a prune crash truly cannot touch the sync result.
|
|
244
|
-
result = { ...prunedNothing(), reason: "prune_threw", status: "fail" };
|
|
245
|
-
console.error("[collector prune] the prune follow-up threw", JSON.stringify({
|
|
246
|
-
reason: "prune_followup_threw",
|
|
247
|
-
detail: redactedSyncErrorDetail(error),
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
// The daily throttle is the steady state — reporting it would post a receipt
|
|
251
|
-
// on 95 of every 96 ticks for no new information.
|
|
252
|
-
if (result.reason !== "throttled_recent_run") {
|
|
253
|
-
events.push(stagingPruneEvent(result));
|
|
254
|
-
}
|
|
255
|
-
if (events.length === 0)
|
|
256
|
-
return;
|
|
257
|
-
await reportInstallEventsBestEffort({
|
|
258
|
-
homeDir: command.homeDir,
|
|
259
|
-
dashboardUrl,
|
|
260
|
-
command: "sync",
|
|
261
|
-
events,
|
|
262
|
-
json: command.json,
|
|
263
|
-
io,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Never throws (`runEvidenceReconcile` already never does); returns `null`
|
|
268
|
-
* for the boring, common case — nothing this tick was `unknown` — so that
|
|
269
|
-
* case costs no receipt either, the same rule the prune's own daily throttle
|
|
270
|
-
* follows above.
|
|
271
|
-
*/
|
|
272
|
-
async function runReconcileFollowUp(command, io, dashboardUrl, options) {
|
|
273
|
-
const result = await runEvidenceReconcile({
|
|
274
|
-
homeDir: command.homeDir,
|
|
275
|
-
dashboardUrl,
|
|
276
|
-
maxBatches: RECONCILE_BATCHES_PER_TICK,
|
|
277
|
-
fetch: io.fetch,
|
|
278
|
-
...(options.now ? { now: options.now } : {}),
|
|
279
|
-
});
|
|
280
|
-
return result.reason === "nothing_unknown" ? null : result;
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* BLI-3797, between the reconcile above and the prune below, and in that order
|
|
284
|
-
* for a reason: reconcile turns `unknown` into a server-backed answer, this
|
|
285
|
-
* re-offers what that answer says never landed, and the prune then deletes
|
|
286
|
-
* whatever this just made durable. Running the drain first would ask the server
|
|
287
|
-
* about hashes it is about to be told the truth about; running it after the
|
|
288
|
-
* prune would leave a tick's worth of freed cap unused.
|
|
289
|
-
*
|
|
290
|
-
* Never throws (`runEvidenceRedelivery` already never does); returns `null` for
|
|
291
|
-
* the boring, common case — nothing on this disk is undelivered — so the steady
|
|
292
|
-
* state costs no receipt, the same rule the reconcile follow-up above and the
|
|
293
|
-
* prune's daily throttle below both follow.
|
|
294
|
-
*/
|
|
295
|
-
async function runRedeliveryFollowUp(command, io, dashboardUrl, options) {
|
|
296
|
-
const result = await runEvidenceRedelivery({
|
|
297
|
-
homeDir: command.homeDir,
|
|
298
|
-
dashboardUrl,
|
|
299
|
-
env: io.env,
|
|
300
|
-
fetch: io.fetch,
|
|
301
|
-
...(options.now ? { now: options.now } : {}),
|
|
302
|
-
});
|
|
303
|
-
return result.reason === "nothing_uncommitted" ? null : result;
|
|
304
|
-
}
|
|
305
|
-
/** Counts and byte totals only; no pack id and no hash travels in a receipt. */
|
|
306
|
-
function redeliveryEvent(result) {
|
|
307
|
-
const detail = [
|
|
308
|
-
`offered ${result.offered} object(s), ${result.offered_bytes}B, across ${result.packs} pack(s)`,
|
|
309
|
-
`uploaded ${result.uploaded} (${result.uploaded_bytes}B), reused ${result.reused}, failed ${result.failed}`,
|
|
310
|
-
`held ${result.held}, deferred ${result.deferred} (${result.deferred_bytes}B)`,
|
|
311
|
-
result.failure_reasons.length > 0
|
|
312
|
-
? `failure_reasons ${result.failure_reasons.join(",")}`
|
|
313
|
-
: null,
|
|
314
|
-
]
|
|
315
|
-
.filter((part) => Boolean(part))
|
|
316
|
-
.join("; ");
|
|
317
|
-
if (result.status === "fail") {
|
|
318
|
-
return {
|
|
319
|
-
step: "evidence_redelivery",
|
|
320
|
-
status: "fail",
|
|
321
|
-
error_code: result.reason,
|
|
322
|
-
error_detail: detail,
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
if (result.status === "skipped") {
|
|
326
|
-
return {
|
|
327
|
-
step: "evidence_redelivery",
|
|
328
|
-
status: "skipped",
|
|
329
|
-
error_code: result.reason,
|
|
330
|
-
error_detail: detail,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
return { step: "evidence_redelivery", status: "ok", error_detail: detail };
|
|
334
|
-
}
|
|
335
|
-
function prunedNothing() {
|
|
336
|
-
return {
|
|
337
|
-
status: "skipped",
|
|
338
|
-
reason: "prune_threw",
|
|
339
|
-
deleted_files: 0,
|
|
340
|
-
deleted_bytes: 0,
|
|
341
|
-
removed_packs: 0,
|
|
342
|
-
kept_uncommitted: 0,
|
|
343
|
-
kept_uncommitted_bytes: 0,
|
|
344
|
-
oldest_uncommitted_age_ms: 0,
|
|
345
|
-
kept_unknown: 0,
|
|
346
|
-
kept_unknown_bytes: 0,
|
|
347
|
-
kept_in_window: 0,
|
|
348
|
-
cap_bytes: 0,
|
|
349
|
-
bytes_before: 0,
|
|
350
|
-
bytes_after: 0,
|
|
351
|
-
cap_blocked_by_uncommitted: false,
|
|
352
|
-
cap_blocked_count: 0,
|
|
353
|
-
failed_deletions: 0,
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
/** Counts and byte totals only; no pack id and no path travels in a receipt. */
|
|
357
|
-
function stagingPruneEvent(result) {
|
|
358
|
-
const detail = [
|
|
359
|
-
`freed ${result.deleted_bytes}B in ${result.deleted_files} file(s)`,
|
|
360
|
-
`held ${result.kept_uncommitted_bytes}B uncommitted`,
|
|
361
|
-
result.cap_blocked_by_uncommitted
|
|
362
|
-
? `staging_cap_blocked_by_uncommitted ${result.cap_blocked_count}`
|
|
363
|
-
: null,
|
|
364
|
-
result.failed_deletions > 0
|
|
365
|
-
? `failed_deletions ${result.failed_deletions}`
|
|
366
|
-
: null,
|
|
367
|
-
]
|
|
368
|
-
.filter((part) => Boolean(part))
|
|
369
|
-
.join("; ");
|
|
370
|
-
if (result.status === "fail") {
|
|
371
|
-
return {
|
|
372
|
-
step: "staging_prune",
|
|
373
|
-
status: "fail",
|
|
374
|
-
error_code: result.reason,
|
|
375
|
-
error_detail: detail,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
if (result.status === "skipped") {
|
|
379
|
-
return { step: "staging_prune", status: "skipped", error_code: result.reason };
|
|
380
|
-
}
|
|
381
|
-
return { step: "staging_prune", status: "ok", error_detail: detail };
|
|
382
|
-
}
|
|
383
|
-
/** Counts only; no hash and no pack id travels in a receipt. */
|
|
384
|
-
function reconcileEvent(result) {
|
|
385
|
-
const detail = `asked ${result.asked} hash(es) across ${result.batches}/${result.total_batches} batch(es); committed ${result.committed}, not_committed ${result.not_committed}, unknown_to_server ${result.unknown_to_server}, failed_batches ${result.failed_batches}`;
|
|
386
|
-
if (result.status === "fail") {
|
|
387
|
-
return { step: "evidence_reconcile", status: "fail", error_code: result.reason, error_detail: detail };
|
|
388
|
-
}
|
|
389
|
-
return { step: "evidence_reconcile", status: "ok", error_detail: detail };
|
|
390
|
-
}
|
|
391
|
-
/**
|
|
392
|
-
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
393
|
-
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
|
394
|
-
* runs AFTER `runSync` has already decided and reported collection's own
|
|
395
|
-
* outcome above — a stuck or failing self-update can never block or delay
|
|
396
|
-
* collection, and a collection failure never blocks the chance to
|
|
397
|
-
* self-update. Every error path here is swallowed on purpose: a failure is
|
|
398
|
-
* reported as its own named `update` receipt, never surfaced as a `sync`
|
|
399
|
-
* failure or thrown from this function.
|
|
400
|
-
*/
|
|
401
|
-
export async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl, minCliVersion) {
|
|
402
|
-
let event;
|
|
403
|
-
try {
|
|
404
|
-
event = await runScheduledSelfUpdateForSync(command, io, minCliVersion);
|
|
405
|
-
}
|
|
406
|
-
catch (error) {
|
|
407
|
-
// The throttle/probe/install machinery below is defensive already; this
|
|
408
|
-
// is the last-resort net so an update crash truly cannot touch the sync
|
|
409
|
-
// result above.
|
|
410
|
-
event = {
|
|
411
|
-
step: "update",
|
|
412
|
-
status: "fail",
|
|
413
|
-
error_code: "self_update_threw",
|
|
414
|
-
error_detail: redactedSyncErrorDetail(error),
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
if (!event)
|
|
418
|
-
return;
|
|
419
|
-
await reportInstallEventsBestEffort({
|
|
420
|
-
homeDir: command.homeDir,
|
|
421
|
-
dashboardUrl,
|
|
422
|
-
command: "update",
|
|
423
|
-
events: [event],
|
|
424
|
-
json: command.json,
|
|
425
|
-
io,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
async function runScheduledSelfUpdateForSync(command, io, minCliVersion) {
|
|
429
|
-
const rawExec = io.exec;
|
|
430
|
-
if (!rawExec) {
|
|
431
|
-
// Only the real production `defaultIo()` supplies a process runner. A
|
|
432
|
-
// caller that omitted one gets a silent no-op rather than this reaching
|
|
433
|
-
// for a real npm binary it was never given — never observed in
|
|
434
|
-
// production, where `defaultIo()` always sets `exec`.
|
|
435
|
-
return null;
|
|
436
|
-
}
|
|
437
|
-
// Every spawn in the scheduled path carries the running node's bin dir on
|
|
438
|
-
// PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
|
|
439
|
-
// this; the scheduler's stripped environment does.
|
|
440
|
-
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
441
|
-
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
442
|
-
const scheduledIo = { ...io, exec };
|
|
443
|
-
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
444
|
-
const result = await runScheduledSelfUpdate(paths, {
|
|
445
|
-
exec,
|
|
446
|
-
currentVersion: LOCAL_COLLECTOR_VERSION,
|
|
447
|
-
install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
|
|
448
|
-
}, { env: io.env, minVersion: minCliVersion });
|
|
449
|
-
return scheduledSelfUpdateInstallEvent(result);
|
|
450
|
-
}
|
|
451
|
-
async function attemptScheduledSelfUpdateInstall(io, tag) {
|
|
452
|
-
try {
|
|
453
|
-
// Reuses the exact npm-install machinery `cockpit doctor`'s
|
|
454
|
-
// `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
|
|
455
|
-
// knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
|
|
456
|
-
// this call never re-execs — see runScheduledSelfUpdate's doc comment.
|
|
457
|
-
await runSelfUpdate(io, { json: true, tag });
|
|
458
|
-
return { ok: true };
|
|
459
|
-
}
|
|
460
|
-
catch (error) {
|
|
461
|
-
if (!(error instanceof SelfUpdateError))
|
|
462
|
-
throw error;
|
|
463
|
-
return { ok: false, eacces: error.eacces };
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
function scheduledSelfUpdateInstallEvent(result) {
|
|
467
|
-
// The steady-state "already checked today" case is a pure no-op; reporting
|
|
468
|
-
// it would post a receipt on ~95 of every 96 sync ticks for no new
|
|
469
|
-
// information. Only a real attempt (ok, fail, or an explicit disable)
|
|
470
|
-
// produces a receipt.
|
|
471
|
-
if (result.reason === "throttled_recent_attempt")
|
|
472
|
-
return null;
|
|
473
|
-
// A forced attempt names its trigger in the receipt either way, so the
|
|
474
|
-
// ledger can tell "converged on the daily cadence" from "the floor pulled
|
|
475
|
-
// this machine forward" (BLI-2678).
|
|
476
|
-
const forcedDetail = result.forced && result.min_version
|
|
477
|
-
? `forced_min_version ${result.min_version}`
|
|
478
|
-
: null;
|
|
479
|
-
if (result.status === "ok") {
|
|
480
|
-
// BLI-3551: this used to be `update ok` with an empty detail unless the
|
|
481
|
-
// floor forced it. One machine posted that receipt daily for nine releases
|
|
482
|
-
// while sitting on 0.2.37, and nobody could tell "already current" from
|
|
483
|
-
// "installed something" from "npm answered nothing" — three different
|
|
484
|
-
// situations wearing one word. The success branch names itself now.
|
|
485
|
-
const okDetail = [
|
|
486
|
-
forcedDetail,
|
|
487
|
-
result.reason === "updated" && result.previous_version && result.installed_version
|
|
488
|
-
? `installed ${result.previous_version}→${result.installed_version}`
|
|
489
|
-
: result.reason,
|
|
490
|
-
result.target_version ? `target ${result.target_version}` : null,
|
|
491
|
-
]
|
|
492
|
-
.filter((part) => Boolean(part))
|
|
493
|
-
.join("; ");
|
|
494
|
-
return {
|
|
495
|
-
step: "update",
|
|
496
|
-
status: "ok",
|
|
497
|
-
...(okDetail ? { error_detail: okDetail } : {}),
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
const detail = [
|
|
501
|
-
forcedDetail,
|
|
502
|
-
result.target_version ? `target ${result.target_version}` : null,
|
|
503
|
-
result.installed_version ? `installed ${result.installed_version}` : null,
|
|
504
|
-
]
|
|
505
|
-
.filter((part) => Boolean(part))
|
|
506
|
-
.join("; ");
|
|
507
|
-
return {
|
|
508
|
-
step: "update",
|
|
509
|
-
status: result.status,
|
|
510
|
-
error_code: result.reason,
|
|
511
|
-
...(detail ? { error_detail: detail } : {}),
|
|
512
|
-
};
|
|
513
|
-
}
|
|
3
|
+
* reported. Read the four exports below as the table of contents: each is one
|
|
4
|
+
* follow-up, each lives in exactly one sibling, and nothing else lives here.
|
|
5
|
+
*
|
|
6
|
+
* keep this CLI current on npm `latest` sync-followups-self-update.ts
|
|
7
|
+
* put a broken scheduler back sync-followups-autostart.ts
|
|
8
|
+
* keep BLI Memory registered sync-followups-memory.ts
|
|
9
|
+
* reconcile, re-offer, then prune staging sync-followups-staging.ts
|
|
10
|
+
*
|
|
11
|
+
* They belong together because they share one rule, and it is the reason all
|
|
12
|
+
* four are called last: a follow-up may never block, delay or fail collection.
|
|
13
|
+
* Every error path in this family is swallowed on purpose and reported as its
|
|
14
|
+
* own named receipt — an `update` step, an autostart repair step, a
|
|
15
|
+
* `memory_install`, an `evidence_reconcile`, an `evidence_redelivery`, a
|
|
16
|
+
* `staging_prune` — never as a `sync` failure. Three of the four are throttled
|
|
17
|
+
* to one attempt a day; the reconcile is the exception and says why in its own
|
|
18
|
+
* file.
|
|
19
|
+
*
|
|
20
|
+
* `sync.ts` calls the first three from `convergeAfterTick` and the staging one
|
|
21
|
+
* on its own line (the throwing arm has never run it). Split out of
|
|
22
|
+
* commands/sync.ts (BLI-3578), then into the siblings above (BLI-3988); the
|
|
23
|
+
* fifth follow-up on that call, the memory experience log, was always its own
|
|
24
|
+
* module (`memory-log.ts`). A new sibling must also join
|
|
25
|
+
* `scripts/build-public-cli.mjs` `runtimeFiles`, or the repo tests stay green
|
|
26
|
+
* while the packed CLI breaks, and `sync-followups-lock.test.ts` holds this
|
|
27
|
+
* family's export surface and every literal it writes.
|
|
28
|
+
*/
|
|
29
|
+
export { runAutostartSelfHealAfterSync } from "./sync-followups-autostart.js";
|
|
30
|
+
export { MEMORY_INSTALL_THROTTLE_MARKER, runMemoryInstallAfterSync, } from "./sync-followups-memory.js";
|
|
31
|
+
export { runScheduledSelfUpdateAfterSync } from "./sync-followups-self-update.js";
|
|
32
|
+
export { runStagingPruneAfterSync } from "./sync-followups-staging.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sendCollectorHeartbeatBestEffort, readHeartbeatStagingFacts, } from "./heartbeat.js";
|
|
2
|
+
import { resolveSyncCollectionRoots } from "./sync-roots.js";
|
|
3
|
+
import { describeError } from "../health-detail.js";
|
|
4
|
+
/**
|
|
5
|
+
* The tick's check-in (BLI-3551).
|
|
6
|
+
*
|
|
7
|
+
* Resolving the roots is best-effort on purpose: a machine with NO approved
|
|
8
|
+
* root is exactly the machine whose silence needs explaining, so it still
|
|
9
|
+
* checks in — with an empty root list, which is itself the finding.
|
|
10
|
+
*/
|
|
11
|
+
export async function sendSyncHeartbeat(command, io, dashboardUrl, facts) {
|
|
12
|
+
const roots = await resolveSyncCollectionRoots(command).catch(() => []);
|
|
13
|
+
// BLI-3797: the backlog figure rides the same check-in as the root labels, so
|
|
14
|
+
// `cockpit ops` learns about undelivered evidence on the SAME tick that proves
|
|
15
|
+
// the machine is alive. Best-effort: nulls omit the fields rather than
|
|
16
|
+
// reporting a zero nobody measured.
|
|
17
|
+
const staging = await readHeartbeatStagingFacts({
|
|
18
|
+
homeDir: command.homeDir,
|
|
19
|
+
}).catch(() => ({ bytes: null, reason: null }));
|
|
20
|
+
await sendCollectorHeartbeatBestEffort({
|
|
21
|
+
homeDir: command.homeDir,
|
|
22
|
+
dashboardUrl,
|
|
23
|
+
roots,
|
|
24
|
+
facts: {
|
|
25
|
+
...facts,
|
|
26
|
+
stagingUncommittedBytes: staging.bytes,
|
|
27
|
+
stagingUncommittedReason: staging.reason,
|
|
28
|
+
},
|
|
29
|
+
io,
|
|
30
|
+
}).catch((error) => {
|
|
31
|
+
// The sender already swallows everything it knows about; this is the net
|
|
32
|
+
// for anything it does not, because a heartbeat must never fail a sync.
|
|
33
|
+
console.error("[heartbeat] the check-in threw and was dropped", JSON.stringify({ reason: "heartbeat_threw", ...describeError(error) }));
|
|
34
|
+
return false;
|
|
35
|
+
});
|
|
36
|
+
}
|