@celilo/cli 0.17.0 → 0.18.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.
Files changed (52) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +35 -8
  3. package/drizzle/0019_backup_pid.sql +18 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +5 -5
  6. package/schemas/system_config.json +1 -1
  7. package/src/cli/command-tree-parser.ts +0 -1
  8. package/src/cli/commands/backup-sweep.ts +62 -0
  9. package/src/cli/commands/module-operations.test.ts +45 -1
  10. package/src/cli/commands/module-operations.ts +35 -12
  11. package/src/cli/commands/module-show.ts +1 -0
  12. package/src/cli/commands/system-audit.ts +2 -0
  13. package/src/cli/commands/system-migrate.ts +40 -0
  14. package/src/cli/commands/system-update.ts +5 -0
  15. package/src/cli/completion.ts +18 -0
  16. package/src/cli/fuel-gauge.ts +0 -1
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/tui/audit-state.ts +4 -0
  19. package/src/cli/tui/audit-tui.test.tsx +0 -1
  20. package/src/db/schema.ts +53 -9
  21. package/src/hooks/capability-loader.ts +30 -1
  22. package/src/ipam/allocator.ts +13 -3
  23. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  24. package/src/services/alerting/builtin-monitors.ts +2 -0
  25. package/src/services/alerting/builtin-source.ts +15 -0
  26. package/src/services/audit/abandoned-operations.test.ts +73 -0
  27. package/src/services/audit/abandoned-operations.ts +0 -0
  28. package/src/services/audit/disk-space.test.ts +111 -0
  29. package/src/services/audit/disk-space.ts +114 -0
  30. package/src/services/audit/index.test.ts +1 -0
  31. package/src/services/audit/index.ts +9 -0
  32. package/src/services/audit/types.ts +2 -0
  33. package/src/services/backup-create.ts +4 -4
  34. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  35. package/src/services/backup-metadata.ts +4 -0
  36. package/src/services/backup-staging.test.ts +134 -0
  37. package/src/services/backup-staging.ts +192 -0
  38. package/src/services/backup-sweep.test.ts +68 -0
  39. package/src/services/backup-sweep.ts +62 -0
  40. package/src/services/config-interview.ts +1 -1
  41. package/src/services/deploy-ansible.ts +0 -1
  42. package/src/services/disk-probe.test.ts +74 -0
  43. package/src/services/disk-probe.ts +145 -0
  44. package/src/services/fleet-checks.ts +15 -0
  45. package/src/services/module-operations.test.ts +22 -0
  46. package/src/services/module-operations.ts +48 -1
  47. package/src/services/module-subscriptions.test.ts +39 -6
  48. package/src/services/module-subscriptions.ts +6 -4
  49. package/src/services/module-types-generator.test.ts +6 -3
  50. package/src/services/module-types-generator.ts +12 -7
  51. package/src/services/update/orchestrator.test.ts +1 -0
  52. package/src/variables/context.ts +6 -1
@@ -77,12 +77,14 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
77
77
  'module_configs',
78
78
  'health',
79
79
  'backups',
80
+ 'abandoned_operations',
80
81
  'undeployed_modules',
81
82
  'unconfigured_modules',
82
83
  'services_credentials',
83
84
  'secrets_decryptable',
84
85
  'services_reachable',
85
86
  'machines_reachable',
87
+ 'disk_space',
86
88
  'transport_reads',
87
89
  'trusted_sources',
88
90
  ];
@@ -96,12 +98,14 @@ export const CATEGORY_LABELS: Record<DriftCategory, string> = {
96
98
  module_configs: 'Module configs',
97
99
  health: 'Module health',
98
100
  backups: 'Backups',
101
+ abandoned_operations: 'Abandoned operations',
99
102
  undeployed_modules: 'Undeployed modules',
100
103
  unconfigured_modules: 'Unconfigured modules',
101
104
  services_credentials: 'Service credentials',
102
105
  secrets_decryptable: 'Secrets',
103
106
  services_reachable: 'Service reachability',
104
107
  machines_reachable: 'Machine reachability',
108
+ disk_space: 'Disk space',
105
109
  transport_reads: 'Transport readability',
106
110
  trusted_sources: 'Trusted networks',
107
111
  };
@@ -35,7 +35,6 @@ const FIXTURE: SystemAuditReport = {
35
35
  };
36
36
 
37
37
  function strip(s: string): string {
38
- // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping CSI escape sequences
39
38
  return s.replace(/\[[0-9;]*m/g, '');
40
39
  }
41
40
 
package/src/db/schema.ts CHANGED
@@ -249,14 +249,31 @@ export const moduleBuilds = sqliteTable('module_builds', {
249
249
  });
250
250
 
251
251
  /**
252
- * Network zones type
253
- * - internal: Home network (192.168.0.0/24)
254
- * - dmz: Public-facing services in home lab (10.0.10.0/24)
255
- * - app: Internal application services (10.0.20.0/24)
256
- * - secure: Authentication and database services (10.0.30.0/24)
252
+ * Network zones — the DEFINED vocabulary, not the active set.
253
+ *
254
+ * A zone being listed here means celilo knows the name and will accept
255
+ * `network.<zone>.*` config for it. It does NOT mean the zone exists on a given
256
+ * fleet: per openspec/specs/progressive-zone-disclosure/spec.md, "deployable
257
+ * zones are those with a configured subnet", so a zone becomes REAL only when
258
+ * something declares `network.<zone>.subnet` — normally the module that supplies
259
+ * the network. Defining a zone here costs nothing and activates nothing.
260
+ *
261
+ * - internal: the semi-trusted LAN the management server sits on
262
+ * - dmz: public-facing services
263
+ * - app: internal application services
264
+ * - secure: authentication and database services
257
265
  * - secure-mgmt: celilo's own control plane (management server + management-plane
258
266
  * modules). Outside the data-plane tier chain; reaches every tier by trust.
259
- * - external: Internet-hosted services (outside home network)
267
+ * - external: internet-hosted services a cloud/VPS provider's network, NOT a
268
+ * VPN. Addressed by the provider, which is why it is excluded from IPAM below.
269
+ * - control-plane-vpn: the ADMINISTRATIVE remote-access client subnet, activated
270
+ * by the module that terminates the tunnel (`wireguard` writes
271
+ * `network.control-plane-vpn.subnet`). Named for its purpose rather than its
272
+ * technology because a fleet may run more than one VPN — a site-to-site link
273
+ * or a user VPN is a different network with different trust, and `vpn` would
274
+ * have been the wrong name to have to share. Distinct from `external` too:
275
+ * that is someone else's cloud, this is a network the fleet's own firewall
276
+ * holds a leg on and must translate for.
260
277
  */
261
278
  export const NETWORK_ZONES = [
262
279
  'internal',
@@ -265,6 +282,7 @@ export const NETWORK_ZONES = [
265
282
  'secure',
266
283
  'secure-mgmt',
267
284
  'external',
285
+ 'control-plane-vpn',
268
286
  ] as const;
269
287
 
270
288
  /**
@@ -276,15 +294,36 @@ export const NETWORK_ZONES = [
276
294
  export type NetworkZone = (typeof NETWORK_ZONES)[number];
277
295
 
278
296
  /**
279
- * Zones an IP allocation or reservation can name: every NetworkZone except
280
- * `external`, whose systems are addressed by the provider, not by our IPAM.
297
+ * Zones an IP allocation or reservation can name: every NetworkZone whose
298
+ * addresses celilo hands out.
299
+ *
300
+ * Two are excluded, for the same underlying reason — somebody else is the
301
+ * address authority:
302
+ * - `external`, whose systems are addressed by the cloud/VPS provider;
303
+ * - `vpn`, whose client addresses are assigned by the module terminating the
304
+ * tunnel. celilo allocating into that subnet would collide with the VPN
305
+ * server's own assignments.
281
306
  *
282
307
  * Derived rather than hand-written for the same reason as NetworkZone above —
283
308
  * the previous hand-written union was copied into two column definitions and a
284
309
  * cast in machine-pool.ts, and the cast had already drifted (it was missing
285
310
  * `secure-mgmt`, and its comment claimed the only difference was `external`).
286
311
  */
287
- export type AllocatableZone = Exclude<NetworkZone, 'external'>;
312
+ export type AllocatableZone = Exclude<NetworkZone, 'external' | 'control-plane-vpn'>;
313
+
314
+ /** The zones whose addresses celilo hands out, as a runtime list. */
315
+ export const ALLOCATABLE_ZONES: AllocatableZone[] = NETWORK_ZONES.filter(
316
+ (zone): zone is AllocatableZone => zone !== 'external' && zone !== 'control-plane-vpn',
317
+ );
318
+
319
+ /**
320
+ * Is this a zone celilo allocates addresses in? Use this rather than testing
321
+ * `zone !== 'external'` by hand — that check predates `vpn` and read as "the one
322
+ * externally-addressed zone" when there are now two.
323
+ */
324
+ export function isAllocatableZone(zone: NetworkZone): zone is AllocatableZone {
325
+ return (ALLOCATABLE_ZONES as string[]).includes(zone);
326
+ }
288
327
 
289
328
  /**
290
329
  * Container services table
@@ -679,6 +718,11 @@ export const backups = sqliteTable('backups', {
679
718
  status: text('status').$type<BackupStatus>().notNull().default('in_progress'),
680
719
  errorMessage: text('error_message'),
681
720
  name: text('name'), // optional human-readable name/annotation
721
+ // The process assembling this backup's staging directory. Lets the staging
722
+ // reaper (services/backup-staging.ts) tell a live backup from one whose
723
+ // process was killed before its `finally` could clean up. Nullable: rows
724
+ // written before this column existed have no pid and age out via the TTL.
725
+ pid: integer('pid'),
682
726
  startedAt: integer('started_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
683
727
  completedAt: integer('completed_at', { mode: 'timestamp' }),
684
728
  });
@@ -28,7 +28,14 @@ import type {
28
28
  } from '@celilo/capabilities';
29
29
  import { and, eq } from 'drizzle-orm';
30
30
  import type { DbClient } from '../db/client';
31
- import { capabilities, modules, secrets, systemConfig, webRoutes } from '../db/schema';
31
+ import {
32
+ NETWORK_ZONES,
33
+ capabilities,
34
+ modules,
35
+ secrets,
36
+ systemConfig,
37
+ webRoutes,
38
+ } from '../db/schema';
32
39
  import { decryptSecret } from '../secrets/encryption';
33
40
  import { getOrCreateMasterKey } from '../secrets/master-key';
34
41
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
@@ -593,6 +600,7 @@ function buildCapabilityInterface(
593
600
  zoneTiers: zones?.zoneTiers ?? [],
594
601
  trustedSubnets: zones?.trustedSubnets ?? [],
595
602
  controlPlaneSubnet: zones?.controlPlaneSubnet,
603
+ frontedSubnets: zones?.frontedSubnets ?? [],
596
604
  },
597
605
  store,
598
606
  undefined, // no upstream — the chain path handles that
@@ -699,6 +707,22 @@ interface FirewallZones {
699
707
  trustedSubnets: string[];
700
708
  /** celilo's control-plane network, as a DESTINATION for trusted sources. */
701
709
  controlPlaneSubnet?: string;
710
+ /**
711
+ * Every declared zone subnet — read from
712
+ * `network.<zone>.subnet` across all of `NETWORK_ZONES`, not just the three
713
+ * data-plane tiers.
714
+ *
715
+ * A downstream firewall must translate for every network behind it, and the
716
+ * tiers are only the ones that happen to form the dmz→app→secure chain.
717
+ * `secure-mgmt` and the control-plane VPN are equally behind it and equally
718
+ * unroutable untranslated. Reading the canonical zone list rather than the tier
719
+ * list means declaring a zone's subnet is sufficient to get it translated — no
720
+ * module registration required.
721
+ *
722
+ * The egress network is NOT excluded here: the firewall derives that from its
723
+ * own routing table, so nothing on this side has to guess which zone it is.
724
+ */
725
+ frontedSubnets: string[];
702
726
  }
703
727
 
704
728
  /** The module that IS celilo's control plane; its network is what we trust. */
@@ -802,6 +826,10 @@ function loadFirewallZones(db: DbClient): FirewallZones {
802
826
  zoneTiers,
803
827
  trustedSubnets: loadTrustedSubnets(db).map((e) => e.subnet),
804
828
  controlPlaneSubnet: loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'),
829
+ // Every zone celilo knows about, not the tier list — see the field doc.
830
+ frontedSubnets: NETWORK_ZONES.map((zone) => readZoneSubnet(db, zone)).filter(
831
+ (subnet): subnet is string => !!subnet,
832
+ ),
805
833
  };
806
834
  }
807
835
 
@@ -953,6 +981,7 @@ async function buildFirewallChain(
953
981
  zoneTiers: zones.zoneTiers,
954
982
  trustedSubnets: zones.trustedSubnets,
955
983
  controlPlaneSubnet: zones.controlPlaneSubnet,
984
+ frontedSubnets: zones.frontedSubnets,
956
985
  },
957
986
  store,
958
987
  currentUpstream,
@@ -9,16 +9,26 @@ import type { BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite';
9
9
  import type { DbClient } from '../db/client';
10
10
  import { ipAllocations, ipReservations, systemConfig, vmidReservations } from '../db/schema';
11
11
  import type { NewIpAllocation, NewIpReservation, NewVmidReservation } from '../db/schema';
12
+ import { ALLOCATABLE_ZONES, type AllocatableZone } from '../db/schema';
12
13
  import type * as schema from '../db/schema';
13
14
  import { generateIPsInSubnet, isIPInRange, isInSubnet, stripCIDR } from './subnet-parser';
14
15
 
15
16
  // Type that accepts both database client and transaction
16
17
  type DbOrTransaction = BunSQLiteDatabase<typeof schema> | DbClient;
17
18
 
18
- /** Zones that support IPAM auto-allocation of VMID and container IP */
19
- export type IpamZone = 'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal';
19
+ /**
20
+ * Zones that support IPAM auto-allocation of VMID and container IP.
21
+ *
22
+ * ALIASED to `AllocatableZone`, not hand-written. This was its own union
23
+ * (`'dmz' | 'app' | 'secure' | 'secure-mgmt' | 'internal'`) that happened to
24
+ * agree with `AllocatableZone` — a fourth hand-maintained copy of the zone list,
25
+ * in a repo where three earlier copies had already drifted and left comments
26
+ * saying so. Deriving means a zone celilo stops allocating for (as `vpn` is,
27
+ * since the tunnel module assigns client addresses) cannot be missed here.
28
+ */
29
+ export type IpamZone = AllocatableZone;
20
30
 
21
- const IPAM_ZONES: IpamZone[] = ['internal', 'dmz', 'app', 'secure', 'secure-mgmt'];
31
+ const IPAM_ZONES: IpamZone[] = ALLOCATABLE_ZONES;
22
32
 
23
33
  /**
24
34
  * Infer which zone an IP address belongs to by checking configured zone subnets.
@@ -27,6 +27,12 @@ describe('targetKindForCategory', () => {
27
27
  expect(targetKindForCategory('backups')).toBe('module');
28
28
  });
29
29
 
30
+ // Machine-scoped so suppression inherits the machine topology: an unreachable
31
+ // host suppresses its own disk alert instead of paging twice for one dead box.
32
+ test('disk_space is machine-scoped', () => {
33
+ expect(targetKindForCategory('disk_space')).toBe('machine');
34
+ });
35
+
30
36
  // Whole-system categories have no narrower subject to suppress against.
31
37
  test('unmapped category falls back to system', () => {
32
38
  expect(targetKindForCategory('cli_version')).toBe('system');
@@ -46,6 +52,42 @@ describe('severityForDriftSeverity', () => {
46
52
  });
47
53
 
48
54
  describe('failingKeysFromFindings', () => {
55
+ // Asserts the WHOLE key, not a prefix. A prefix-only assertion is exactly why
56
+ // #596 survived — machines_reachable emits a UUID where suppression expects a
57
+ // hostname, and `toContain('builtin:machines_reachable/machine:')` passes for
58
+ // both. This check must be pinned to the identifier itself.
59
+ test('disk_space keys on the hostname, in full', () => {
60
+ const keys = failingKeysFromFindings(
61
+ 'disk_space',
62
+ [
63
+ finding({
64
+ category: 'disk_space',
65
+ code: 'disk_critical',
66
+ severity: 'blocked',
67
+ subject: 'celilo-mgr',
68
+ message: 'celilo-mgr: root filesystem 96% full',
69
+ }),
70
+ ],
71
+ 'critical',
72
+ );
73
+
74
+ expect(keys).toHaveLength(1);
75
+ expect(keys[0]?.key).toBe('builtin:disk_space/machine:celilo-mgr');
76
+ expect(keys[0]?.severity).toBe('critical');
77
+ });
78
+
79
+ // An unmeasurable host records without paging.
80
+ test('an unmeasured disk downgrades to warning', () => {
81
+ const keys = failingKeysFromFindings(
82
+ 'disk_space',
83
+ [finding({ category: 'disk_space', severity: 'todo', subject: 'iot' })],
84
+ 'critical',
85
+ );
86
+
87
+ expect(keys[0]?.key).toBe('builtin:disk_space/machine:iot');
88
+ expect(keys[0]?.severity).toBe('warning');
89
+ });
90
+
49
91
  test('projects findings into builtin keys', () => {
50
92
  const keys = failingKeysFromFindings('machines_reachable', [finding()], 'critical');
51
93
  expect(keys).toEqual([
@@ -24,10 +24,12 @@ import { type FailingKey, builtinAlertKey } from './keys';
24
24
  */
25
25
  const TARGET_KIND_BY_CATEGORY: Partial<Record<DriftCategory, string>> = {
26
26
  machines_reachable: 'machine',
27
+ disk_space: 'machine',
27
28
  transport_reads: 'module',
28
29
  services_reachable: 'service',
29
30
  services_credentials: 'service',
30
31
  backups: 'module',
32
+ abandoned_operations: 'module',
31
33
  health: 'module',
32
34
  module_versions: 'module',
33
35
  module_configs: 'module',
@@ -13,16 +13,21 @@
13
13
  */
14
14
 
15
15
  import type { DbClient } from '../../db/client';
16
+ import { auditAbandonedOperations, loadAbandonedOperations } from '../audit/abandoned-operations';
16
17
  import { loadBackupAuditInfo } from '../audit/backup-source';
17
18
  import { auditBackups } from '../audit/backups';
19
+ import { auditDiskSpace } from '../audit/disk-space';
18
20
  import { auditMachinesReachable } from '../audit/machines-reachable';
19
21
  import type { DriftCategory, DriftFinding } from '../audit/types';
22
+ import { probeDiskUsage } from '../disk-probe';
20
23
  import { probeMachines } from '../machine-probe';
21
24
 
22
25
  /** Categories a monitor can currently schedule. */
23
26
  export const SCHEDULABLE_BUILTIN_CHECKS: readonly DriftCategory[] = [
24
27
  'machines_reachable',
25
28
  'backups',
29
+ 'disk_space',
30
+ 'abandoned_operations',
26
31
  ];
27
32
 
28
33
  export function isSchedulableBuiltin(category: string): category is DriftCategory {
@@ -37,12 +42,22 @@ export async function runBuiltinCheckForMonitor(
37
42
  return auditMachinesReachable({ results: await probeMachines() });
38
43
  }
39
44
 
45
+ // Same shape as machines_reachable — one SSH round trip per machine, bounded
46
+ // — but it MEASURES the local box rather than exempting it. See disk-probe.ts.
47
+ if (category === 'disk_space') {
48
+ return auditDiskSpace({ results: await probeDiskUsage() });
49
+ }
50
+
40
51
  // Local DB reads only — cheap enough to run on every sweep, which is
41
52
  // the whole reason this category is schedulable and most are not.
42
53
  if (category === 'backups') {
43
54
  return auditBackups({ modules: loadBackupAuditInfo(db) });
44
55
  }
45
56
 
57
+ if (category === 'abandoned_operations') {
58
+ return auditAbandonedOperations({ records: loadAbandonedOperations(db) });
59
+ }
60
+
46
61
  throw new Error(
47
62
  `Built-in check "${category}" is not schedulable yet. Schedulable: ${SCHEDULABLE_BUILTIN_CHECKS.join(', ')}.`,
48
63
  );
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ ABANDONED_THRESHOLD,
4
+ ABANDONED_WINDOW_MS,
5
+ type AbandonedOperationRecord,
6
+ auditAbandonedOperations,
7
+ } from './abandoned-operations';
8
+
9
+ const NOW = Date.parse('2026-08-05T12:00:00Z');
10
+ const now = () => NOW;
11
+
12
+ function records(
13
+ moduleId: string,
14
+ operation: 'deploy' | 'backup',
15
+ count: number,
16
+ ageMs = 60_000,
17
+ ): AbandonedOperationRecord[] {
18
+ return Array.from({ length: count }, () => ({
19
+ moduleId,
20
+ operation,
21
+ startedAt: NOW - ageMs,
22
+ }));
23
+ }
24
+
25
+ describe('auditAbandonedOperations', () => {
26
+ it('says nothing about a single abandonment — that is an operator hitting Ctrl-C', () => {
27
+ const findings = auditAbandonedOperations({
28
+ records: records('forgejo', 'backup', ABANDONED_THRESHOLD - 1),
29
+ now,
30
+ });
31
+ expect(findings).toEqual([]);
32
+ });
33
+
34
+ it('flags a module whose same operation keeps dying', () => {
35
+ const findings = auditAbandonedOperations({
36
+ records: records('forgejo', 'backup', 63),
37
+ now,
38
+ });
39
+
40
+ expect(findings).toHaveLength(1);
41
+ expect(findings[0]?.subject).toBe('forgejo');
42
+ expect(findings[0]?.severity).toBe('drift');
43
+ expect(findings[0]?.message).toContain('63 backup operations abandoned');
44
+ });
45
+
46
+ // The point of grouping: a module whose backups are being killed and whose
47
+ // deploys are fine should say exactly that.
48
+ it('reports each operation kind separately', () => {
49
+ const findings = auditAbandonedOperations({
50
+ records: [...records('forgejo', 'backup', 5), ...records('technitium', 'deploy', 5)],
51
+ now,
52
+ });
53
+
54
+ expect(findings.map((f) => f.subject)).toEqual(['forgejo', 'technitium']);
55
+ expect(findings[1]?.message).toContain('deploy operations');
56
+ });
57
+
58
+ it('ignores abandonments older than the window, so a fixed module stops being flagged', () => {
59
+ const findings = auditAbandonedOperations({
60
+ records: records('forgejo', 'backup', 60, ABANDONED_WINDOW_MS + 60_000),
61
+ now,
62
+ });
63
+ expect(findings).toEqual([]);
64
+ });
65
+
66
+ it('does not merge two modules into one unactionable count', () => {
67
+ const findings = auditAbandonedOperations({
68
+ records: [...records('forgejo', 'backup', 2), ...records('lunacycle', 'backup', 2)],
69
+ now,
70
+ });
71
+ expect(findings).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ DISK_BLOCKED_PERCENT,
4
+ DISK_DRIFT_PERCENT,
5
+ type DiskUsageResult,
6
+ auditDiskSpace,
7
+ } from './disk-space';
8
+
9
+ function usage(over: Partial<DiskUsageResult> = {}): DiskUsageResult {
10
+ return {
11
+ hostname: 'celilo-mgr',
12
+ ipAddress: '10.0.120.10',
13
+ usedPercent: 10,
14
+ availableBytes: 102 * 1024 ** 3,
15
+ ...over,
16
+ };
17
+ }
18
+
19
+ describe('auditDiskSpace', () => {
20
+ test('a healthy filesystem produces no finding', () => {
21
+ expect(auditDiskSpace({ results: [usage({ usedPercent: 10 })] })).toEqual([]);
22
+ });
23
+
24
+ test('just below the threshold stays quiet', () => {
25
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT - 1 })] });
26
+ expect(findings).toEqual([]);
27
+ });
28
+
29
+ test('at the drift threshold reports drift, naming host and usage', () => {
30
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_DRIFT_PERCENT })] });
31
+
32
+ expect(findings).toHaveLength(1);
33
+ expect(findings[0]?.severity).toBe('drift');
34
+ expect(findings[0]?.code).toBe('disk_low');
35
+ expect(findings[0]?.message).toContain('celilo-mgr');
36
+ expect(findings[0]?.message).toContain('85%');
37
+ });
38
+
39
+ test('at the blocked threshold escalates to blocked', () => {
40
+ const findings = auditDiskSpace({ results: [usage({ usedPercent: DISK_BLOCKED_PERCENT })] });
41
+
42
+ expect(findings[0]?.severity).toBe('blocked');
43
+ expect(findings[0]?.code).toBe('disk_critical');
44
+ });
45
+
46
+ // The alert has to name the host to act on, and suppression resolves a
47
+ // machine's ancestor key from its HOSTNAME (alerting/suppression.ts). A UUID
48
+ // subject produces a key suppression can never match — that is #596, filed
49
+ // against machines_reachable. This check must not repeat it.
50
+ test('subjects the finding on the hostname, never a UUID', () => {
51
+ const findings = auditDiskSpace({
52
+ results: [usage({ hostname: 'iot', usedPercent: 99 })],
53
+ });
54
+
55
+ expect(findings[0]?.subject).toBe('iot');
56
+ });
57
+
58
+ test('only the offending host is reported in a mixed fleet', () => {
59
+ const findings = auditDiskSpace({
60
+ results: [
61
+ usage({ hostname: 'celilo-mgr', usedPercent: 96 }),
62
+ usage({ hostname: 'iot', usedPercent: 12 }),
63
+ usage({ hostname: 'dns-ext', usedPercent: 40 }),
64
+ ],
65
+ });
66
+
67
+ expect(findings).toHaveLength(1);
68
+ expect(findings[0]?.subject).toBe('celilo-mgr');
69
+ });
70
+
71
+ test('reports free space alongside the percentage', () => {
72
+ const findings = auditDiskSpace({
73
+ results: [usage({ usedPercent: 96, availableBytes: 6 * 1024 ** 3 })],
74
+ });
75
+
76
+ expect(findings[0]?.message).toContain('6.0 GB free');
77
+ });
78
+
79
+ // Unmeasurable must not read as healthy — but it must not page either, since
80
+ // machines_reachable is already alerting for the same dead host.
81
+ test('an unmeasurable host is recorded as todo, not as healthy', () => {
82
+ const findings = auditDiskSpace({
83
+ results: [usage({ hostname: 'iot', usedPercent: null, message: 'ssh: connect timed out' })],
84
+ });
85
+
86
+ expect(findings).toHaveLength(1);
87
+ expect(findings[0]?.severity).toBe('todo');
88
+ expect(findings[0]?.code).toBe('disk_unmeasured');
89
+ expect(findings[0]?.details).toContain('timed out');
90
+ });
91
+
92
+ test('an unreachable host does not hide a filling one', () => {
93
+ const findings = auditDiskSpace({
94
+ results: [
95
+ usage({ hostname: 'iot', usedPercent: null, message: 'unreachable' }),
96
+ usage({ hostname: 'celilo-mgr', usedPercent: 97 }),
97
+ ],
98
+ });
99
+
100
+ expect(findings.map((f) => f.subject).sort()).toEqual(['celilo-mgr', 'iot']);
101
+ expect(findings.find((f) => f.subject === 'celilo-mgr')?.severity).toBe('blocked');
102
+ });
103
+
104
+ test('every finding carries the disk_space category', () => {
105
+ const findings = auditDiskSpace({
106
+ results: [usage({ usedPercent: 99 }), usage({ hostname: 'iot', usedPercent: null })],
107
+ });
108
+
109
+ expect(findings.every((f) => f.category === 'disk_space')).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Disk-space check.
3
+ *
4
+ * Catches the failure that had no detector at all: a filesystem filling up.
5
+ * celilo-mgr reached 34% and climbing at ~4.8 GB/hour from leaked backup
6
+ * staging, and the only reason anyone noticed was an operator running `df` by
7
+ * eye. Every one of the eighteen configured monitors watched module health or
8
+ * machine reachability; not one looked at disk.
9
+ *
10
+ * Reports EARLY, not at exhaustion. A check that fires once a filesystem is
11
+ * full reports an outage instead of preventing one, so the thresholds leave
12
+ * room to act: `drift` at 85% is "you have time", `blocked` at 95% is "you do
13
+ * not". On a 117 GB root that is ~17 GB and ~6 GB of headroom respectively —
14
+ * hours at the leak rate that motivated this, days at any normal one.
15
+ *
16
+ * The audit consumes pre-computed measurements so it stays unit-testable
17
+ * without a live filesystem or SSH client, exactly like `machines-reachable`.
18
+ */
19
+
20
+ import type { DriftFinding } from './types';
21
+
22
+ /** Usage at or above this is divergence worth an operator's attention. */
23
+ export const DISK_DRIFT_PERCENT = 85;
24
+
25
+ /** Usage at or above this is close enough to exhaustion to gate on. */
26
+ export const DISK_BLOCKED_PERCENT = 95;
27
+
28
+ export interface DiskUsageResult {
29
+ /**
30
+ * User-facing hostname, and the identifier the finding is keyed by.
31
+ *
32
+ * NOT the machine's UUID. Suppression resolves a machine's ancestor key from
33
+ * its hostname (`machineAlertKey` in alerting/suppression.ts), so a finding
34
+ * subjected on the UUID produces an alert key suppression can never match —
35
+ * which is exactly the bug filed as #596 against `machines_reachable`. Using
36
+ * the hostname here also satisfies CLAUDE.md: users never see UUIDs.
37
+ */
38
+ hostname: string;
39
+ ipAddress: string;
40
+ /** Percent of the root filesystem in use, or null when it could not be measured. */
41
+ usedPercent: number | null;
42
+ /** Bytes still available. Omitted when unmeasured. */
43
+ availableBytes?: number;
44
+ /** Why the measurement failed, when `usedPercent` is null. */
45
+ message?: string;
46
+ }
47
+
48
+ export interface DiskSpaceAuditDeps {
49
+ results: DiskUsageResult[];
50
+ }
51
+
52
+ function humanBytes(bytes: number): string {
53
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
54
+ let value = bytes;
55
+ let unit = 0;
56
+ while (value >= 1024 && unit < units.length - 1) {
57
+ value /= 1024;
58
+ unit++;
59
+ }
60
+ return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
61
+ }
62
+
63
+ export function auditDiskSpace(deps: DiskSpaceAuditDeps): DriftFinding[] {
64
+ const findings: DriftFinding[] = [];
65
+
66
+ for (const result of deps.results) {
67
+ // Unmeasurable is NOT healthy — but it does not page either. The host is
68
+ // already unreachable, `machines_reachable` is already alerting on it, and
69
+ // a second page for one dead host is noise. `todo` records without
70
+ // notifying, which is the existing severity for exactly that.
71
+ if (result.usedPercent === null) {
72
+ findings.push({
73
+ category: 'disk_space',
74
+ severity: 'todo',
75
+ code: 'disk_unmeasured',
76
+ message: `${result.hostname}: disk usage could not be measured`,
77
+ details: result.message,
78
+ remediation:
79
+ 'The host is unreachable or `df` failed on it. `machines_reachable` covers reachability; this finding only records that disk is currently unknown, not that it is healthy.',
80
+ actionable: false,
81
+ subject: result.hostname,
82
+ });
83
+ continue;
84
+ }
85
+
86
+ if (result.usedPercent < DISK_DRIFT_PERCENT) continue;
87
+
88
+ const critical = result.usedPercent >= DISK_BLOCKED_PERCENT;
89
+ const free =
90
+ result.availableBytes === undefined ? '' : `, ${humanBytes(result.availableBytes)} free`;
91
+
92
+ findings.push({
93
+ category: 'disk_space',
94
+ severity: critical ? 'blocked' : 'drift',
95
+ code: critical ? 'disk_critical' : 'disk_low',
96
+ message: `${result.hostname}: root filesystem ${result.usedPercent}% full${free}`,
97
+ details: critical
98
+ ? `At or above ${DISK_BLOCKED_PERCENT}% the host is close enough to exhaustion that writes can begin failing. On the management server a full root filesystem takes the event bus and dispatcher with it, including whatever scheduled work would otherwise clean up.`
99
+ : `At or above ${DISK_DRIFT_PERCENT}% there is still room to act. Find the growth before it becomes an outage rather than after.`,
100
+ remediation: [
101
+ `Find what is growing on ${result.hostname}:`,
102
+ ' df -h /',
103
+ ' sudo du -xh --max-depth=2 / | sort -h | tail -30',
104
+ 'Then fix the source — a retention policy, a size cap, or a prune',
105
+ 'schedule. A one-off delete leaves the same thing growing.',
106
+ ].join('\n'),
107
+ // Multi-step diagnosis, not a one-shot celilo command.
108
+ actionable: false,
109
+ subject: result.hostname,
110
+ });
111
+ }
112
+
113
+ return findings;
114
+ }
@@ -26,6 +26,7 @@ const emptyDeps = {
26
26
  moduleConfigs: { modules: [] },
27
27
  health: { results: [] },
28
28
  backups: { modules: [] },
29
+ abandonedOperations: { records: [] },
29
30
  undeployedModules: { modules: [] },
30
31
  unconfiguredModules: { modules: [] },
31
32
  servicesCredentials: { results: [] },