@celilo/cli 0.23.0 → 0.24.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 (61) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +64 -2
  14. package/src/cli/commands/module-config.ts +159 -8
  15. package/src/cli/commands/module-status.ts +124 -0
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/types.ts +2 -1
  24. package/src/manifest/contracts/v1.ts +16 -0
  25. package/src/manifest/schema.ts +40 -65
  26. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  27. package/src/services/alerting/cadence-migration.test.ts +155 -0
  28. package/src/services/alerting/cadence-migration.ts +90 -0
  29. package/src/services/alerting/coverage-source.ts +8 -11
  30. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  31. package/src/services/alerting/deploy-hooks.ts +11 -5
  32. package/src/services/alerting/health-cadence.test.ts +58 -0
  33. package/src/services/alerting/health-cadence.ts +128 -0
  34. package/src/services/alerting/health-coverage.ts +18 -8
  35. package/src/services/alerting/monitors.ts +50 -15
  36. package/src/services/alerting/sweep-runner.test.ts +51 -3
  37. package/src/services/alerting/sweep-runner.ts +30 -7
  38. package/src/services/audit/backup-source.ts +24 -1
  39. package/src/services/audit/backups.test.ts +95 -10
  40. package/src/services/audit/backups.ts +40 -37
  41. package/src/services/audit/interface-classification.test.ts +220 -0
  42. package/src/services/audit/interface-classification.ts +167 -0
  43. package/src/services/audit/types.ts +2 -1
  44. package/src/services/backup-age-agreement.test.ts +118 -0
  45. package/src/services/backup-create.ts +36 -30
  46. package/src/services/backup-metadata.ts +52 -1
  47. package/src/services/backup-retention.test.ts +123 -0
  48. package/src/services/backup-retention.ts +66 -5
  49. package/src/services/backup-schedule.test.ts +166 -0
  50. package/src/services/backup-schedule.ts +105 -15
  51. package/src/services/backup-staging.ts +14 -1
  52. package/src/services/backup-sweep.test.ts +22 -3
  53. package/src/services/backup-sweep.ts +15 -5
  54. package/src/services/cadence.test.ts +97 -0
  55. package/src/services/cadence.ts +165 -0
  56. package/src/services/machine-detector.ts +23 -1
  57. package/src/services/module-config.ts +33 -0
  58. package/src/services/storage-providers/s3.test.ts +96 -13
  59. package/src/services/storage-providers/s3.ts +48 -15
  60. package/src/services/zone-detector.test.ts +34 -3
  61. package/src/services/zone-detector.ts +33 -13
@@ -10,12 +10,12 @@ import { defineEvents, openBus } from '@celilo/event-bus';
10
10
  import { getEventBusPath } from '../../config/paths';
11
11
  import { getDb } from '../../db/client';
12
12
  import type { MonitorKind } from '../../db/schema';
13
- import { parseIntervalMinutes } from '../../manifest/schema';
14
13
  import {
15
14
  isSchedulableBuiltin,
16
15
  runBuiltinCheckForMonitor,
17
16
  } from '../../services/alerting/builtin-source';
18
17
  import { loadModuleCoverage } from '../../services/alerting/coverage-source';
18
+ import { loadModuleHealthCadences } from '../../services/alerting/health-cadence';
19
19
  import { HEALTH_COVERAGE_CHECK } from '../../services/alerting/health-coverage';
20
20
  import {
21
21
  createMonitor,
@@ -23,11 +23,18 @@ import {
23
23
  findMonitorByTarget,
24
24
  listMonitors,
25
25
  setMonitorEnabled,
26
+ updateMonitorInterval,
26
27
  } from '../../services/alerting/monitors';
27
28
  import { listPolicies } from '../../services/alerting/people';
28
29
  import { runOneMonitor } from '../../services/alerting/run-monitor';
29
30
  import { promoteReadyAlerts } from '../../services/alerting/store';
30
31
  import type { DriftCategory } from '../../services/audit/types';
32
+ import {
33
+ MONITOR_INTERVAL_FLOOR_MINUTES,
34
+ cadenceSchema,
35
+ formatCadence,
36
+ parseCadence,
37
+ } from '../../services/cadence';
31
38
  import { runModuleHealthCheck } from '../../services/health-runner';
32
39
  import type { CommandResult } from '../types';
33
40
 
@@ -65,18 +72,34 @@ function handleList(): CommandResult {
65
72
  const policyOf = (id: string | null) =>
66
73
  id ? (policies.get(id) ?? '(deleted policy)') : '— pages nobody';
67
74
 
75
+ // A `module_hook` row's stored interval is not what the sweep uses, so
76
+ // printing it would be a confident lie. Resolve the same way the sweep does.
77
+ const cadences = loadModuleHealthCadences(getDb());
78
+ const everyOf = (monitor: (typeof rows)[number]): string => {
79
+ if (monitor.kind !== 'module_hook') return `${monitor.intervalMinutes}m`;
80
+ const cadence = cadences.get(monitor.target)?.cadence ?? null;
81
+ return cadence === null ? '—' : formatCadence(cadence);
82
+ };
83
+
68
84
  const width = Math.max(6, ...rows.map((r) => r.target.length));
69
85
  const policyWidth = Math.max(6, ...rows.map((r) => policyOf(r.escalationPolicyId).length));
70
86
  console.log('');
71
87
  console.log(
72
- `${'TARGET'.padEnd(width)} ${'KIND'.padEnd(14)} ${'EVERY'.padEnd(6)} ${'POLICY'.padEnd(policyWidth)} STATE`,
88
+ `${'TARGET'.padEnd(width)} ${'KIND'.padEnd(14)} ${'EVERY'.padEnd(8)} ${'POLICY'.padEnd(policyWidth)} STATE`,
73
89
  );
74
90
  for (const monitor of rows) {
75
- const state = monitor.enabled ? 'enabled' : 'disabled';
91
+ const every = everyOf(monitor);
92
+ const state =
93
+ monitor.kind === 'module_hook'
94
+ ? every === 'manual' || every === '—'
95
+ ? 'not watched'
96
+ : 'watched'
97
+ : monitor.enabled
98
+ ? 'enabled'
99
+ : 'disabled';
76
100
  const suffix = monitor.lastRunAt ? '' : ' (never run)';
77
- const every = `${monitor.intervalMinutes}m`;
78
101
  console.log(
79
- `${monitor.target.padEnd(width)} ${monitor.kind.padEnd(14)} ${every.padEnd(6)} ${policyOf(monitor.escalationPolicyId).padEnd(policyWidth)} ${state}${suffix}`,
102
+ `${monitor.target.padEnd(width)} ${monitor.kind.padEnd(14)} ${every.padEnd(8)} ${policyOf(monitor.escalationPolicyId).padEnd(policyWidth)} ${state}${suffix}`,
80
103
  );
81
104
  }
82
105
  console.log('');
@@ -90,18 +113,6 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
90
113
  }
91
114
 
92
115
  const db = getDb();
93
- if (findMonitorByTarget(db, target)) {
94
- return { success: false, error: `A monitor for "${target}" already exists.` };
95
- }
96
-
97
- const interval = typeof flags.interval === 'string' ? flags.interval : '15m';
98
- const intervalMinutes = parseIntervalMinutes(interval);
99
- if (intervalMinutes === null) {
100
- return {
101
- success: false,
102
- error: `Invalid --interval "${interval}". Use a duration like "15m", "1h", or "1d".`,
103
- };
104
- }
105
116
 
106
117
  // A target naming an audit category is a built-in check; anything else is a
107
118
  // module's health_check hook. `isSchedulableBuiltin` is checked explicitly
@@ -113,6 +124,30 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
113
124
  ? 'builtin_check'
114
125
  : 'module_hook';
115
126
 
127
+ // Checked BEFORE the already-exists check, so an operator reaching for the
128
+ // cadence knob is told where it moved rather than told the row exists. A
129
+ // module's cadence does not live on its row, so accepting `--interval` here
130
+ // would store a number nothing reads — refuse rather than ignore.
131
+ if (kind === 'module_hook' && typeof flags.interval === 'string') {
132
+ return { success: false, error: moduleCadenceRedirect(target, '--interval') };
133
+ }
134
+
135
+ if (findMonitorByTarget(db, target)) {
136
+ return {
137
+ success: false,
138
+ error:
139
+ kind === 'module_hook'
140
+ ? `A monitor for "${target}" already exists — a deploy creates one for every module with a health_check hook.\n\nTo change how often it runs: celilo module config set ${target} health_check_interval <cadence>`
141
+ : `A monitor for "${target}" already exists.\n\nTo change how often it runs: celilo monitor set-interval ${target} <cadence>`,
142
+ };
143
+ }
144
+
145
+ const interval = typeof flags.interval === 'string' ? flags.interval : '15m';
146
+ const invalid = validateMonitorCadence(interval);
147
+ if (invalid) return { success: false, error: invalid };
148
+ const cadence = parseCadence(interval);
149
+ const intervalMinutes = cadence !== null && cadence !== 'manual' ? cadence.minutes : 0;
150
+
116
151
  createMonitor(db, { kind, target, intervalMinutes });
117
152
 
118
153
  // Creating the first monitor is also what switches the sweep on. Registering
@@ -127,7 +162,10 @@ function handleAdd(args: string[], flags: Record<string, boolean | string>): Com
127
162
 
128
163
  return {
129
164
  success: true,
130
- message: `Monitoring ${target} every ${interval} (sweep runs on timer.tick.5m)`,
165
+ message:
166
+ kind === 'module_hook'
167
+ ? `Watching ${target}. How often is per-module policy: celilo module config set ${target} health_check_interval <cadence>`
168
+ : `Monitoring ${target} every ${interval} (sweep runs on timer.tick.5m)`,
131
169
  };
132
170
  }
133
171
 
@@ -167,11 +205,68 @@ function handleToggle(args: string[], enabled: boolean): CommandResult {
167
205
  const db = getDb();
168
206
  const monitor = findMonitorByTarget(db, target);
169
207
  if (!monitor) return { success: false, error: `No monitor for "${target}".` };
208
+ if (monitor.kind === 'module_hook') {
209
+ return { success: false, error: moduleCadenceRedirect(target, `monitor ${verb}`) };
210
+ }
170
211
 
171
212
  setMonitorEnabled(db, monitor.id, enabled, new Date());
172
213
  return { success: true, message: `Monitor for ${target} ${enabled ? 'enabled' : 'disabled'}` };
173
214
  }
174
215
 
216
+ /**
217
+ * Two ways to change one module's cadence would disagree about what `module
218
+ * status` shows, so the fleet-level commands refuse a module target and name
219
+ * the per-module one. The targets are disjoint in practice — an operator has
220
+ * either a module or an audit check in hand — and an explicit error teaches
221
+ * better than silence.
222
+ */
223
+ function moduleCadenceRedirect(target: string, what: string): string {
224
+ return (
225
+ `${what} does not apply to "${target}" — it is a module, and a module's cadence is per-module policy rather than a monitor setting. Its monitor row is created by the deploy.\n\n` +
226
+ ` celilo module config set ${target} health_check_interval 15m # watch it every 15 minutes\n` +
227
+ ` celilo module config set ${target} health_check_interval manual # stop watching it\n` +
228
+ ` celilo module config unset ${target} health_check_interval # follow the manifest again`
229
+ );
230
+ }
231
+
232
+ /** The floor comes from the alerting sweep's own tick, never a written-down number. */
233
+ function validateMonitorCadence(value: string): string | null {
234
+ const result = cadenceSchema({ floorMinutes: MONITOR_INTERVAL_FLOOR_MINUTES }).safeParse(value);
235
+ if (result.success) return null;
236
+ return `Invalid interval "${value}".\n\n${result.error.issues.map((i) => i.message).join('\n')}`;
237
+ }
238
+
239
+ /**
240
+ * `celilo monitor set-interval <check> <cadence>` — re-cadence a built-in check
241
+ * without removing and recreating it, which would orphan its alert history.
242
+ */
243
+ function handleSetInterval(args: string[]): CommandResult {
244
+ const [target, cadence] = args;
245
+ if (!target || !cadence) {
246
+ return { success: false, error: 'Usage: celilo monitor set-interval <check> <cadence>' };
247
+ }
248
+
249
+ const db = getDb();
250
+ const monitor = findMonitorByTarget(db, target);
251
+ if (!monitor) return { success: false, error: `No monitor for "${target}".` };
252
+ if (monitor.kind === 'module_hook') {
253
+ return { success: false, error: moduleCadenceRedirect(target, 'monitor set-interval') };
254
+ }
255
+
256
+ const invalid = validateMonitorCadence(cadence);
257
+ if (invalid) return { success: false, error: invalid };
258
+ const parsed = parseCadence(cadence);
259
+ if (parsed === null || parsed === 'manual') {
260
+ return {
261
+ success: false,
262
+ error: `A built-in check has no "manual" — disable it instead: celilo monitor disable ${target}`,
263
+ };
264
+ }
265
+
266
+ updateMonitorInterval(db, monitor.id, parsed.minutes);
267
+ return { success: true, message: `${target} now runs every ${formatCadence(parsed)}` };
268
+ }
269
+
175
270
  export async function handleMonitor(
176
271
  subcommand: string | undefined,
177
272
  args: string[],
@@ -189,10 +284,12 @@ export async function handleMonitor(
189
284
  return handleToggle(args, true);
190
285
  case 'disable':
191
286
  return handleToggle(args, false);
287
+ case 'set-interval':
288
+ return handleSetInterval(args);
192
289
  default:
193
290
  return {
194
291
  success: false,
195
- error: `Unknown monitor subcommand: ${subcommand}\n\nUse: list, add, run, enable, disable`,
292
+ error: `Unknown monitor subcommand: ${subcommand}\n\nUse: list, add, run, set-interval, enable, disable`,
196
293
  };
197
294
  }
198
295
  }
@@ -14,6 +14,7 @@ import { createDbClient, findMigrationsFolder, getDb } from '../../db/client';
14
14
  import { runMigrationsOn } from '../../db/migrate';
15
15
  import { getMigrationStatus } from '../../db/migration-status';
16
16
  import { findSchemaDrift } from '../../db/schema-introspection';
17
+ import { migrateMonitorCadences } from '../../services/alerting/cadence-migration';
17
18
  import { ensureBackupSweepSubscriber } from '../../services/backup-sweep';
18
19
  import { ensureOperationsSweepSubscriber } from '../../services/module-operations';
19
20
  import type { CommandResult } from '../types';
@@ -141,6 +142,13 @@ export async function handleSystemMigrate(
141
142
 
142
143
  ensureCoreSubscribers();
143
144
 
145
+ // A health-check cadence used to live on the monitor row and now resolves
146
+ // from `module_configs`. Without carrying the existing rows over, this very
147
+ // upgrade would silently revert every operator's cadence to the manifest's
148
+ // suggestion and resume watching modules they deliberately stopped watching.
149
+ // Idempotent: writes only where no override exists.
150
+ const cadences = migrateMonitorCadences(db);
151
+
144
152
  // Name the latest migration, not just a table count: "35 tables" reads the
145
153
  // same whether a column migration applied or silently did nothing (celilo#604).
146
154
  const status = getMigrationStatus(sqlite, findMigrationsFolder());
@@ -148,6 +156,12 @@ export async function handleSystemMigrate(
148
156
  applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
149
157
  `Applied migrations: ${status.appliedCount} (latest: ${status.latestApplied ?? 'none'})`,
150
158
  `Schema current: ${drift.tableCount} tables, ${drift.columnCount} columns.`,
159
+ ...(cadences.written.size > 0
160
+ ? [
161
+ `Carried ${cadences.written.size} health-check cadence(s) into module config:`,
162
+ ...[...cadences.written].map(([moduleId, value]) => ` ${moduleId}: ${value}`),
163
+ ]
164
+ : []),
151
165
  ];
152
166
  return { success: true, message: lines.join('\n'), data: status };
153
167
  }
@@ -31,8 +31,9 @@ import { fetchLatestCliVersion } from '../../services/audit/cli-version';
31
31
  import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
32
32
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
33
33
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
34
+ import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
34
35
  import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
35
- import { parseStoredConfigValue } from '../../services/module-config';
36
+ import { configOverride, parseStoredConfigValue } from '../../services/module-config';
36
37
  import { deployModule } from '../../services/module-deploy';
37
38
  import { buildModuleGraph } from '../../services/update/dep-graph';
38
39
  import {
@@ -566,6 +567,7 @@ export async function handleSystemUpdate(
566
567
  id: m.id,
567
568
  state: m.state,
568
569
  manifest: m.manifestData as ModuleManifest,
570
+ scheduleOverride: configOverride(configsByModule.get(m.id), BACKUP_SCHEDULE_CONFIG_KEY),
569
571
  lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
570
572
  })),
571
573
  },
@@ -794,6 +796,7 @@ export function rebuildAuditDepsForRerun(
794
796
  id: m.id,
795
797
  state: m.state,
796
798
  manifest: m.manifestData as ModuleManifest,
799
+ scheduleOverride: configOverride(configsByModule.get(m.id), BACKUP_SCHEDULE_CONFIG_KEY),
797
800
  lastSuccessfulBackupAt: priorBackupByModule.get(m.id) ?? null,
798
801
  })),
799
802
  },
@@ -13,6 +13,7 @@ import { listBackups } from '../services/backup-metadata';
13
13
  import { listBackupStorages } from '../services/backup-storage';
14
14
  import { listContainerServices } from '../services/container-service';
15
15
  import { listMachines } from '../services/machine-pool';
16
+ import { FRAMEWORK_CONFIG_KEYS } from './commands/module-config';
16
17
 
17
18
  /**
18
19
  * Get completion suggestions based on current command context
@@ -41,6 +42,7 @@ export async function getCompletions(words: string[], current: number): Promise<
41
42
  'alerts',
42
43
  'escalation-policy',
43
44
  'events',
45
+ 'firewall',
44
46
  'help',
45
47
  'hook',
46
48
  'ipam',
@@ -200,9 +202,9 @@ export async function getCompletions(words: string[], current: number): Promise<
200
202
  return filterSuggestions(subcommands, args[1] || '');
201
203
  }
202
204
 
203
- // Module config subcommands (celilo module config set/get)
205
+ // Module config subcommands (celilo module config set/get/unset)
204
206
  if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
205
- const subcommands = ['set', 'get'];
207
+ const subcommands = ['set', 'get', 'unset'];
206
208
  return filterSuggestions(subcommands, args[2] || '');
207
209
  }
208
210
 
@@ -218,11 +220,11 @@ export async function getCompletions(words: string[], current: number): Promise<
218
220
  return filterSuggestions(subcommands, args[2] || '');
219
221
  }
220
222
 
221
- // Module config set/get - complete with module IDs
223
+ // Module config set/get/unset - complete with module IDs
222
224
  if (
223
225
  command === 'module' &&
224
226
  args[1] === 'config' &&
225
- (args[2] === 'set' || args[2] === 'get') &&
227
+ (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') &&
226
228
  currentIndex === 3
227
229
  ) {
228
230
  const db = getDb();
@@ -231,11 +233,16 @@ export async function getCompletions(words: string[], current: number): Promise<
231
233
  return filterSuggestions(moduleIds, args[3] || '');
232
234
  }
233
235
 
234
- // Module config set/get <module-id> - complete with config variable names
236
+ // Module config set/get/unset <module-id> - complete with config key names.
237
+ // Framework keys (backup cadence, upgrade policy…) are offered on EVERY
238
+ // module: they describe how celilo treats a module, so a module never
239
+ // declares them and completion built from the manifest alone could not see
240
+ // them — which is why `auto_upgrade` and `upgrade_policy` were uncompletable
241
+ // for as long as they have existed.
235
242
  if (
236
243
  command === 'module' &&
237
244
  args[1] === 'config' &&
238
- (args[2] === 'set' || args[2] === 'get') &&
245
+ (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') &&
239
246
  currentIndex === 4
240
247
  ) {
241
248
  const db = getDb();
@@ -244,13 +251,15 @@ export async function getCompletions(words: string[], current: number): Promise<
244
251
  .from(modules)
245
252
  .where(eq(modules.id, args[3] || ''))
246
253
  .get();
254
+ const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS);
247
255
  if (module?.manifestData) {
248
256
  const manifest = module.manifestData as ModuleManifest;
249
257
  const varNames = (manifest.variables?.owns || [])
250
258
  .filter((v) => v.source === 'user' || !v.source)
251
259
  .map((v) => v.name);
252
- return filterSuggestions(varNames, args[4] || '');
260
+ return filterSuggestions([...varNames, ...frameworkKeys], args[4] || '');
253
261
  }
262
+ return filterSuggestions(frameworkKeys, args[4] || '');
254
263
  }
255
264
 
256
265
  // Module secret subcommands (celilo module secret set/list)
@@ -395,6 +404,16 @@ export async function getCompletions(words: string[], current: number): Promise<
395
404
  }
396
405
 
397
406
  // Machine subcommands
407
+ if (command === 'firewall' && currentIndex === 1) {
408
+ // `interface` is the only group. `acknowledge` and `enforce` were deleted in
409
+ // the design amendments — there is no policy toggle to complete.
410
+ return filterSuggestions(['interface'], args[1] || '');
411
+ }
412
+
413
+ if (command === 'firewall' && args[1] === 'interface' && currentIndex === 2) {
414
+ return filterSuggestions(['list'], args[2] || '');
415
+ }
416
+
398
417
  if (command === 'machine' && currentIndex === 1) {
399
418
  const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
400
419
  return filterSuggestions(subcommands, args[1] || '');
@@ -484,7 +503,10 @@ export async function getCompletions(words: string[], current: number): Promise<
484
503
 
485
504
  // Monitor subcommands
486
505
  if (command === 'monitor' && currentIndex === 1) {
487
- return filterSuggestions(['list', 'add', 'run', 'enable', 'disable'], args[1] || '');
506
+ return filterSuggestions(
507
+ ['list', 'add', 'run', 'set-interval', 'enable', 'disable'],
508
+ args[1] || '',
509
+ );
488
510
  }
489
511
 
490
512
  // Monitor targets - a module ID or one of celilo's own schedulable checks.
@@ -492,7 +514,11 @@ export async function getCompletions(words: string[], current: number): Promise<
492
514
  // a new built-in check is completable the moment it is schedulable.
493
515
  if (
494
516
  command === 'monitor' &&
495
- (args[1] === 'add' || args[1] === 'run' || args[1] === 'enable' || args[1] === 'disable') &&
517
+ (args[1] === 'add' ||
518
+ args[1] === 'run' ||
519
+ args[1] === 'set-interval' ||
520
+ args[1] === 'enable' ||
521
+ args[1] === 'disable') &&
496
522
  currentIndex === 2
497
523
  ) {
498
524
  const db = getDb();
package/src/cli/index.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  handleEventsTail,
49
49
  handleEventsUninstallDaemon,
50
50
  } from './commands/events';
51
+ import { handleFirewallInterfaceList } from './commands/firewall-interface-list';
51
52
  import { handleHookRun } from './commands/hook-run';
52
53
  import {
53
54
  handleIpamIpListReservations,
@@ -68,7 +69,11 @@ import { moduleAudit } from './commands/module-audit';
68
69
  import { handleModuleBuild } from './commands/module-build';
69
70
  import { handleModuleChangeset } from './commands/module-changeset';
70
71
  import { handleModuleCheck } from './commands/module-check';
71
- import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-config';
72
+ import {
73
+ handleModuleConfigGet,
74
+ handleModuleConfigSet,
75
+ handleModuleConfigUnset,
76
+ } from './commands/module-config';
72
77
  import { handleModuleDeploy } from './commands/module-deploy';
73
78
  import { handleModuleGenerate } from './commands/module-generate';
74
79
  import { handleModuleHealth } from './commands/module-health';
@@ -204,6 +209,7 @@ Commands:
204
209
  storage Manage backup storage destinations
205
210
  backup Create and manage backups
206
211
  restore Restore a celilo-mgmt backup from a local file (fresh-bootstrap path)
212
+ firewall Inspect a firewall's interfaces (classification, on demand)
207
213
  machine Manage machine pool (bring-your-own-hardware)
208
214
  system Manage system configuration
209
215
  apt-upgrade Upgrade the deb-installed celilo packages + apply migrations
@@ -598,6 +604,7 @@ Subcommands:
598
604
 
599
605
  config set <id> <key> <value> Set module configuration value
600
606
  config get <id> [key] Get module configuration value(s)
607
+ config unset <id> <key> Remove an override; follow the manifest again
601
608
 
602
609
  secret set <id> <key> <value> Set encrypted module secret
603
610
  secret list <id> List module secrets
@@ -675,6 +682,8 @@ Examples:
675
682
  celilo module config set homebridge container_ip "192.168.0.110/24"
676
683
  celilo module config get homebridge
677
684
  celilo module config get homebridge hostname
685
+ celilo module config set caddy backup_schedule 6h
686
+ celilo module config unset caddy backup_schedule
678
687
  celilo module show-config homebridge
679
688
  celilo module show-zone homebridge
680
689
  celilo module build caddy
@@ -917,6 +926,28 @@ Related Commands:
917
926
  /**
918
927
  * Display machine command help
919
928
  */
929
+ function displayFirewallHelp(): CommandResult {
930
+ console.log(`
931
+ celilo firewall — inspect a firewall's interfaces
932
+
933
+ Usage:
934
+ celilo firewall interface list [<hostname>]
935
+
936
+ Shows how celilo classifies every interface on a firewall: the zone it
937
+ matched, the external edge, or ALIEN when nothing accounts for it.
938
+
939
+ Read-only. It reads the recorded interface table and the declared zone
940
+ subnets and classifies in memory — it never touches the box, so it is safe
941
+ to run against a firewall whose converge is currently refusing.
942
+
943
+ With no hostname, every machine celilo classifies as a router is shown.
944
+
945
+ An interface celilo cannot attribute is resolved by DECLARING it:
946
+ celilo system config set network.<zone>.subnet <cidr>
947
+ `);
948
+ return { success: true, message: 'firewall help' };
949
+ }
950
+
920
951
  function displayMachineHelp(): CommandResult {
921
952
  const helpText = `
922
953
  Celilo - Machine Pool Management
@@ -1544,7 +1575,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1544
1575
  if (!configSubcommand) {
1545
1576
  return {
1546
1577
  success: false,
1547
- error: 'Config action required (set or get)\n\nRun "celilo help" for usage',
1578
+ error: 'Config action required (set, get or unset)\n\nRun "celilo help" for usage',
1548
1579
  };
1549
1580
  }
1550
1581
  const configArgs = parsed.args.slice(1);
@@ -1554,6 +1585,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1554
1585
  if (configSubcommand === 'get') {
1555
1586
  return handleModuleConfigGet(configArgs);
1556
1587
  }
1588
+ if (configSubcommand === 'unset') {
1589
+ return handleModuleConfigUnset(configArgs);
1590
+ }
1557
1591
  return {
1558
1592
  success: false,
1559
1593
  error: `Unknown config action: ${configSubcommand}`,
@@ -2050,6 +2084,29 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2050
2084
  };
2051
2085
  }
2052
2086
 
2087
+ if (parsed.command === 'firewall') {
2088
+ if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
2089
+ return displayFirewallHelp();
2090
+ }
2091
+ if (parsed.subcommand === 'interface') {
2092
+ // `interface` is a group, not a leaf: `list` is its only verb today.
2093
+ // `acknowledge` and `enforce` were both deleted in the design amendments —
2094
+ // there is no policy toggle and no per-interface escape hatch.
2095
+ const verb = parsed.args[0];
2096
+ if (!verb || verb === 'list') {
2097
+ return handleFirewallInterfaceList(parsed.args.slice(1), parsed.flags);
2098
+ }
2099
+ return {
2100
+ success: false,
2101
+ error: `Unknown firewall interface subcommand: ${verb}\n\nRun "celilo firewall --help" for usage`,
2102
+ };
2103
+ }
2104
+ return {
2105
+ success: false,
2106
+ error: `Unknown firewall subcommand: ${parsed.subcommand}\n\nRun "celilo firewall --help" for usage`,
2107
+ };
2108
+ }
2109
+
2053
2110
  if (parsed.command === 'machine') {
2054
2111
  // Handle machine --help
2055
2112
  if (parsed.flags.help || parsed.flags.h) {
@@ -88,9 +88,11 @@ export const ALL_CATEGORIES: readonly DriftCategory[] = [
88
88
  'disk_space',
89
89
  'transport_reads',
90
90
  'trusted_sources',
91
+ 'interface_classification',
91
92
  ];
92
93
 
93
94
  export const CATEGORY_LABELS: Record<DriftCategory, string> = {
95
+ interface_classification: 'Firewall interfaces',
94
96
  cli_version: 'CLI version',
95
97
  schema: 'Schema migrations',
96
98
  capability_abi: 'Capability ABI',