@celilo/cli 4.0.0 → 4.1.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 (51) hide show
  1. package/CELILO_SUBSYSTEMS.md +1 -0
  2. package/package.json +5 -4
  3. package/src/cli/command-tree-parser.test.ts +18 -0
  4. package/src/cli/command-tree-parser.ts +12 -0
  5. package/src/cli/commands/api.ts +67 -14
  6. package/src/cli/commands/events.ts +5 -2
  7. package/src/cli/commands/machine-reclassify.ts +79 -0
  8. package/src/cli/commands/system-audit.ts +22 -0
  9. package/src/cli/commands/system-reboot.ts +126 -0
  10. package/src/cli/commands/system-update.ts +5 -0
  11. package/src/cli/completion.ts +3 -2
  12. package/src/cli/index.ts +19 -0
  13. package/src/cli/tui/audit-state.ts +4 -0
  14. package/src/hooks/broker.ts +31 -0
  15. package/src/hooks/capability-loader.ts +8 -2
  16. package/src/hooks/executor.ts +20 -2
  17. package/src/hooks/test-fixtures/slow-capability-hook.ts +28 -0
  18. package/src/hooks/timeout-names-provider.test.ts +87 -0
  19. package/src/services/api-access.test.ts +44 -0
  20. package/src/services/audit/index.test.ts +7 -0
  21. package/src/services/audit/index.ts +15 -0
  22. package/src/services/audit/machine-interface-zones.test.ts +30 -0
  23. package/src/services/audit/machine-interface-zones.ts +43 -0
  24. package/src/services/audit/reboot-pending.test.ts +63 -0
  25. package/src/services/audit/reboot-pending.ts +85 -0
  26. package/src/services/audit/recurrence-gate.test.ts +21 -0
  27. package/src/services/audit/server-bun-pin.test.ts +44 -0
  28. package/src/services/audit/server-bun-pin.ts +95 -0
  29. package/src/services/audit/types.ts +2 -0
  30. package/src/services/capability-compat.test.ts +22 -0
  31. package/src/services/capability-compat.ts +15 -2
  32. package/src/services/celilo-events.test.ts +39 -0
  33. package/src/services/celilo-events.ts +41 -4
  34. package/src/services/deploy-preflight.ts +12 -3
  35. package/src/services/deploy-validation.test.ts +81 -2
  36. package/src/services/deploy-validation.ts +19 -0
  37. package/src/services/fleet-checks.ts +40 -10
  38. package/src/services/machine-interface-zones.test.ts +73 -0
  39. package/src/services/machine-interface-zones.ts +101 -0
  40. package/src/services/module-deploy.stale-provider-guard.test.ts +98 -0
  41. package/src/services/module-deploy.ts +31 -10
  42. package/src/services/provider-arrival.test.ts +62 -46
  43. package/src/services/provider-arrival.ts +41 -10
  44. package/src/services/system-reboot.test.ts +231 -0
  45. package/src/services/system-reboot.ts +357 -0
  46. package/src/services/update/orchestrator.test.ts +164 -0
  47. package/src/services/update/orchestrator.ts +43 -12
  48. package/src/services/update/types.ts +10 -1
  49. package/src/templates/generator.test.ts +15 -4
  50. package/src/templates/generator.ts +12 -4
  51. package/src/variables/context.ts +14 -1
@@ -212,6 +212,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
212
212
  - **Inbound** (`reconcileAspectsForSystems`): every approved aspect applied to systems that have just come into existence. Called from `module-deploy.ts` between `waitForSSH` and `executeAnsible` (so a module's playbook and `on_install` see a correctly configured host) and from `machine-add.ts`. Eligibility is `applicable_zones` + approval; the aspect's `triggers` list is deliberately NOT consulted, so convergence is not opt-in per manifest. A failure here IS fatal to the deploy that created the system — the aspect is a prerequisite of that host — while a failure on `machine add` is only a warning. PAUSED providers are skipped, which is the escape hatch for a wedged aspect. Nothing is rolled back: rows, guest and IPAM allocation persist and a re-run converges on the same system. Fixes celilo#902, where a system provisioned after its provider deployed silently never received the aspect. See `openspec/changes/aspect-fanout-new-systems/`.
213
213
  - **Coverage verification** — `verifyAspectCoverage` + `celilo system doctor --deep [--fix]`. Answers "is any system missing an aspect its zone entitles it to" WITHOUT stored state: the entitled set is `planAspectFanOut` itself, and coverage is measured by evaluating the role against the host in Ansible check mode (`executeAnsible({check:true})` + `parseAnsibleRecap`). Three outcomes, not two — `changed=0,skipped=0` applied, `changed>0` missing, **`skipped>0` unknown**, because check mode SKIPS a task it cannot evaluate and a `command`/`shell` role would otherwise report clean having never run.
214
214
  - **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
215
+ - **Management-server reboot** — `apps/celilo/src/services/system-reboot.ts`, surfaced as `celilo system reboot [--allow-reboot] [--verify]` (`apps/celilo/src/cli/commands/system-reboot.ts`) and reachable over MCP like any other registry leaf. Rebooting THIS box takes down the dispatcher, the MCP server and the remote API, so `requestReboot` refuses while `checkInFlight` reports a module operation running and names it, and consent follows the flag-or-interview rule (`--allow-reboot` wins, otherwise an event-bus question — never stdin). **Verification is a second call, by construction**: the process asking for the reboot dies in it, so no single invocation can both reboot the host and report that it came back. `requestReboot` records `state/reboot-requested.json` carrying the boot id at request time; `verifyReturn` (`--verify`) passes only once that boot id has CHANGED and the dispatcher heartbeat and installed CLI both answer, and names which of the three is missing when it has not. It refuses up front when the boot id is unreadable, because a reboot whose return could never be verified is the out-of-band `sudo reboot` this replaces. `judgeRebootPending` compares the running kernel against the newest installed one and reads Debian's `/var/run/reboot-required`; either tell is enough, neither readable is `unmeasured` rather than clean. Both facts surface as the `reboot_pending` audit category (`apps/celilo/src/services/audit/reboot-pending.ts`): a pending reboot is `drift`, an UNVERIFIED one is `blocked` — that is a control plane that may not have come back. celilo#1378.
215
216
  - **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/specs/module-pause/spec.md`.
216
217
  - **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
217
218
  - **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "dev": "bun run src/cli/index.ts",
36
36
  "test": "bun test --timeout 60000 src/",
37
37
  "test:integration": "bun test --timeout 60000 test-integration/",
38
+ "test:integration:pr": "bun test --timeout 60000 test-integration/alerting test-integration/ansible test-integration/api test-integration/cli test-integration/completion test-integration/deploy test-integration/golden test-integration/machine test-integration/module test-integration/publish test-integration/secrets test-integration/service test-integration/sizing test-integration/system test-integration/values test-integration/module-integrity-baseline.test.ts",
38
39
  "test:integration-slow": "bun test --timeout 300000 test-integration/",
39
40
  "test:completion": "bun test --timeout 60000 test-integration/completion/",
40
41
  "test:all": "bun test --timeout 60000 src/ test-integration/",
@@ -58,10 +59,10 @@
58
59
  "dependencies": {
59
60
  "@aws-sdk/client-s3": "^3.1109.0",
60
61
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^5.1.0",
62
+ "@celilo/capabilities": "^5.1.1",
62
63
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.13.0",
64
- "@celilo/event-bus": "^0.6.0",
64
+ "@celilo/core": "^0.14.0",
65
+ "@celilo/event-bus": "^0.7.0",
65
66
  "ajv": "^8.18.0",
66
67
  "drizzle-orm": "^0.36.4",
67
68
  "ink": "^7.0.1",
@@ -162,6 +162,24 @@ Commands:
162
162
  expect(commands.filter((c: string) => c === 'module')).toHaveLength(1);
163
163
  });
164
164
 
165
+ test('treats wrapped description lines as continuations, not subcommands', () => {
166
+ // celilo#1409: a wrapped help description's continuation lines matched the
167
+ // subcommand regex, so prose words were reported as missing registry entries.
168
+ const helpText = `
169
+ Commands:
170
+ reboot Reboot the system if an operation
171
+ dies, and it
172
+ came back
173
+ status Show system status
174
+ `;
175
+
176
+ // biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
177
+ const parseMethod = (parser as any).parseHelpText.bind(parser);
178
+ const commands = parseMethod(helpText);
179
+
180
+ expect(commands).toEqual(['reboot', 'status']);
181
+ });
182
+
165
183
  test('handles hyphenated command names', () => {
166
184
  const helpText = `
167
185
  Commands:
@@ -100,12 +100,16 @@ export class CommandTreeParser {
100
100
 
101
101
  // Track whether we're in a Commands/Subcommands section
102
102
  let inCommandSection = false;
103
+ // Column where the last parsed entry's description starts; anything indented
104
+ // that far is wrapped prose, not a subcommand.
105
+ let descriptionColumn: number | null = null;
103
106
 
104
107
  for (const line of lines) {
105
108
  // Check if we're entering a Commands section
106
109
  // Match " Commands:" or "│ Commands:"
107
110
  if (/^[│\s]*(Commands|Subcommands):\s*$/i.test(line)) {
108
111
  inCommandSection = true;
112
+ descriptionColumn = null;
109
113
  continue;
110
114
  }
111
115
 
@@ -134,8 +138,16 @@ export class CommandTreeParser {
134
138
  // " command-name Description"
135
139
  const match = line.match(/^[│\s]*([a-z][a-z0-9-]*)(?:\s{2,}|\s+[<a-z])/);
136
140
 
141
+ // A wrapped description's continuation lines are indented to the previous
142
+ // entry's description column and match the pattern above (celilo#1409).
143
+ const indent = line.search(/[^│\s]/);
144
+ if (descriptionColumn !== null && indent >= descriptionColumn) {
145
+ continue;
146
+ }
147
+
137
148
  if (match) {
138
149
  const cmdName = match[1];
150
+ descriptionColumn = match[0].length;
139
151
 
140
152
  // Skip examples (lines starting with "celilo")
141
153
  if (cmdName === 'celilo') {
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
9
9
  import { homedir } from 'node:os';
10
10
  import { dirname, join } from 'node:path';
11
11
  import {
12
+ getPrincipalByName,
12
13
  grantPrincipal,
13
14
  listPrincipals,
14
15
  renderAuthorizedKeys,
@@ -22,7 +23,25 @@ function errMsg(error: unknown): string {
22
23
  return error instanceof Error ? error.message : String(error);
23
24
  }
24
25
 
25
- /** `celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>` */
26
+ function grantList(value: unknown): string[] {
27
+ return typeof value === 'string'
28
+ ? value
29
+ .split(',')
30
+ .map((g) => g.trim())
31
+ .filter(Boolean)
32
+ : [];
33
+ }
34
+
35
+ /**
36
+ * `celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,...]>`
37
+ *
38
+ * `--can` REPLACES the grant set; `--add`/`--remove` amend it. Replace-only was
39
+ * the whole surface, so adding one permission meant re-supplying the full list
40
+ * plus the key, and a typo in either silently narrowed the principal or changed
41
+ * its key (celilo#1404). So: `--key` is required only when CREATING a principal,
42
+ * and a `--can` that drops grants an existing principal holds is refused unless
43
+ * `--force` says the narrowing is intended.
44
+ */
26
45
  export async function handleApiGrant(
27
46
  args: string[],
28
47
  flags: Record<string, boolean | string> = {},
@@ -32,37 +51,71 @@ export async function handleApiGrant(
32
51
  if (!name) {
33
52
  return {
34
53
  success: false,
35
- error: 'Usage: celilo api grant <principal> --key <pubkey|path> --can <grant[,grant...]>',
54
+ error:
55
+ 'Usage: celilo api grant <principal> [--key <pubkey|path>] --can|--add|--remove <grant[,grant...]>',
36
56
  };
37
57
  }
38
58
 
39
- const keyArg = typeof flags.key === 'string' ? flags.key : '';
40
- if (!keyArg) {
59
+ const replace = grantList(flags.can);
60
+ const added = grantList(flags.add);
61
+ const removed = grantList(flags.remove);
62
+ if (replace.length > 0 && (added.length > 0 || removed.length > 0)) {
41
63
  return {
42
64
  success: false,
43
- error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
65
+ error: '--can replaces the grant set; use it OR --add/--remove, not both.',
66
+ };
67
+ }
68
+ if (replace.length === 0 && added.length === 0 && removed.length === 0) {
69
+ return {
70
+ success: false,
71
+ error:
72
+ 'One of --can <grants> (replace), --add <grants>, or --remove <grants> is required (e.g. --add module:journal).',
44
73
  };
45
74
  }
46
75
 
47
- const canArg = typeof flags.can === 'string' ? flags.can : '';
48
- if (!canArg) {
76
+ const existing = await getPrincipalByName(name);
77
+ if (!existing && (added.length > 0 || removed.length > 0)) {
49
78
  return {
50
79
  success: false,
51
- error: '--can <grant[,grant...]> is required (e.g. --can module:deploy,service:*)',
80
+ error: `No API principal named "${name}" — --add/--remove amend an existing one. Create it with --key and --can.`,
52
81
  };
53
82
  }
54
83
 
55
- const publicKey = existsSync(keyArg) ? readFileSync(keyArg, 'utf8').trim() : keyArg.trim();
56
- const grants = canArg
57
- .split(',')
58
- .map((g) => g.trim())
59
- .filter(Boolean);
84
+ const keyArg = typeof flags.key === 'string' ? flags.key : '';
85
+ if (!keyArg && !existing) {
86
+ return {
87
+ success: false,
88
+ error: '--key <public-key-or-path> is required (e.g. ~/.ssh/id_ed25519.pub)',
89
+ };
90
+ }
91
+ // Keep the enrolled key when none is supplied, so amending grants can never
92
+ // rotate the key by omission.
93
+ const publicKey = keyArg
94
+ ? existsSync(keyArg)
95
+ ? readFileSync(keyArg, 'utf8').trim()
96
+ : keyArg.trim()
97
+ : (existing?.publicKey ?? '');
98
+
99
+ const current = existing?.grants ?? [];
100
+ let grants: string[];
101
+ if (replace.length > 0) {
102
+ const dropped = current.filter((g) => !replace.includes(g));
103
+ if (dropped.length > 0 && flags.force !== true) {
104
+ return {
105
+ success: false,
106
+ error: `Refusing to narrow "${name}": --can would drop ${dropped.join(', ')}. Use --add/--remove to amend, or --force to replace anyway.`,
107
+ };
108
+ }
109
+ grants = replace;
110
+ } else {
111
+ grants = [...new Set([...current, ...added])].filter((g) => !removed.includes(g));
112
+ }
60
113
 
61
114
  const { principal, created } = await grantPrincipal({ name, publicKey, grants });
62
115
 
63
116
  return {
64
117
  success: true,
65
- message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ')}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
118
+ message: `${created ? 'Granted' : 'Updated'} API access for "${principal.name}" → ${grants.join(', ') || '(none)'}\n\nInstall the forced-command line on celilo-mgr with:\n celilo api authorized-keys >> ~celilo-api/.ssh/authorized_keys`,
66
119
  };
67
120
  } catch (error) {
68
121
  return { success: false, error: `Failed to grant API access: ${errMsg(error)}` };
@@ -6,7 +6,7 @@
6
6
  * second tool.
7
7
  *
8
8
  * Subcommands:
9
- * status bus.health() as JSON
9
+ * status bus.healthConfirmed() as JSON
10
10
  * tail recent events
11
11
  * list-subscribers persistent subscribers
12
12
  * list-pending pending deliveries (subscriber fan-out — NOT questions)
@@ -80,7 +80,10 @@ function jsonResult(data: unknown): CommandResult {
80
80
  export async function handleEventsStatus(): Promise<CommandResult> {
81
81
  const bus = openCliBus();
82
82
  try {
83
- return jsonResult(bus.health());
83
+ // Confirmed: the ansible task and the fleet check both read this verdict and
84
+ // act on it, so it must not say `wedged` about a dispatcher the reconcile path
85
+ // calls `lagging` (celilo#1399). One threshold, one answer.
86
+ return jsonResult(await bus.healthConfirmed());
84
87
  } finally {
85
88
  bus.close();
86
89
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Machine Reclassify Command
3
+ *
4
+ * Re-runs zone detection over a machine's STORED interface labels and reports
5
+ * old → new. Dry-run by default: an operator sees every relabel before any of
6
+ * them is written. `--apply` writes.
7
+ *
8
+ * See services/machine-interface-zones.ts for why stale labels happen.
9
+ */
10
+
11
+ import { eq } from 'drizzle-orm';
12
+ import { getDb } from '../../db/client';
13
+ import { machines } from '../../db/schema';
14
+ import { applyReclassification, diffInterfaceZones } from '../../services/machine-interface-zones';
15
+ import { getMachineByHostname, getMachineByIp } from '../../services/machine-pool';
16
+ import { detectZoneFromIp } from '../../services/zone-detector';
17
+ import { celiloIntro, celiloOutro } from '../prompts';
18
+ import type { CommandResult } from '../types';
19
+
20
+ const USAGE =
21
+ 'Usage:\n celilo machine reclassify <hostname|ip> # show what would change\n celilo machine reclassify <hostname|ip> --apply # write the new labels';
22
+
23
+ export async function handleMachineReclassify(
24
+ args: string[],
25
+ flags: Record<string, boolean | string> = {},
26
+ ): Promise<CommandResult> {
27
+ try {
28
+ celiloIntro('Re-classify Machine Interfaces');
29
+
30
+ const identifier = args[0];
31
+ if (!identifier) {
32
+ return { success: false, error: `Machine hostname or IP is required\n\n${USAGE}` };
33
+ }
34
+
35
+ const isIp = /^\d+\.\d+\.\d+\.\d+$/.test(identifier);
36
+ const machine = isIp
37
+ ? await getMachineByIp(identifier)
38
+ : await getMachineByHostname(identifier);
39
+
40
+ if (!machine) {
41
+ return { success: false, error: `Machine not found: ${identifier}` };
42
+ }
43
+
44
+ const interfaces = machine.interfaces ?? [];
45
+ const changes = await diffInterfaceZones(interfaces, detectZoneFromIp);
46
+
47
+ if (changes.length === 0) {
48
+ celiloOutro(
49
+ `${machine.hostname}: all ${interfaces.length} interface zone label(s) already match the current zone model`,
50
+ );
51
+ return { success: true, message: `${machine.hostname}: no re-classification needed` };
52
+ }
53
+
54
+ const lines = changes.map((c) => ` ${c.name} (${c.ipAddress}): ${c.from} → ${c.to}`);
55
+ const summary = `${machine.hostname}: ${changes.length} interface(s) would be re-classified\n${lines.join('\n')}`;
56
+
57
+ if (!flags.apply) {
58
+ celiloOutro(`${summary}\n\nRe-run with --apply to write these labels.`);
59
+ return { success: true, message: summary };
60
+ }
61
+
62
+ getDb()
63
+ .update(machines)
64
+ .set({ interfaces: applyReclassification(interfaces, changes) })
65
+ .where(eq(machines.id, machine.id))
66
+ .run();
67
+
68
+ celiloOutro(`${summary}\n\nWritten.`);
69
+ return {
70
+ success: true,
71
+ message: `${machine.hostname}: re-classified ${changes.length} interface(s)`,
72
+ };
73
+ } catch (error) {
74
+ return {
75
+ success: false,
76
+ error: `Failed to re-classify machine interfaces: ${error instanceof Error ? error.message : String(error)}`,
77
+ };
78
+ }
79
+ }
@@ -26,6 +26,7 @@ import { testProxmoxConnection } from '../../api-clients/proxmox';
26
26
  import { getDb } from '../../db/client';
27
27
  import {
28
28
  capabilitySecrets,
29
+ machines,
29
30
  moduleConfigs as moduleConfigsTbl,
30
31
  modules,
31
32
  secrets,
@@ -52,6 +53,7 @@ import {
52
53
  } from '../../services/audit/public-dns-source';
53
54
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
54
55
  import type { SecretCheckResult } from '../../services/audit/secrets-decryptable';
56
+ import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
55
57
  import type { ServiceCredentialsResult } from '../../services/audit/services-credentials';
56
58
  import type { ServiceReachableResult } from '../../services/audit/services-reachable';
57
59
  import type { TerraformPlanRunner } from '../../services/audit/terraform-plan';
@@ -63,6 +65,7 @@ import { collectJailExemptions } from '../../services/jail-exemptions';
63
65
  import { probeMachines } from '../../services/machine-probe';
64
66
  import { parseStoredConfigValue } from '../../services/module-config';
65
67
  import { createPublicDnsProbe, loadPublicDnsProbeSettings } from '../../services/public-dns-probe';
68
+ import { collectRebootPendingDeps } from '../../services/system-reboot';
66
69
  import { buildTerraformEnvForModule } from '../../services/terraform-env';
67
70
  import { hasFlag } from '../parser';
68
71
  import type { CommandResult } from '../types';
@@ -368,6 +371,22 @@ async function buildAuditDeps(
368
371
  probeDiskUsage(db),
369
372
  ]);
370
373
 
374
+ // Stale interface zone labels. Reads only what is already stored — the
375
+ // detector matches an address against the declared subnets, so this costs
376
+ // no SSH and cannot be confused by an unreachable machine.
377
+ const { diffInterfaceZones } = await import('../../services/machine-interface-zones');
378
+ const { detectZoneFromIp } = await import('../../services/zone-detector');
379
+ const machineZoneLabels = await Promise.all(
380
+ db
381
+ .select()
382
+ .from(machines)
383
+ .all()
384
+ .map(async (m) => ({
385
+ hostname: m.hostname,
386
+ changes: await diffInterfaceZones(m.interfaces, detectZoneFromIp),
387
+ })),
388
+ );
389
+
371
390
  const migrationsFolder = findMigrationsFolderSafe();
372
391
 
373
392
  return {
@@ -388,6 +407,8 @@ async function buildAuditDeps(
388
407
  })),
389
408
  },
390
409
  browserPin: collectBrowserPinDeps(deployedModules),
410
+ serverBunPin: collectServerBunPinDeps(),
411
+ rebootPending: collectRebootPendingDeps(),
391
412
  terraformPlan: {
392
413
  modules: terraformModules,
393
414
  run: realTerraformPlan,
@@ -429,6 +450,7 @@ async function buildAuditDeps(
429
450
  secretsDecryptable: { results: secretResults },
430
451
  servicesReachable: { results: serviceReachableResults },
431
452
  machinesReachable: { results: machineReachableResults },
453
+ machineInterfaceZones: { machines: machineZoneLabels },
432
454
  // The only check here whose vantage point is OUTSIDE the fleet. Its
433
455
  // undetermined counters are read but not written from this path: an
434
456
  // operator-run audit is not a run of a schedule, so counting it towards
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `celilo system reboot` — reboot the management server (celilo#1378).
3
+ *
4
+ * Thin adapter (Rule 10.5): parse flags, obtain consent, compose the real
5
+ * host's dependencies, call the service, format the result. Every judgement —
6
+ * the in-flight refusal, the boot-id bookkeeping, what counts as "came back" —
7
+ * lives in `services/system-reboot.ts` and is tested against a fake host.
8
+ *
9
+ * Consent follows the repo's flag-or-interview rule: `--allow-reboot` (or
10
+ * `--yes`) wins, otherwise the question goes on the event bus so a headless
11
+ * responder can answer it. No stdin prompt.
12
+ *
13
+ * `--verify` is the other half, and it exists because the process asking for a
14
+ * reboot dies in it: no single call can both reboot this host and report that
15
+ * it came back. See the service's header.
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { readFileSync } from 'node:fs';
20
+ import { defineEvents, openBus } from '@celilo/event-bus';
21
+ import { getEventBusPath } from '../../config/paths';
22
+ import { askConfirm, withInterviewSession } from '../../services/bus-interview';
23
+ import {
24
+ BOOT_ID_PATH,
25
+ collectRebootPendingDeps,
26
+ requestReboot,
27
+ verifyReturn,
28
+ } from '../../services/system-reboot';
29
+ import { hasFlag } from '../parser';
30
+ import type { CommandResult } from '../types';
31
+
32
+ /** The deb's wrapper — the same binary `apt-upgrade` re-execs for migrations. */
33
+ const CELILO_BIN = '/usr/local/bin/celilo';
34
+
35
+ /** This command publishes nothing; it only reads the bus's health. */
36
+ const NO_SCHEMAS = defineEvents({});
37
+
38
+ function readBootId(): string | null {
39
+ try {
40
+ const id = readFileSync(BOOT_ID_PATH, 'utf-8').trim();
41
+ return id.length > 0 ? id : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Does the installed CLI answer? Its version, or null. */
48
+ function installedCliVersion(): string | null {
49
+ try {
50
+ const { status, stdout } = spawnSync(CELILO_BIN, ['--version'], { encoding: 'utf-8' });
51
+ if (status !== 0) return null;
52
+ const version = (stdout ?? '').trim();
53
+ return version.length > 0 ? version : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Is the dispatcher alive? Confirmed rather than sampled — a dispatcher
61
+ * starved on a box that just booted looks exactly like a dead one for one
62
+ * sample (celilo#1399).
63
+ */
64
+ async function dispatcherHeartbeatLive(): Promise<boolean> {
65
+ let bus: ReturnType<typeof openBus> | undefined;
66
+ try {
67
+ bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
68
+ const health = await bus.healthConfirmed();
69
+ return health.status !== 'no_dispatcher' && health.status !== 'wedged';
70
+ } catch {
71
+ return false;
72
+ } finally {
73
+ bus?.close?.();
74
+ }
75
+ }
76
+
77
+ export async function handleSystemReboot(
78
+ _args: string[],
79
+ flags: Record<string, string | boolean>,
80
+ ): Promise<CommandResult> {
81
+ if (hasFlag(flags, 'verify')) {
82
+ const result = await verifyReturn({
83
+ bootId: readBootId,
84
+ heartbeatLive: dispatcherHeartbeatLive,
85
+ cliVersion: installedCliVersion,
86
+ });
87
+ return result.success
88
+ ? { success: true, message: result.message ?? 'Reboot verified.' }
89
+ : { success: false, error: result.error ?? 'Reboot could not be verified.' };
90
+ }
91
+
92
+ // Report the condition before asking, so the operator is answering about
93
+ // the host in front of them rather than about the word "reboot".
94
+ const { verdict } = collectRebootPendingDeps();
95
+ const why = verdict.pending
96
+ ? verdict.reasons.join('; ')
97
+ : 'no pending kernel or deferred restart was detected';
98
+
99
+ const consented =
100
+ flags['allow-reboot'] === true ||
101
+ hasFlag(flags, 'yes') ||
102
+ (await withInterviewSession(() =>
103
+ askConfirm({
104
+ scope: 'system-reboot',
105
+ key: 'reboot',
106
+ message: `Reboot the management server? This takes down the dispatcher, the MCP server and the remote API. Condition: ${why}.`,
107
+ defaultValue: false,
108
+ }),
109
+ ));
110
+
111
+ if (!consented) {
112
+ return { success: false, error: 'Reboot not approved — re-run with --allow-reboot.' };
113
+ }
114
+
115
+ const result = requestReboot(
116
+ {
117
+ bootId: readBootId,
118
+ issueReboot: () => spawnSync('sudo', ['systemctl', 'reboot'], { stdio: 'inherit' }),
119
+ },
120
+ why,
121
+ );
122
+
123
+ return result.success
124
+ ? { success: true, message: result.message ?? 'Reboot issued.' }
125
+ : { success: false, error: result.error ?? 'Reboot refused.' };
126
+ }
@@ -31,12 +31,14 @@ import { collectBrowserPinDeps } from '../../services/audit/browser-pin';
31
31
  import { fetchLatestCliVersion } from '../../services/audit/cli-version';
32
32
  import { unusedPublicDnsProbe } from '../../services/audit/public-dns';
33
33
  import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
34
+ import { collectServerBunPinDeps } from '../../services/audit/server-bun-pin';
34
35
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
35
36
  import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
36
37
  import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
37
38
  import { collectJailExemptions } from '../../services/jail-exemptions';
38
39
  import { configOverride, parseStoredConfigValue } from '../../services/module-config';
39
40
  import { deployModule } from '../../services/module-deploy';
41
+ import { collectRebootPendingDeps } from '../../services/system-reboot';
40
42
  import { buildModuleGraph } from '../../services/update/dep-graph';
41
43
  import {
42
44
  type ModuleSnapshot,
@@ -549,6 +551,8 @@ export async function handleSystemUpdate(
549
551
  })),
550
552
  },
551
553
  browserPin: collectBrowserPinDeps(upgradableModules),
554
+ serverBunPin: collectServerBunPinDeps(),
555
+ rebootPending: collectRebootPendingDeps(),
552
556
  terraformPlan: {
553
557
  modules: upgradableModules.map((m) => ({
554
558
  id: m.id,
@@ -616,6 +620,7 @@ export async function handleSystemUpdate(
616
620
  secretsDecryptable: { results: [] },
617
621
  servicesReachable: { results: [] },
618
622
  machinesReachable: { results: [] },
623
+ machineInterfaceZones: { machines: [] },
619
624
  // Public reachability needs a network round trip per name; the update
620
625
  // flow's partial audit does no probing. `system audit` and the scheduled
621
626
  // monitor own this check.
@@ -439,14 +439,14 @@ export async function getCompletions(words: string[], current: number): Promise<
439
439
  }
440
440
 
441
441
  if (command === 'machine' && currentIndex === 1) {
442
- const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect'];
442
+ const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'reclassify', 'detect'];
443
443
  return filterSuggestions(subcommands, args[1] || '');
444
444
  }
445
445
 
446
446
  // Machine remove/earmark - complete with machine hostnames and IPs
447
447
  if (
448
448
  command === 'machine' &&
449
- (args[1] === 'remove' || args[1] === 'earmark') &&
449
+ (args[1] === 'remove' || args[1] === 'earmark' || args[1] === 'reclassify') &&
450
450
  currentIndex === 2
451
451
  ) {
452
452
  const machineList = await listMachines();
@@ -704,6 +704,7 @@ export async function getCompletions(words: string[], current: number): Promise<
704
704
  'update',
705
705
  'doctor',
706
706
  'migrate',
707
+ 'reboot',
707
708
  ];
708
709
  return filterSuggestions(subcommands, args[1] || '');
709
710
  }
package/src/cli/index.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  import { handleMachineAdd } from './commands/machine-add';
66
66
  import { handleMachineEarmark } from './commands/machine-earmark';
67
67
  import { handleMachineList } from './commands/machine-list';
68
+ import { handleMachineReclassify } from './commands/machine-reclassify';
68
69
  import { handleMachineRemove } from './commands/machine-remove';
69
70
  import { handleMachineStatus } from './commands/machine-status';
70
71
  import { moduleAudit } from './commands/module-audit';
@@ -132,6 +133,7 @@ import { handleSystemDoctor } from './commands/system-doctor';
132
133
  import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
133
134
  import { handleSystemInit } from './commands/system-init';
134
135
  import { handleSystemMigrate } from './commands/system-migrate';
136
+ import { handleSystemReboot } from './commands/system-reboot';
135
137
  import { handleSystemSecretGet } from './commands/system-secret-get';
136
138
  import { handleSystemSecretSet } from './commands/system-secret-set';
137
139
  import { handleSystemUpdate } from './commands/system-update';
@@ -1097,6 +1099,10 @@ Examples:
1097
1099
  celilo machine earmark 10.0.10.10 caddy
1098
1100
  celilo machine earmark pi-01 --clear
1099
1101
 
1102
+ # Re-classify stored interface zone labels after a zone-model change
1103
+ celilo machine reclassify celilo-mgr
1104
+ celilo machine reclassify celilo-mgr --apply
1105
+
1100
1106
  # Remove machine (by hostname or IP)
1101
1107
  celilo machine remove pi-01
1102
1108
  celilo machine remove 10.0.10.10 --force
@@ -1214,6 +1220,11 @@ Subcommands:
1214
1220
 
1215
1221
  doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
1216
1222
 
1223
+ reboot [--allow-reboot] [--verify] Reboot the management server. Refuses while a module
1224
+ operation is in flight and names it. The process asking
1225
+ dies in the reboot, so --verify is what confirms the host
1226
+ came back: boot id changed, dispatcher live, CLI answering.
1227
+
1217
1228
  Runbook:
1218
1229
  https://celilo.computer/docs/system-update
1219
1230
  (offline summary in apps/celilo/docs/INDEX.md)
@@ -2269,6 +2280,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2269
2280
  return handleMachineEarmark(parsed.args, parsed.flags);
2270
2281
  }
2271
2282
 
2283
+ if (parsed.subcommand === 'reclassify') {
2284
+ return handleMachineReclassify(parsed.args, parsed.flags);
2285
+ }
2286
+
2272
2287
  return {
2273
2288
  success: false,
2274
2289
  error: `Unknown machine subcommand: ${parsed.subcommand}\n\nRun "celilo machine --help" for usage`,
@@ -2369,6 +2384,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2369
2384
  return handleSystemMigrate(parsed.args, parsed.flags);
2370
2385
  }
2371
2386
 
2387
+ if (parsed.subcommand === 'reboot') {
2388
+ return handleSystemReboot(parsed.args, parsed.flags);
2389
+ }
2390
+
2372
2391
  return {
2373
2392
  success: false,
2374
2393
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,