@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.
Files changed (72) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +38 -9
  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/api/remote-client.test.ts +62 -0
  8. package/src/api/serve.ts +14 -6
  9. package/src/cli/command-tree-parser.ts +0 -1
  10. package/src/cli/commands/apt-upgrade.test.ts +20 -1
  11. package/src/cli/commands/apt-upgrade.ts +12 -2
  12. package/src/cli/commands/backup-sweep.ts +62 -0
  13. package/src/cli/commands/events.ts +90 -0
  14. package/src/cli/commands/module-operations.test.ts +45 -1
  15. package/src/cli/commands/module-operations.ts +35 -12
  16. package/src/cli/commands/module-show.ts +1 -0
  17. package/src/cli/commands/module-update.test.ts +72 -1
  18. package/src/cli/commands/module-update.ts +45 -22
  19. package/src/cli/commands/system-audit.ts +2 -0
  20. package/src/cli/commands/system-migrate.test.ts +56 -0
  21. package/src/cli/commands/system-migrate.ts +92 -4
  22. package/src/cli/commands/system-update.ts +5 -0
  23. package/src/cli/completion.ts +19 -0
  24. package/src/cli/fuel-gauge.ts +0 -1
  25. package/src/cli/generate-zsh-completion.ts +1 -1
  26. package/src/cli/index.ts +5 -1
  27. package/src/cli/tui/audit-state.ts +4 -0
  28. package/src/cli/tui/audit-tui.test.tsx +0 -1
  29. package/src/db/migration-status.test.ts +114 -0
  30. package/src/db/migration-status.ts +78 -0
  31. package/src/db/schema-introspection.ts +8 -1
  32. package/src/db/schema.ts +53 -9
  33. package/src/hooks/capability-loader.ts +30 -1
  34. package/src/ipam/allocator.ts +13 -3
  35. package/src/services/alerting/builtin-monitors.test.ts +42 -0
  36. package/src/services/alerting/builtin-monitors.ts +2 -0
  37. package/src/services/alerting/builtin-source.ts +15 -0
  38. package/src/services/audit/abandoned-operations.test.ts +73 -0
  39. package/src/services/audit/abandoned-operations.ts +0 -0
  40. package/src/services/audit/disk-space.test.ts +111 -0
  41. package/src/services/audit/disk-space.ts +114 -0
  42. package/src/services/audit/index.test.ts +1 -0
  43. package/src/services/audit/index.ts +9 -0
  44. package/src/services/audit/types.ts +2 -0
  45. package/src/services/backup-create.ts +4 -4
  46. package/src/services/backup-in-flight-refusal.test.ts +2 -0
  47. package/src/services/backup-metadata.ts +4 -0
  48. package/src/services/backup-staging.test.ts +134 -0
  49. package/src/services/backup-staging.ts +192 -0
  50. package/src/services/backup-sweep.test.ts +68 -0
  51. package/src/services/backup-sweep.ts +62 -0
  52. package/src/services/bus-interview.ts +11 -5
  53. package/src/services/config-interview.ts +1 -1
  54. package/src/services/deploy-ansible.ts +0 -1
  55. package/src/services/disk-probe.test.ts +74 -0
  56. package/src/services/disk-probe.ts +145 -0
  57. package/src/services/events-daemon.test.ts +244 -0
  58. package/src/services/events-daemon.ts +295 -8
  59. package/src/services/fleet-checks.test.ts +75 -4
  60. package/src/services/fleet-checks.ts +97 -12
  61. package/src/services/interview-errors.ts +20 -0
  62. package/src/services/module-operations.test.ts +22 -0
  63. package/src/services/module-operations.ts +48 -1
  64. package/src/services/module-subscriptions.test.ts +39 -6
  65. package/src/services/module-subscriptions.ts +6 -4
  66. package/src/services/module-types-generator.test.ts +6 -3
  67. package/src/services/module-types-generator.ts +12 -7
  68. package/src/services/remote-responder.test.ts +70 -0
  69. package/src/services/remote-responder.ts +27 -10
  70. package/src/services/responder-probe.ts +3 -1
  71. package/src/services/update/orchestrator.test.ts +1 -0
  72. package/src/variables/context.ts +6 -1
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  handleEventsRepair,
32
32
  handleEventsReply,
33
33
  handleEventsRespond,
34
+ handleEventsRestartDaemon,
34
35
  handleEventsResyncSubscriptions,
35
36
  handleEventsRun,
36
37
  handleEventsRunHook,
@@ -303,6 +304,7 @@ Subcommands:
303
304
  respond Run the terminal responder; answer deploy prompts from another shell
304
305
  install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
305
306
  uninstall-daemon [--system] Remove the installed supervisor unit
307
+ restart-daemon [--system] Restart the dispatcher and verify the new process is on current code
306
308
  show-daemon [--system] Print the currently installed unit file
307
309
 
308
310
  Description:
@@ -1439,6 +1441,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1439
1441
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1440
1442
  case 'uninstall-daemon':
1441
1443
  return handleEventsUninstallDaemon(parsed.args, parsed.flags);
1444
+ case 'restart-daemon':
1445
+ return handleEventsRestartDaemon(parsed.args, parsed.flags);
1442
1446
  case 'show-daemon':
1443
1447
  return handleEventsShowDaemon(parsed.args, parsed.flags);
1444
1448
  default:
@@ -2133,7 +2137,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2133
2137
  }
2134
2138
 
2135
2139
  if (parsed.subcommand === 'migrate') {
2136
- return handleSystemMigrate();
2140
+ return handleSystemMigrate(parsed.args, parsed.flags);
2137
2141
  }
2138
2142
 
2139
2143
  return {
@@ -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
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * celilo#604: a table COUNT reported "up to date" for a DB missing a migrated
3
+ * COLUMN, so the rollout's own stop-condition was uncheckable by the tools the
4
+ * runbook named. These assert the column case specifically.
5
+ */
6
+
7
+ import { Database } from 'bun:sqlite';
8
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { getMigrationStatus, readMigrationJournal } from './migration-status';
13
+
14
+ /** A journal + a `__drizzle_migrations` table, wired the way drizzle wires them. */
15
+ function seed(dir: string, entries: Array<{ when: number; tag: string }>, appliedWhens: number[]) {
16
+ mkdirSync(join(dir, 'meta'), { recursive: true });
17
+ writeFileSync(
18
+ join(dir, 'meta', '_journal.json'),
19
+ JSON.stringify({ version: '7', dialect: 'sqlite', entries }),
20
+ );
21
+ const db = new Database(':memory:');
22
+ db.run(
23
+ 'CREATE TABLE `__drizzle_migrations` (id INTEGER PRIMARY KEY, hash TEXT, created_at NUMERIC)',
24
+ );
25
+ for (const when of appliedWhens) {
26
+ db.run('INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES (?, ?)', [
27
+ `h${when}`,
28
+ when,
29
+ ]);
30
+ }
31
+ return db;
32
+ }
33
+
34
+ describe('getMigrationStatus', () => {
35
+ let dir: string;
36
+ beforeEach(() => {
37
+ dir = mkdtempSync(join(tmpdir(), 'celilo-migstatus-'));
38
+ });
39
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
40
+
41
+ it('names the latest applied migration and every pending one', () => {
42
+ const db = seed(
43
+ dir,
44
+ [
45
+ { when: 100, tag: '0018_drop_alert_policy_snapshot' },
46
+ { when: 200, tag: '0019_backup_pid' },
47
+ { when: 300, tag: '0020_future' },
48
+ ],
49
+ [100, 200],
50
+ );
51
+
52
+ const status = getMigrationStatus(db, dir);
53
+
54
+ expect(status.appliedCount).toBe(2);
55
+ expect(status.latestApplied).toBe('0019_backup_pid');
56
+ expect(status.pending).toEqual(['0020_future']);
57
+ });
58
+
59
+ it('reports nothing pending once every journal entry is applied', () => {
60
+ const db = seed(dir, [{ when: 100, tag: '0000_init' }], [100]);
61
+ const status = getMigrationStatus(db, dir);
62
+ expect(status.pending).toEqual([]);
63
+ expect(status.latestApplied).toBe('0000_init');
64
+ });
65
+
66
+ it('reports a DB that has never been migrated as all-pending', () => {
67
+ mkdirSync(join(dir, 'meta'), { recursive: true });
68
+ writeFileSync(
69
+ join(dir, 'meta', '_journal.json'),
70
+ JSON.stringify({ entries: [{ when: 1, tag: '0000_init' }] }),
71
+ );
72
+ const status = getMigrationStatus(new Database(':memory:'), dir);
73
+ expect(status.appliedCount).toBe(0);
74
+ expect(status.latestApplied).toBeNull();
75
+ expect(status.pending).toEqual(['0000_init']);
76
+ });
77
+
78
+ it('counts columns, not just tables — the signal a column migration needs', () => {
79
+ const db = seed(dir, [], []);
80
+ const status = getMigrationStatus(db, dir);
81
+ // Every schema table is missing here, so the counts are the code's totals.
82
+ expect(status.tableCount).toBeGreaterThan(0);
83
+ expect(status.columnCount).toBeGreaterThan(status.tableCount);
84
+ });
85
+
86
+ it('reads journal entries oldest-first regardless of file order', () => {
87
+ mkdirSync(join(dir, 'meta'), { recursive: true });
88
+ writeFileSync(
89
+ join(dir, 'meta', '_journal.json'),
90
+ JSON.stringify({
91
+ entries: [
92
+ { when: 300, tag: 'c' },
93
+ { when: 100, tag: 'a' },
94
+ ],
95
+ }),
96
+ );
97
+ expect(readMigrationJournal(dir).map((e) => e.tag)).toEqual(['a', 'c']);
98
+ });
99
+
100
+ it('returns an empty journal when the folder has none', () => {
101
+ expect(readMigrationJournal(join(dir, 'nope'))).toEqual([]);
102
+ });
103
+ });
104
+
105
+ describe('getMigrationStatus against the real migrations folder', () => {
106
+ it('sees backups.pid as missing when the column migration did not land', () => {
107
+ // The exact celilo#604 shape: every table present, one migrated column not.
108
+ const db = new Database(':memory:');
109
+ db.run('CREATE TABLE backups (id TEXT PRIMARY KEY)');
110
+ const status = getMigrationStatus(db, join(import.meta.dir, '../../drizzle'));
111
+ expect(status.missingColumns).toContain('backups.pid');
112
+ expect(status.pending).toContain('0019_backup_pid');
113
+ });
114
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Read-only migration interrogation (celilo#604).
3
+ *
4
+ * `system migrate` used to report "Schema current: 35 tables" — a table COUNT,
5
+ * which cannot distinguish "the column migration applied" from "nothing
6
+ * happened". 0019_backup_pid adds a column; a rollout runbook asserting
7
+ * "applied 19 → 20, backups.pid present" had no product surface to check it
8
+ * against and had to reach for `sqlite3` over SSH.
9
+ *
10
+ * This names migrations. Drizzle's `__drizzle_migrations.created_at` is the
11
+ * journal entry's `when`, so the join back to a human tag is exact.
12
+ */
13
+
14
+ import type { Database } from 'bun:sqlite';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { findSchemaDrift } from './schema-introspection';
18
+
19
+ export interface MigrationStatus {
20
+ /** Rows in `__drizzle_migrations` — what the runbook calls "applied count". */
21
+ appliedCount: number;
22
+ /** Tag of the newest applied migration, e.g. `0019_backup_pid`. */
23
+ latestApplied: string | null;
24
+ /** Tags present on disk that this DB has not applied, oldest first. */
25
+ pending: string[];
26
+ /** Tables the running code declares that the DB lacks. */
27
+ missingTables: string[];
28
+ /** `table.column` the running code declares that the DB lacks. */
29
+ missingColumns: string[];
30
+ tableCount: number;
31
+ columnCount: number;
32
+ }
33
+
34
+ interface JournalEntry {
35
+ when: number;
36
+ tag: string;
37
+ }
38
+
39
+ /** Journal entries (oldest first), or [] when the folder has no journal. */
40
+ export function readMigrationJournal(migrationsFolder: string): JournalEntry[] {
41
+ const path = join(migrationsFolder, 'meta', '_journal.json');
42
+ if (!existsSync(path)) return [];
43
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { entries?: JournalEntry[] };
44
+ return [...(parsed.entries ?? [])].sort((a, b) => a.when - b.when);
45
+ }
46
+
47
+ /** `created_at` timestamps recorded in `__drizzle_migrations` (empty if untracked). */
48
+ function appliedTimestamps(sqlite: Database): number[] {
49
+ try {
50
+ return sqlite
51
+ .query<{ created_at: number }, []>(
52
+ 'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at',
53
+ )
54
+ .all()
55
+ .map((r) => r.created_at);
56
+ } catch {
57
+ // No migrations table — a DB that has never been through the migrator.
58
+ return [];
59
+ }
60
+ }
61
+
62
+ export function getMigrationStatus(sqlite: Database, migrationsFolder: string): MigrationStatus {
63
+ const journal = readMigrationJournal(migrationsFolder);
64
+ const applied = new Set(appliedTimestamps(sqlite));
65
+ const appliedTags = journal.filter((e) => applied.has(e.when)).map((e) => e.tag);
66
+ const pending = journal.filter((e) => !applied.has(e.when)).map((e) => e.tag);
67
+ const drift = findSchemaDrift(sqlite);
68
+
69
+ return {
70
+ appliedCount: applied.size,
71
+ latestApplied: appliedTags.at(-1) ?? null,
72
+ pending,
73
+ missingTables: drift.missingTables,
74
+ missingColumns: drift.missingColumns,
75
+ tableCount: drift.tableCount,
76
+ columnCount: drift.columnCount,
77
+ };
78
+ }
@@ -20,6 +20,12 @@ export interface SchemaDrift {
20
20
  missingColumns: string[];
21
21
  /** Total number of tables the code's schema declares. */
22
22
  tableCount: number;
23
+ /**
24
+ * Total number of columns the code's schema declares. Reported alongside
25
+ * tableCount so the doctor can say what it actually checked — a table count
26
+ * alone reads as "columns unverified" even when they were (celilo#604).
27
+ */
28
+ columnCount: number;
23
29
  }
24
30
 
25
31
  /** Every table name + column names the drizzle schema declares. */
@@ -84,5 +90,6 @@ export function findSchemaDrift(sqlite: Database): SchemaDrift {
84
90
  if (!cols.has(c)) missingColumns.push(`${t.name}.${c}`);
85
91
  }
86
92
  }
87
- return { missingTables, missingColumns, tableCount: tables.length };
93
+ const columnCount = tables.reduce((n, t) => n + t.columns.length, 0);
94
+ return { missingTables, missingColumns, tableCount: tables.length, columnCount };
88
95
  }
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
+ });