@celilo/cli 0.17.0 → 0.19.0
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/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +38 -9
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/api/remote-client.test.ts +62 -0
- package/src/api/serve.ts +14 -6
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/apt-upgrade.test.ts +20 -1
- package/src/cli/commands/apt-upgrade.ts +12 -2
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/events.ts +90 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/module-update.test.ts +72 -1
- package/src/cli/commands/module-update.ts +45 -22
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-migrate.test.ts +56 -0
- package/src/cli/commands/system-migrate.ts +92 -4
- package/src/cli/commands/system-update.ts +5 -0
- package/src/cli/completion.ts +19 -0
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/tui/audit-state.ts +4 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/migration-status.test.ts +114 -0
- package/src/db/migration-status.ts +78 -0
- package/src/db/schema-introspection.ts +8 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +2 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +9 -0
- package/src/services/audit/types.ts +2 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/bus-interview.ts +11 -5
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/events-daemon.test.ts +244 -0
- package/src/services/events-daemon.ts +295 -8
- package/src/services/fleet-checks.test.ts +75 -4
- package/src/services/fleet-checks.ts +97 -12
- package/src/services/interview-errors.ts +20 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/remote-responder.test.ts +70 -0
- package/src/services/remote-responder.ts +27 -10
- package/src/services/responder-probe.ts +3 -1
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/variables/context.ts +6 -1
|
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import type { ModuleManifest } from '../manifest/schema';
|
|
3
3
|
import type { BackupSchedule } from './backup-schedule';
|
|
4
4
|
import {
|
|
5
|
+
BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
5
6
|
BACKUP_SWEEP_PATTERN,
|
|
6
7
|
BACKUP_SWEEP_SUBSCRIBER,
|
|
8
|
+
BACKUP_SWEEP_TIMEOUT_MS,
|
|
7
9
|
type BackupSweepDeps,
|
|
8
10
|
type BackupSweepModule,
|
|
9
11
|
ensureBackupSweepSubscriber,
|
|
@@ -33,11 +35,54 @@ function deps(
|
|
|
33
35
|
prune: async ({ id }) => {
|
|
34
36
|
pruned.push(id);
|
|
35
37
|
},
|
|
38
|
+
reapStaging: () => ({ reclaimed: [], kept: [], ignored: [] }),
|
|
36
39
|
...overrides,
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
describe('runBackupSweep', () => {
|
|
44
|
+
// Ordering is the point, not just that it happens: this pass is the only
|
|
45
|
+
// thing creating staging on a schedule, and a box already short on disk
|
|
46
|
+
// needs the space back BEFORE another few GB are requested.
|
|
47
|
+
test('reclaims orphaned staging before backing anything up', async () => {
|
|
48
|
+
const order: string[] = [];
|
|
49
|
+
const report = await runBackupSweep(
|
|
50
|
+
deps([moduleWith('forgejo', 'daily')], {
|
|
51
|
+
reapStaging: () => {
|
|
52
|
+
order.push('reap');
|
|
53
|
+
return {
|
|
54
|
+
reclaimed: [{ path: '/tmp/celilo-backup-x', recordId: 'x', reason: 'process-dead' }],
|
|
55
|
+
kept: [],
|
|
56
|
+
ignored: [],
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
backup: async () => {
|
|
60
|
+
order.push('backup');
|
|
61
|
+
return { success: true };
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(order).toEqual(['reap', 'backup']);
|
|
67
|
+
expect(report.staging.reclaimed).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('reclaims staging even when no module is due to back up', async () => {
|
|
71
|
+
const report = await runBackupSweep(
|
|
72
|
+
deps([moduleWith('forgejo', 'daily')], {
|
|
73
|
+
isDue: () => false,
|
|
74
|
+
reapStaging: () => ({
|
|
75
|
+
reclaimed: [{ path: '/tmp/celilo-backup-y', recordId: 'y', reason: 'record-absent' }],
|
|
76
|
+
kept: [],
|
|
77
|
+
ignored: [],
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(report.backedUp).toEqual([]);
|
|
83
|
+
expect(report.staging.reclaimed).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
41
86
|
test('backs up a module whose declared cadence is due', async () => {
|
|
42
87
|
const d = deps([moduleWith('authentik', 'daily')]);
|
|
43
88
|
const report = await runBackupSweep(d);
|
|
@@ -129,6 +174,8 @@ describe('ensureBackupSweepSubscriber', () => {
|
|
|
129
174
|
pattern: string;
|
|
130
175
|
handler: string;
|
|
131
176
|
registeredBy?: string;
|
|
177
|
+
maxAttempts?: number;
|
|
178
|
+
timeoutMs?: number;
|
|
132
179
|
}> = [];
|
|
133
180
|
ensureBackupSweepSubscriber({
|
|
134
181
|
subscribe: (options) => calls.push(options),
|
|
@@ -140,9 +187,30 @@ describe('ensureBackupSweepSubscriber', () => {
|
|
|
140
187
|
pattern: BACKUP_SWEEP_PATTERN,
|
|
141
188
|
handler: 'celilo backup sweep',
|
|
142
189
|
registeredBy: 'celilo-backup',
|
|
190
|
+
maxAttempts: BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
191
|
+
timeoutMs: BACKUP_SWEEP_TIMEOUT_MS,
|
|
143
192
|
},
|
|
144
193
|
]);
|
|
145
194
|
// 1h is the coarsest tick that can still serve an `hourly` cadence.
|
|
146
195
|
expect(BACKUP_SWEEP_PATTERN).toBe('timer.tick.1h');
|
|
147
196
|
});
|
|
197
|
+
|
|
198
|
+
// Both values are the bug. Registering without them inherits the bus
|
|
199
|
+
// defaults of 60000ms / 3 attempts, which cannot finish a backup that needs
|
|
200
|
+
// ~5.5 minutes and then retries the impossible twice more per tick — three
|
|
201
|
+
// killed backups and three stranded staging directories every hour.
|
|
202
|
+
test('states the budget explicitly rather than inheriting the bus defaults', () => {
|
|
203
|
+
let registered: { maxAttempts?: number; timeoutMs?: number } | undefined;
|
|
204
|
+
ensureBackupSweepSubscriber({
|
|
205
|
+
subscribe: (options) => {
|
|
206
|
+
registered = options;
|
|
207
|
+
return options;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(registered?.timeoutMs).toBeDefined();
|
|
212
|
+
expect(registered?.maxAttempts).toBeDefined();
|
|
213
|
+
expect(registered?.timeoutMs).toBeGreaterThan(60_000);
|
|
214
|
+
expect(registered?.maxAttempts).toBe(1);
|
|
215
|
+
});
|
|
148
216
|
});
|
|
@@ -18,23 +18,72 @@
|
|
|
18
18
|
|
|
19
19
|
import type { ModuleManifest } from '../manifest/schema';
|
|
20
20
|
import { type BackupSchedule, effectiveBackupSchedule } from './backup-schedule';
|
|
21
|
+
import type { ReapStagingReport } from './backup-staging';
|
|
21
22
|
import { InFlightError } from './module-operations';
|
|
22
23
|
|
|
23
24
|
export const BACKUP_SWEEP_SUBSCRIBER = 'celilo-backup-sweep';
|
|
24
25
|
export const BACKUP_SWEEP_PATTERN = 'timer.tick.1h';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* How long the sweep may run before the dispatcher kills it.
|
|
29
|
+
*
|
|
30
|
+
* Set EXPLICITLY because the bus default is 60 seconds and this pass cannot
|
|
31
|
+
* finish in 60 seconds. A single forgejo backup measured 2026-08-06 takes ~5.5
|
|
32
|
+
* minutes — snapshotting SQLite, archiving repositories, and streaming ~1.3 GB
|
|
33
|
+
* over SSH — and the sweep runs every due module serially, so the budget covers
|
|
34
|
+
* the sum rather than the slowest one.
|
|
35
|
+
*
|
|
36
|
+
* Inheriting the default made every scheduled forgejo backup structurally
|
|
37
|
+
* impossible: killed at 60s, mid-encrypt, three times an hour, for days.
|
|
38
|
+
*
|
|
39
|
+
* Four hours is generous on purpose. The cost of it being too large is one
|
|
40
|
+
* delayed reclamation of a wedged sweep; the cost of it being too small is a
|
|
41
|
+
* backup that can never succeed. `celilo-mgmt.registry-poll` set the precedent
|
|
42
|
+
* at 30 minutes for the same reason.
|
|
43
|
+
*
|
|
44
|
+
* ponytail: one flat number for a serial sweep. If the fleet grows enough that
|
|
45
|
+
* the sum stops fitting, the upgrade is for the sweep to emit a per-module
|
|
46
|
+
* backup event carrying its own budget.
|
|
47
|
+
*/
|
|
48
|
+
export const BACKUP_SWEEP_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One attempt per tick.
|
|
52
|
+
*
|
|
53
|
+
* The bus default of 3 is right for a transient fault and wrong for this pass.
|
|
54
|
+
* A sweep that cannot finish inside its budget will not finish on the retry
|
|
55
|
+
* either — retrying it produced three killed backups and three stranded staging
|
|
56
|
+
* directories per hour instead of one, which is how 27 GB accumulated in 5.7
|
|
57
|
+
* hours. The hourly tick is already the retry.
|
|
58
|
+
*/
|
|
59
|
+
export const BACKUP_SWEEP_MAX_ATTEMPTS = 1;
|
|
60
|
+
|
|
26
61
|
export interface SubscriberRegistrar {
|
|
27
62
|
subscribe(options: {
|
|
28
63
|
name: string;
|
|
29
64
|
pattern: string;
|
|
30
65
|
handler: string;
|
|
31
66
|
registeredBy?: string;
|
|
67
|
+
maxAttempts?: number;
|
|
68
|
+
timeoutMs?: number;
|
|
32
69
|
}): unknown;
|
|
33
70
|
}
|
|
34
71
|
|
|
35
72
|
/**
|
|
36
73
|
* Idempotent: `bus.subscribe` upserts by name, so this is safe to call on
|
|
37
74
|
* every module install and update.
|
|
75
|
+
*
|
|
76
|
+
* Also called from `celilo system migrate`, which the .deb postinst runs on
|
|
77
|
+
* every apt upgrade. Registering only from module install/update meant a
|
|
78
|
+
* corrected budget would not reach an existing fleet until some module happened
|
|
79
|
+
* to be touched next — indistinguishable from a fix that shipped and silently
|
|
80
|
+
* did nothing. celilo-mgr sat at the 60s default with the row already present.
|
|
81
|
+
*
|
|
82
|
+
* This re-arms a deliberately paused sweep, and that is intentional: the pause
|
|
83
|
+
* only ever existed because staging leaked, and the reaper that makes it not
|
|
84
|
+
* leak ships in this same binary. Re-arming without the reaper present is the
|
|
85
|
+
* failure this comment exists to prevent — do not lift this call into a release
|
|
86
|
+
* that does not carry `reapOrphanedStaging`.
|
|
38
87
|
*/
|
|
39
88
|
export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
40
89
|
bus.subscribe({
|
|
@@ -42,6 +91,8 @@ export function ensureBackupSweepSubscriber(bus: SubscriberRegistrar): void {
|
|
|
42
91
|
pattern: BACKUP_SWEEP_PATTERN,
|
|
43
92
|
handler: 'celilo backup sweep',
|
|
44
93
|
registeredBy: 'celilo-backup',
|
|
94
|
+
maxAttempts: BACKUP_SWEEP_MAX_ATTEMPTS,
|
|
95
|
+
timeoutMs: BACKUP_SWEEP_TIMEOUT_MS,
|
|
45
96
|
});
|
|
46
97
|
}
|
|
47
98
|
|
|
@@ -57,9 +108,13 @@ export interface BackupSweepDeps {
|
|
|
57
108
|
backup(moduleId: string): Promise<{ success: boolean; error?: string }>;
|
|
58
109
|
/** Apply the module's declared retention. No-op when it declares none. */
|
|
59
110
|
prune(module: BackupSweepModule): Promise<void>;
|
|
111
|
+
/** Reclaim staging left by backups whose process died. See backup-staging.ts. */
|
|
112
|
+
reapStaging(): ReapStagingReport;
|
|
60
113
|
}
|
|
61
114
|
|
|
62
115
|
export interface BackupSweepReport {
|
|
116
|
+
/** Staging reclaimed before this pass created any of its own. */
|
|
117
|
+
staging: ReapStagingReport;
|
|
63
118
|
backedUp: string[];
|
|
64
119
|
/** Explicit `schedule: manual` — the author opted out. */
|
|
65
120
|
skippedManual: string[];
|
|
@@ -70,7 +125,14 @@ export interface BackupSweepReport {
|
|
|
70
125
|
}
|
|
71
126
|
|
|
72
127
|
export async function runBackupSweep(deps: BackupSweepDeps): Promise<BackupSweepReport> {
|
|
128
|
+
// Reclaim BEFORE backing anything up, not after. This pass is the only thing
|
|
129
|
+
// that creates staging on a schedule, so it is where the orphans come from —
|
|
130
|
+
// and a box already short on disk needs the space freed before we ask for
|
|
131
|
+
// another ~4 GB of it, not once we are finished with it.
|
|
132
|
+
const staging = deps.reapStaging();
|
|
133
|
+
|
|
73
134
|
const report: BackupSweepReport = {
|
|
135
|
+
staging,
|
|
74
136
|
backedUp: [],
|
|
75
137
|
skippedManual: [],
|
|
76
138
|
skippedNotDue: [],
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { type Bus, defineEvents, openBus } from '@celilo/event-bus';
|
|
16
16
|
import { getEventBusPath } from '../config/paths';
|
|
17
|
+
import { InterviewUnansweredError } from './interview-errors';
|
|
17
18
|
import { ensureResponderForInterview } from './responder-probe';
|
|
18
19
|
|
|
19
20
|
const NO_SCHEMAS = defineEvents({});
|
|
@@ -239,6 +240,13 @@ export interface InterviewRequiredPayload {
|
|
|
239
240
|
*/
|
|
240
241
|
export interface InterviewReply {
|
|
241
242
|
value: unknown;
|
|
243
|
+
/**
|
|
244
|
+
* Set instead of `value` when the responder could not reach a decider (e.g.
|
|
245
|
+
* the remote client has no TTY and no pre-staged answer). `askInterview`
|
|
246
|
+
* turns it into an `InterviewUnansweredError` so the question fails loudly
|
|
247
|
+
* rather than silently resolving to `defaultValue`.
|
|
248
|
+
*/
|
|
249
|
+
error?: string;
|
|
242
250
|
}
|
|
243
251
|
|
|
244
252
|
/**
|
|
@@ -314,11 +322,9 @@ export async function askInterview(
|
|
|
314
322
|
payload: InterviewRequiredPayload,
|
|
315
323
|
ownerBus?: Bus,
|
|
316
324
|
): Promise<unknown> {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
ownerBus,
|
|
321
|
-
);
|
|
325
|
+
const type = EVENT_TYPES.interviewRequired(payload.scope, payload.key);
|
|
326
|
+
const reply = await busInterviewGuarded<InterviewReply>(type, payload, ownerBus);
|
|
327
|
+
if (reply.error) throw new InterviewUnansweredError(type, reply.error);
|
|
322
328
|
return reply.value;
|
|
323
329
|
}
|
|
324
330
|
|
|
@@ -260,7 +260,7 @@ export async function autoDeriveMachineConfig(
|
|
|
260
260
|
upsertModuleConfig(db, moduleId, variable.name, derived);
|
|
261
261
|
configured.push(variable.name);
|
|
262
262
|
|
|
263
|
-
// Handle per_selection follow-ups (e.g.,
|
|
263
|
+
// Handle per_selection follow-ups (e.g., `zone.<zone>.ip` from the zone list)
|
|
264
264
|
if (variable.options && variable.per_selection) {
|
|
265
265
|
for (const selectedVal of derived.split(',')) {
|
|
266
266
|
const followUpKey = variable.per_selection.key_pattern.replace('{value}', selectedVal);
|
|
@@ -22,7 +22,6 @@ export interface AnsibleResult {
|
|
|
22
22
|
* Parse raw Ansible output lines into concise human-readable status.
|
|
23
23
|
* Returns null for lines that should be suppressed (decorative separators, etc.)
|
|
24
24
|
*/
|
|
25
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally stripping ANSI escape codes
|
|
26
25
|
const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
|
|
27
26
|
|
|
28
27
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { parseDfOutput, percentUsed } from './disk-probe';
|
|
3
|
+
|
|
4
|
+
describe('percentUsed', () => {
|
|
5
|
+
// Checked against what celilo-mgr's df actually printed, because an operator
|
|
6
|
+
// comparing an alert to their own df must see the same figure.
|
|
7
|
+
//
|
|
8
|
+
// 117G root, 11G used, 102G available — after the staging was cleared.
|
|
9
|
+
test('matches df for celilo-mgr at 10% used', () => {
|
|
10
|
+
expect(percentUsed(122_683_392, 111_149_056, 106_954_752)).toBe(10);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Same filesystem at 38G used / 75G available — what df reported while the
|
|
14
|
+
// leak was running.
|
|
15
|
+
test('matches df for celilo-mgr at 34% used', () => {
|
|
16
|
+
expect(percentUsed(122_683_392, 82_837_504, 78_643_200)).toBe(34);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Not 1 - bavail/blocks. A filesystem reserves blocks for root, so
|
|
20
|
+
// free-to-root and free-to-everyone differ; df computes capacity against
|
|
21
|
+
// what an ordinary process can use. With a large reserve the naive formula
|
|
22
|
+
// over-reports and would page early forever.
|
|
23
|
+
test('excludes root-reserved blocks, as df does', () => {
|
|
24
|
+
// 1000 total, 100 free to root, only 50 usable by others → 900 used of 950.
|
|
25
|
+
expect(percentUsed(1000, 100, 50)).toBe(95);
|
|
26
|
+
// The naive 1 - bavail/blocks would say 95% here too by coincidence, so
|
|
27
|
+
// use a case where they diverge: 1000 total, 500 free, 200 available.
|
|
28
|
+
expect(percentUsed(1000, 500, 200)).toBe(71); // 500 used of 700 usable
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a full filesystem reports 100', () => {
|
|
32
|
+
expect(percentUsed(1000, 0, 0)).toBe(100);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('a degenerate zero-block filesystem does not divide by zero', () => {
|
|
36
|
+
expect(percentUsed(0, 0, 0)).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('parseDfOutput', () => {
|
|
41
|
+
test('parses real df -P output', () => {
|
|
42
|
+
const out = [
|
|
43
|
+
'Filesystem 1024-blocks Used Available Capacity Mounted on',
|
|
44
|
+
'/dev/mmcblk0p2 120699413 11534336 106954752 10% /',
|
|
45
|
+
].join('\n');
|
|
46
|
+
|
|
47
|
+
expect(parseDfOutput(out)).toEqual({
|
|
48
|
+
usedPercent: 10,
|
|
49
|
+
availableBytes: 106_954_752 * 1024,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('parses a nearly-full filesystem', () => {
|
|
54
|
+
const out = [
|
|
55
|
+
'Filesystem 1024-blocks Used Available Capacity Mounted on',
|
|
56
|
+
'/dev/sda1 41284928 39220684 966140 98% /',
|
|
57
|
+
].join('\n');
|
|
58
|
+
|
|
59
|
+
expect(parseDfOutput(out)?.usedPercent).toBe(98);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns null rather than guessing on unusable output', () => {
|
|
63
|
+
expect(parseDfOutput('')).toBeNull();
|
|
64
|
+
expect(parseDfOutput('Filesystem 1024-blocks Used Available Capacity Mounted on')).toBeNull();
|
|
65
|
+
expect(parseDfOutput('header\ntoo few fields')).toBeNull();
|
|
66
|
+
expect(parseDfOutput('header\n/dev/sda1 a b c d /')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('tolerates leading whitespace and extra columns', () => {
|
|
70
|
+
const out =
|
|
71
|
+
' Filesystem 1024-blocks Used Available Capacity Mounted on\n /dev/sda1 100 50 40 56% / extra';
|
|
72
|
+
expect(parseDfOutput(out)?.usedPercent).toBe(56);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-usage probe for the machine pool.
|
|
3
|
+
*
|
|
4
|
+
* Structurally a sibling of `machine-probe.ts` — same pool, same SSH bounding —
|
|
5
|
+
* with one deliberate difference that is the whole reason this file has its own
|
|
6
|
+
* comment.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ THE LOCAL BOX IS MEASURED, NOT EXEMPTED.
|
|
9
|
+
*
|
|
10
|
+
* `probeMachines()` reports the management server reachable WITHOUT probing it,
|
|
11
|
+
* and that is correct there: celilo runs as a user with no SSH key for itself,
|
|
12
|
+
* so `ssh root@127.0.0.1` fails on a perfectly healthy host — and the question
|
|
13
|
+
* is meaningless anyway, since if this box were unreachable this code would not
|
|
14
|
+
* be running.
|
|
15
|
+
*
|
|
16
|
+
* None of that transfers to disk. The management server stages backups, caches
|
|
17
|
+
* modules, holds the celilo DB and writes the logs; it is the likeliest host in
|
|
18
|
+
* the fleet to fill, and it is the host that DID fill. A disk check that copied
|
|
19
|
+
* the probe's structure and inherited its local shortcut would skip the only
|
|
20
|
+
* machine the check exists to protect — reporting "all clear" about a
|
|
21
|
+
* filesystem it never looked at.
|
|
22
|
+
*
|
|
23
|
+
* So the local box reads `statfs` directly. There is no reachability question
|
|
24
|
+
* to answer about the machine running the code, only a usage one.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFile } from 'node:child_process';
|
|
28
|
+
import { statfs } from 'node:fs/promises';
|
|
29
|
+
import { promisify } from 'node:util';
|
|
30
|
+
import type { DiskUsageResult } from './audit/disk-space';
|
|
31
|
+
import { listMachines } from './machine-pool';
|
|
32
|
+
import { LOCAL_MACHINE_IP } from './ssh-key-manager';
|
|
33
|
+
|
|
34
|
+
const execFileAsync = promisify(execFile);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Percent-used the way `df` reports it.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not `1 - bavail/blocks`. A filesystem reserves blocks for root,
|
|
40
|
+
* so free-to-root and free-to-everyone-else differ; `df` computes capacity
|
|
41
|
+
* against the space an ordinary process can actually use, and an operator
|
|
42
|
+
* comparing this alert to their own `df` output must see the same number.
|
|
43
|
+
*/
|
|
44
|
+
export function percentUsed(totalBlocks: number, freeBlocks: number, availBlocks: number): number {
|
|
45
|
+
const used = totalBlocks - freeBlocks;
|
|
46
|
+
const usable = used + availBlocks;
|
|
47
|
+
if (usable <= 0) return 0;
|
|
48
|
+
return Math.round((used / usable) * 100);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse the data row of `df -P /`. Returns null when the output is unusable. */
|
|
52
|
+
export function parseDfOutput(
|
|
53
|
+
stdout: string,
|
|
54
|
+
): { usedPercent: number; availableBytes: number } | null {
|
|
55
|
+
// -P guarantees one line per filesystem, so the row we want is the second.
|
|
56
|
+
const line = stdout.trim().split('\n')[1];
|
|
57
|
+
if (!line) return null;
|
|
58
|
+
|
|
59
|
+
// Filesystem 1024-blocks Used Available Capacity Mounted-on
|
|
60
|
+
const fields = line.trim().split(/\s+/);
|
|
61
|
+
if (fields.length < 5) return null;
|
|
62
|
+
|
|
63
|
+
const available = Number(fields[3]);
|
|
64
|
+
const percent = Number(fields[4]?.replace('%', ''));
|
|
65
|
+
if (!Number.isFinite(available) || !Number.isFinite(percent)) return null;
|
|
66
|
+
|
|
67
|
+
return { usedPercent: percent, availableBytes: available * 1024 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function probeLocal(hostname: string, ipAddress: string): Promise<DiskUsageResult> {
|
|
71
|
+
try {
|
|
72
|
+
const stats = await statfs('/');
|
|
73
|
+
return {
|
|
74
|
+
hostname,
|
|
75
|
+
ipAddress,
|
|
76
|
+
usedPercent: percentUsed(stats.blocks, stats.bfree, stats.bavail),
|
|
77
|
+
availableBytes: stats.bavail * stats.bsize,
|
|
78
|
+
};
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
hostname,
|
|
82
|
+
ipAddress,
|
|
83
|
+
usedPercent: null,
|
|
84
|
+
message: err instanceof Error ? err.message : String(err),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function probeRemote(
|
|
90
|
+
hostname: string,
|
|
91
|
+
ipAddress: string,
|
|
92
|
+
sshUser: string,
|
|
93
|
+
): Promise<DiskUsageResult> {
|
|
94
|
+
try {
|
|
95
|
+
// Same bounding as machine-probe: BatchMode so a password prompt can never
|
|
96
|
+
// hang the probe, ConnectTimeout so an unresponsive host — the condition
|
|
97
|
+
// this check exists to notice — cannot wedge it.
|
|
98
|
+
const { stdout } = await execFileAsync(
|
|
99
|
+
'ssh',
|
|
100
|
+
[
|
|
101
|
+
'-o',
|
|
102
|
+
'BatchMode=yes',
|
|
103
|
+
'-o',
|
|
104
|
+
'ConnectTimeout=5',
|
|
105
|
+
'-o',
|
|
106
|
+
'StrictHostKeyChecking=no',
|
|
107
|
+
'-o',
|
|
108
|
+
'UserKnownHostsFile=/dev/null',
|
|
109
|
+
`${sshUser}@${ipAddress}`,
|
|
110
|
+
'df -P /',
|
|
111
|
+
],
|
|
112
|
+
{ timeout: 8000 },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const parsed = parseDfOutput(stdout);
|
|
116
|
+
if (!parsed) {
|
|
117
|
+
return {
|
|
118
|
+
hostname,
|
|
119
|
+
ipAddress,
|
|
120
|
+
usedPercent: null,
|
|
121
|
+
message: `unparseable df output: ${stdout}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { hostname, ipAddress, ...parsed };
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const e = err as { stderr?: string; message?: string };
|
|
127
|
+
return {
|
|
128
|
+
hostname,
|
|
129
|
+
ipAddress,
|
|
130
|
+
usedPercent: null,
|
|
131
|
+
message: e.stderr?.trim() || e.message || 'df probe failed',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function probeDiskUsage(): Promise<DiskUsageResult[]> {
|
|
137
|
+
const machines = await listMachines();
|
|
138
|
+
return Promise.all(
|
|
139
|
+
machines.map((m) =>
|
|
140
|
+
m.ipAddress === LOCAL_MACHINE_IP
|
|
141
|
+
? probeLocal(m.hostname, m.ipAddress)
|
|
142
|
+
: probeRemote(m.hostname, m.ipAddress, m.sshUser),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|