@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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only four things in this family that touch a file for its own sake:
|
|
3
|
+
* list a pack's files, add up a directory's bytes, read a pack's manifest, and
|
|
4
|
+
* hash a file the manifest does not name.
|
|
5
|
+
*
|
|
6
|
+
* They are together because they share one rule — a daily inventory must not
|
|
7
|
+
* re-read 20 GB. Sizes come from `stat`, hashes come from the manifest each
|
|
8
|
+
* pack already wrote, and the one path that must actually read bytes
|
|
9
|
+
* (`hashUnnamedFile`) spends a budget and stops.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { describeError } from "./health-detail.js";
|
|
15
|
+
/** Bytes this inventory may spend hashing files no manifest names. */
|
|
16
|
+
export const UNNAMED_FILE_HASH_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* A file its own manifest does not name gets hashed — but only while the run's
|
|
19
|
+
* byte budget lasts. Past it the object stays `unknown` and says which budget
|
|
20
|
+
* ran out, because reading 20 GB to answer a housekeeping question is how the
|
|
21
|
+
* old GC earned its once-a-day throttle.
|
|
22
|
+
*/
|
|
23
|
+
export async function hashUnnamedFile(file, size, budget) {
|
|
24
|
+
if (size > budget.remaining)
|
|
25
|
+
return null;
|
|
26
|
+
const bytes = await fs.readFile(file).catch(() => null);
|
|
27
|
+
if (!bytes)
|
|
28
|
+
return null;
|
|
29
|
+
budget.remaining -= bytes.byteLength;
|
|
30
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A pack's own record of what it staged: pack-relative path -> content hash.
|
|
34
|
+
* This is where a classification gets its hash without reading a payload.
|
|
35
|
+
*/
|
|
36
|
+
export async function readManifestIndex(dir) {
|
|
37
|
+
const raw = await fs
|
|
38
|
+
.readFile(path.join(dir, "manifest.json"), "utf8")
|
|
39
|
+
.catch(() => null);
|
|
40
|
+
if (!raw)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
const index = new Map();
|
|
45
|
+
for (const file of parsed.files ?? []) {
|
|
46
|
+
if (typeof file.relative_path === "string" &&
|
|
47
|
+
typeof file.content_hash_sha256 === "string" &&
|
|
48
|
+
file.content_hash_sha256.length === 64) {
|
|
49
|
+
index.set(file.relative_path, file.content_hash_sha256);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return index;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// Named, not swallowed: an unreadable manifest turns every file in the
|
|
56
|
+
// pack into `unknown`, which is the difference between a pack that gets
|
|
57
|
+
// pruned and one that is kept forever (BLI-3238's rule, BLI-3619's cost).
|
|
58
|
+
console.error("[collector disk] pack manifest unreadable, its files cannot be classified", JSON.stringify({
|
|
59
|
+
reason: "manifest_unreadable",
|
|
60
|
+
pack_id: path.basename(dir),
|
|
61
|
+
...describeError(error),
|
|
62
|
+
}));
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Every file under a directory, as POSIX paths relative to it. */
|
|
67
|
+
export async function listPackFiles(dir) {
|
|
68
|
+
const files = [];
|
|
69
|
+
const stack = [{ dir, prefix: "" }];
|
|
70
|
+
while (stack.length > 0) {
|
|
71
|
+
const current = stack.pop();
|
|
72
|
+
if (!current)
|
|
73
|
+
continue;
|
|
74
|
+
const entries = await fs
|
|
75
|
+
.readdir(current.dir, { withFileTypes: true })
|
|
76
|
+
.catch(() => []);
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const relative = current.prefix
|
|
79
|
+
? `${current.prefix}/${entry.name}`
|
|
80
|
+
: entry.name;
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
stack.push({ dir: path.join(current.dir, entry.name), prefix: relative });
|
|
83
|
+
}
|
|
84
|
+
else if (entry.isFile()) {
|
|
85
|
+
files.push(relative);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
/** What a directory weighs, for the parts of the footprint that have no packs. */
|
|
92
|
+
export async function directoryBytes(dir) {
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const relative of await listPackFiles(dir)) {
|
|
95
|
+
const info = await fs.stat(path.join(dir, relative)).catch(() => null);
|
|
96
|
+
total += info?.isFile() ? info.size : 0;
|
|
97
|
+
}
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole-machine read: staging, rotated logs, the spool, and the vaults a
|
|
3
|
+
* person made by hand — the four places Cockpit ever writes bytes on a laptop.
|
|
4
|
+
*
|
|
5
|
+
* The staging half is the rest of this family; what lives here is the other
|
|
6
|
+
* three and the one function that puts all four together.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { MANUAL_VAULT_PREFIX, } from "./disk-usage-facts.js";
|
|
11
|
+
import { directoryBytes, listPackFiles } from "./disk-usage-files.js";
|
|
12
|
+
import { readStagingInventory } from "./disk-usage-scan.js";
|
|
13
|
+
import { rotatedLogFootprint } from "./log-rotation.js";
|
|
14
|
+
/**
|
|
15
|
+
* Everything on this machine Cockpit put there, in one read. Used by the sync
|
|
16
|
+
* tick's prune, by `cockpit clean` and by `cockpit doctor`'s disk row, so all
|
|
17
|
+
* three quote the same numbers rather than three near-misses.
|
|
18
|
+
*/
|
|
19
|
+
export async function readDiskFootprint(paths, now = new Date()) {
|
|
20
|
+
return {
|
|
21
|
+
staging: await readStagingInventory(paths, now),
|
|
22
|
+
logs: await rotatedLogFootprint(paths),
|
|
23
|
+
spool_bytes: await directoryBytes(paths.spool_dir),
|
|
24
|
+
vaults: await readManualVaults(paths),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The hand-made vaults: counted and named so nothing deletes them silently. */
|
|
28
|
+
async function readManualVaults(paths) {
|
|
29
|
+
const entries = await fs
|
|
30
|
+
.readdir(paths.state_dir, { withFileTypes: true })
|
|
31
|
+
.catch(() => []);
|
|
32
|
+
const vaults = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory() || !entry.name.startsWith(MANUAL_VAULT_PREFIX)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const dir = path.join(paths.state_dir, entry.name);
|
|
38
|
+
vaults.push({
|
|
39
|
+
name: entry.name,
|
|
40
|
+
byte_size: await directoryBytes(dir),
|
|
41
|
+
file_count: (await listPackFiles(dir)).length,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return vaults;
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three records this machine keeps about staged bytes, read before the
|
|
3
|
+
* walk starts so every object is classified against the same answers: what the
|
|
4
|
+
* upload ledger committed, what the server said when it was asked, and when
|
|
5
|
+
* each object was staged.
|
|
6
|
+
*
|
|
7
|
+
* `reachesBackTo` is the load-bearing one. The commit ledger's `objects` map is
|
|
8
|
+
* capped and prunes the oldest, so its silence about an object staged before
|
|
9
|
+
* that horizon is "I forgot", not "it never landed" — and the classifier can
|
|
10
|
+
* only tell those apart if it knows how far back the ledger still remembers.
|
|
11
|
+
*/
|
|
12
|
+
import { readRawEvidenceCursor } from "./cursors/raw-evidence-cursor.js";
|
|
13
|
+
import { readReconcileCursor, } from "./cursors/raw-evidence-reconcile-cursor.js";
|
|
14
|
+
import { readRawEvidenceStagingState } from "./raw-evidence-staging.js";
|
|
15
|
+
/**
|
|
16
|
+
* The commit record, read from the one place that writes it: the raw-evidence
|
|
17
|
+
* cursor's `objects` map (`upload.ts` → `markObjectCommitted`, only ever for an
|
|
18
|
+
* outcome that was not `upload_failed`).
|
|
19
|
+
*/
|
|
20
|
+
export async function readCommitLedger(paths) {
|
|
21
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
22
|
+
const committed = new Map();
|
|
23
|
+
let oldest = null;
|
|
24
|
+
for (const [hash, entry] of Object.entries(cursor.objects)) {
|
|
25
|
+
committed.set(hash, entry.committed_at);
|
|
26
|
+
if (!oldest || entry.committed_at.localeCompare(oldest) < 0) {
|
|
27
|
+
oldest = entry.committed_at;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { committed, reachesBackTo: oldest };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The reconcile cursor as a lookup map. Read alongside the commit ledger so an
|
|
34
|
+
* object the server confirmed committed classifies exactly like one this
|
|
35
|
+
* machine's own ledger still remembers.
|
|
36
|
+
*/
|
|
37
|
+
export async function readReconcileLedger(paths) {
|
|
38
|
+
const cursor = await readReconcileCursor(paths);
|
|
39
|
+
return new Map(Object.entries(cursor.results));
|
|
40
|
+
}
|
|
41
|
+
/** `pack_id/relative_path` → when this machine staged those bytes. */
|
|
42
|
+
export async function readStagedAtIndex(paths) {
|
|
43
|
+
const staging = await readRawEvidenceStagingState(paths.state_dir);
|
|
44
|
+
const index = new Map();
|
|
45
|
+
for (const entry of Object.values(staging.staged)) {
|
|
46
|
+
index.set(`${entry.pack_id}/${entry.relative_path}`, entry.staged_at);
|
|
47
|
+
}
|
|
48
|
+
return index;
|
|
49
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The walk itself: read the three records once, then visit every pack under
|
|
3
|
+
* `raw-evidence`, classify each file in it, and add it to the totals.
|
|
4
|
+
*
|
|
5
|
+
* This is the entry the prune, `cockpit clean`, the redelivery plan and the
|
|
6
|
+
* heartbeat all spend, so the pass is deliberately cheap — one `stat` per file
|
|
7
|
+
* for two facts (its size and when its bytes were written here), hashes off
|
|
8
|
+
* each pack's own manifest, and a single byte budget shared across the run.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { classifyObject, } from "./disk-usage-classify.js";
|
|
13
|
+
import { RAW_EVIDENCE_DIR, } from "./disk-usage-facts.js";
|
|
14
|
+
import { directoryBytes, listPackFiles, readManifestIndex, UNNAMED_FILE_HASH_BUDGET_BYTES, } from "./disk-usage-files.js";
|
|
15
|
+
import { readCommitLedger, readReconcileLedger, readStagedAtIndex, } from "./disk-usage-ledger.js";
|
|
16
|
+
import { accumulatePack, emptyStagingInventory, rankUncommittedReasons, } from "./disk-usage-totals.js";
|
|
17
|
+
export async function readStagingInventory(paths, now = new Date()) {
|
|
18
|
+
const root = path.join(paths.state_dir, RAW_EVIDENCE_DIR);
|
|
19
|
+
const ledger = await readCommitLedger(paths);
|
|
20
|
+
const reconciled = await readReconcileLedger(paths);
|
|
21
|
+
const stagedAt = await readStagedAtIndex(paths);
|
|
22
|
+
const entries = await fs
|
|
23
|
+
.readdir(root, { withFileTypes: true })
|
|
24
|
+
.catch(() => []);
|
|
25
|
+
const inventory = emptyStagingInventory();
|
|
26
|
+
const budget = { remaining: UNNAMED_FILE_HASH_BUDGET_BYTES };
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isDirectory())
|
|
29
|
+
continue;
|
|
30
|
+
const dir = path.join(root, entry.name);
|
|
31
|
+
if (entry.name.startsWith(".staging-")) {
|
|
32
|
+
inventory.orphan_staging_dirs += 1;
|
|
33
|
+
inventory.orphan_staging_bytes += await directoryBytes(dir);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!entry.name.startsWith("work-"))
|
|
37
|
+
continue;
|
|
38
|
+
const pack = await readPack(dir, entry.name, {
|
|
39
|
+
ledger,
|
|
40
|
+
reconciled,
|
|
41
|
+
stagedAt,
|
|
42
|
+
now,
|
|
43
|
+
budget,
|
|
44
|
+
});
|
|
45
|
+
inventory.packs.push(pack);
|
|
46
|
+
accumulatePack(inventory, pack);
|
|
47
|
+
}
|
|
48
|
+
rankUncommittedReasons(inventory);
|
|
49
|
+
return inventory;
|
|
50
|
+
}
|
|
51
|
+
/** One pack: its manifest's own bytes, and a classified fact per payload file. */
|
|
52
|
+
async function readPack(dir, packId, options) {
|
|
53
|
+
const manifest = await readManifestIndex(dir);
|
|
54
|
+
const pack = {
|
|
55
|
+
pack_id: packId,
|
|
56
|
+
objects: [],
|
|
57
|
+
manifest_bytes: 0,
|
|
58
|
+
manifest_present: manifest !== null,
|
|
59
|
+
};
|
|
60
|
+
for (const relative of await listPackFiles(dir)) {
|
|
61
|
+
// One stat, two facts: how big the staged copy is and WHEN it was written.
|
|
62
|
+
// The mtime is the only unfalsifiable record of how long undelivered
|
|
63
|
+
// evidence has been sitting here (BLI-3797) — every other timestamp on the
|
|
64
|
+
// machine records when something last had an opinion about it.
|
|
65
|
+
const info = await fs.stat(path.join(dir, relative)).catch(() => null);
|
|
66
|
+
const size = info?.isFile() ? info.size : 0;
|
|
67
|
+
if (relative === "manifest.json") {
|
|
68
|
+
pack.manifest_bytes += size;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const stagedOnDiskAt = info && Number.isFinite(info.mtimeMs)
|
|
72
|
+
? new Date(info.mtimeMs).toISOString()
|
|
73
|
+
: null;
|
|
74
|
+
pack.objects.push(await classifyObject(dir, packId, relative, size, manifest, {
|
|
75
|
+
...options,
|
|
76
|
+
stagedOnDiskAt,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
return pack;
|
|
80
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function emptyStagingInventory() {
|
|
2
|
+
return {
|
|
3
|
+
pack_count: 0,
|
|
4
|
+
object_count: 0,
|
|
5
|
+
total_bytes: 0,
|
|
6
|
+
committed_count: 0,
|
|
7
|
+
committed_bytes: 0,
|
|
8
|
+
uncommitted_count: 0,
|
|
9
|
+
uncommitted_bytes: 0,
|
|
10
|
+
oldest_uncommitted_at: null,
|
|
11
|
+
uncommitted_reasons: [],
|
|
12
|
+
dominant_uncommitted_reason: null,
|
|
13
|
+
unknown_count: 0,
|
|
14
|
+
unknown_bytes: 0,
|
|
15
|
+
manifest_bytes: 0,
|
|
16
|
+
orphan_staging_dirs: 0,
|
|
17
|
+
orphan_staging_bytes: 0,
|
|
18
|
+
packs: [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** One pack's objects against the running totals, each in its own state. */
|
|
22
|
+
export function accumulatePack(inventory, pack) {
|
|
23
|
+
inventory.pack_count += 1;
|
|
24
|
+
inventory.manifest_bytes += pack.manifest_bytes;
|
|
25
|
+
inventory.total_bytes += pack.manifest_bytes;
|
|
26
|
+
for (const object of pack.objects) {
|
|
27
|
+
inventory.object_count += 1;
|
|
28
|
+
inventory.total_bytes += object.byte_size;
|
|
29
|
+
if (object.state === "committed") {
|
|
30
|
+
inventory.committed_count += 1;
|
|
31
|
+
inventory.committed_bytes += object.byte_size;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (object.state === "unknown") {
|
|
35
|
+
inventory.unknown_count += 1;
|
|
36
|
+
inventory.unknown_bytes += object.byte_size;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
inventory.uncommitted_count += 1;
|
|
40
|
+
inventory.uncommitted_bytes += object.byte_size;
|
|
41
|
+
tallyUncommittedReason(inventory, object);
|
|
42
|
+
// The age of the BYTES, not of the answer about them (BLI-3797). Falls back
|
|
43
|
+
// to `decided_at` only when the file has no readable mtime, which is the
|
|
44
|
+
// one case where the disk itself cannot say.
|
|
45
|
+
const stagedAt = object.staged_on_disk_at ?? object.decided_at;
|
|
46
|
+
if (stagedAt &&
|
|
47
|
+
(!inventory.oldest_uncommitted_at ||
|
|
48
|
+
stagedAt.localeCompare(inventory.oldest_uncommitted_at) < 0)) {
|
|
49
|
+
inventory.oldest_uncommitted_at = stagedAt;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One more object against its reason label. Kept as a mutable array rather than
|
|
55
|
+
* a Map so the inventory stays a plain serializable value — it rides `--json`
|
|
56
|
+
* output and a receipt, and a Map would silently become `{}` in both.
|
|
57
|
+
*/
|
|
58
|
+
function tallyUncommittedReason(inventory, object) {
|
|
59
|
+
const existing = inventory.uncommitted_reasons.find((tally) => tally.reason === object.reason);
|
|
60
|
+
const tally = existing ?? {
|
|
61
|
+
reason: object.reason,
|
|
62
|
+
count: 0,
|
|
63
|
+
bytes: 0,
|
|
64
|
+
oldest_disk_age_ms: 0,
|
|
65
|
+
};
|
|
66
|
+
tally.count += 1;
|
|
67
|
+
tally.bytes += object.byte_size;
|
|
68
|
+
tally.oldest_disk_age_ms = Math.max(tally.oldest_disk_age_ms, object.disk_age_ms || object.age_ms);
|
|
69
|
+
if (!existing)
|
|
70
|
+
inventory.uncommitted_reasons.push(tally);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Rank the reasons by BYTES, then name the biggest one.
|
|
74
|
+
*
|
|
75
|
+
* Bytes, not object count, because the question the ranking answers is "what is
|
|
76
|
+
* holding this laptop over its cap" — 234 objects at 1,836 MB and 17 at 81 MB
|
|
77
|
+
* are not the same finding, and counting rows would have called them close.
|
|
78
|
+
*/
|
|
79
|
+
export function rankUncommittedReasons(inventory) {
|
|
80
|
+
inventory.uncommitted_reasons.sort((left, right) => right.bytes - left.bytes || right.count - left.count);
|
|
81
|
+
inventory.dominant_uncommitted_reason =
|
|
82
|
+
inventory.uncommitted_reasons[0] ?? null;
|
|
83
|
+
}
|