@celilo/cli 0.21.0 → 0.23.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 (55) hide show
  1. package/CELILO_CORE_MODULES.md +5 -4
  2. package/CELILO_SUBSYSTEMS.md +34 -2
  3. package/drizzle/0024_module_pause.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +5 -6
  6. package/src/__integration__/container-services-cli.integration.test.ts +8 -2
  7. package/src/api/remote-client.test.ts +6 -5
  8. package/src/api/serve.ts +41 -7
  9. package/src/api-clients/proxmox.ts +34 -0
  10. package/src/cli/commands/alerts-sweep.ts +2 -0
  11. package/src/cli/commands/events.test.ts +66 -0
  12. package/src/cli/commands/events.ts +106 -3
  13. package/src/cli/commands/module-deploy.ts +2 -2
  14. package/src/cli/commands/module-health.ts +1 -0
  15. package/src/cli/commands/module-import.ts +3 -3
  16. package/src/cli/commands/module-list.ts +12 -1
  17. package/src/cli/commands/module-pause.ts +317 -0
  18. package/src/cli/commands/module-remove.ts +78 -40
  19. package/src/cli/commands/module-status.ts +3 -4
  20. package/src/cli/commands/module-update.test.ts +1 -1
  21. package/src/cli/commands/proxmox-template-selection.ts +1 -1
  22. package/src/cli/commands/status.ts +25 -3
  23. package/src/cli/completion.ts +5 -0
  24. package/src/cli/fuel-gauge.ts +4 -4
  25. package/src/cli/index.ts +49 -20
  26. package/src/cli/json-output.test.ts +162 -0
  27. package/src/cli/prompts.ts +53 -74
  28. package/src/cli/service-credential.ts +3 -3
  29. package/src/cli/stdout-is-undecorated.test.ts +94 -0
  30. package/src/cli/types.ts +7 -2
  31. package/src/db/schema.ts +73 -15
  32. package/src/hooks/run-named-hook.ts +28 -0
  33. package/src/services/alerting/suppression.test.ts +5 -0
  34. package/src/services/alerting/suppression.ts +18 -1
  35. package/src/services/alerting/sweep-runner.test.ts +1 -0
  36. package/src/services/alerting/sweep-runner.ts +11 -1
  37. package/src/services/bus-interview.ts +2 -2
  38. package/src/services/bus-secret-flow.test.ts +1 -1
  39. package/src/services/dns-registrations.ts +12 -0
  40. package/src/services/fleet-checks.test.ts +46 -0
  41. package/src/services/fleet-checks.ts +63 -6
  42. package/src/services/module-deploy.ts +1 -1
  43. package/src/services/module-pause-observability.test.ts +224 -0
  44. package/src/services/module-pause-quiescence.test.ts +163 -0
  45. package/src/services/module-pause.test.ts +573 -0
  46. package/src/services/module-pause.ts +544 -0
  47. package/src/services/remove-guard.test.ts +175 -0
  48. package/src/services/remove-guard.ts +109 -0
  49. package/src/services/terminal-responder.ts +16 -16
  50. package/src/services/update/dep-graph.test.ts +33 -4
  51. package/src/services/update/dep-graph.ts +39 -17
  52. package/src/services/zone-detector.ts +2 -39
  53. package/src/test-utils/cli.ts +15 -14
  54. package/src/test-utils/integration-guard.ts +26 -0
  55. package/src/test-utils/setup-test-db.ts +13 -23
@@ -14,19 +14,20 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
14
14
  > the `@celilo/cli` npm package). Re-grep manifests if an entry looks stale.
15
15
 
16
16
  > How the graph closes: a public app (`requires: public_web`) is served by **caddy**, which
17
- > `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**greenwave** /
18
- > **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
17
+ > `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**axon** /
18
+ > **greenwave** / **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
19
19
  > (**knot-unbound-internal** / **technitium**). Identity comes from **authentik** (`idp`). That's
20
20
  > the whole edge: DNS + firewall + ingress + identity, each a swappable provider module.
21
21
 
22
22
  ## Network fabric (DNS / firewall / DHCP)
23
23
 
24
- - **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`.
24
+ - **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**.
25
+ - **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
25
26
  - **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **provides:** `firewall`.
26
27
  - **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
27
28
  - **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
28
29
  - **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
29
- - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does, `greenwave` does not).
30
+ - **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
30
31
 
31
32
  ## Public edge (ingress / identity)
32
33
 
@@ -170,8 +170,26 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
170
170
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
171
171
  - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium).
172
172
  - **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.
173
+ - **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/changes/module-pause-lifecycle/`.
174
+ - **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).
173
175
  - **Backup artifact encryption** — `apps/celilo/src/services/backup-cipher.ts` — `encryptFileToFile`/`decryptFileToFile`, file-in/file-out and streamed, used by every backup writer (`backup-create.ts`) and reader (`backup-restore.ts`, `restore-from-file.ts`). Do NOT route artifacts through `secrets/encryption.ts`: that API is string-in/string-out for short DB values, and feeding it a tar cost base64 (1.33x) then hex (2x) then `JSON.stringify` — ~9x the artifact in memory, which OOM-killed forgejo's 774 MB backup, and a hard ~805 MB ceiling from the max string length that no amount of RAM raises. On-disk format is `magic "CELILOBK" (8) | version (1) | iv (16) | ciphertext | GCM tag (16)`; the tag is a trailer because it does not exist until the last byte is encrypted. `decryptFileToFile` still reads the pre-2026-07 JSON-envelope artifacts, discriminating on the magic bytes — the envelope's own `schemaVersion` cannot serve, as it lives inside the encrypted tar.
174
176
 
177
+ ### Paused modules are conspicuous
178
+
179
+ A pause switches OFF the alerting that would otherwise report the module as
180
+ down, so the paused-ness itself is the signal. Four surfaces, none optional:
181
+ `celilo module list` / `celilo status` render `PAUSED (3d)` with the reason;
182
+ `checkPausedModules` in `apps/celilo/src/services/fleet-checks.ts` makes ANY
183
+ paused module a `system doctor` FAILURE (no threshold — a pause is a degraded
184
+ state, and a threshold is just something to tune until the detector stops
185
+ firing); `findSuppressor` in `apps/celilo/src/services/alerting/suppression.ts`
186
+ attributes the module's suppressed alerts to the pause rather than dropping them
187
+ anonymously; and `fleetWarnings()` in `apps/celilo/src/api/serve.ts` stamps a
188
+ warning naming every paused module and its age onto EVERY management-API result
189
+ (`ResultMessage.warnings`), including on commands unrelated to those modules.
190
+ That last one is the important half: it does not depend on the operator choosing
191
+ to look, which is how a forgotten pause actually gets found.
192
+
175
193
  ## Generation & templating
176
194
 
177
195
  - **Generator** — `apps/celilo/src/templates/generator.ts` — `generateTemplates` (orchestration), plus Terraform/Ansible file handling.
@@ -238,6 +256,20 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
238
256
  - **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one. **Supervision** (`services/events-daemon.ts`): the unit is named `celilo-events.service` in BOTH the user and system scope, so the two are indistinguishable in every operator-facing string — `install-daemon` (which defaults to **user** scope) therefore refuses when the other scope's unit exists, and `checkDispatcher` compares the live pid against each installed unit's `MainPID` (`unitMainPid`) rather than merely testing that a unit file exists, since a file nobody is running is not supervision (#610).
239
257
  - **Dispatcher supervision (install / restart)** — `apps/celilo/src/services/events-daemon.ts` — `installDaemon`/`planDaemonInstall`/`uninstallDaemon`/`readInstalledUnit` write the systemd unit or launchd plist without touching supervisor state, and `restartDaemon` (+ pure `orphanDispatcherPids`, `resolveRestartScope`, `supervisorCommands`) is the one verb that DOES cycle it. CLI: `celilo events install-daemon|uninstall-daemon|show-daemon|restart-daemon [--system]`. `restart-daemon` exists because the dispatcher runs the code it LOADED: celilo-mgr sat 9 days on event-bus v0.1.8 after apt installed v0.2.0, running the very bug the release fixed (celilo#604). Two things make it non-trivial and both are load-bearing: (1) it stops any live dispatcher the supervisor does not own first — an ORPHAN (PPID 1) is invisible to `systemctl restart`, and `assertSoleDispatcher` then crash-loops the unit while the old code keeps serving; (2) it verifies on the BUS that a NEW pid is live reporting `BUS_VERSION`, never off systemctl's exit code, which returns 0 into exactly that crash loop. System scope shells `sudo systemctl` (the unit is root-owned; celilo runs unprivileged), covered by the scoped `/etc/sudoers.d/celilo-events-restart` conffile that `celilo-bootstrap` ships — a unit test asserts the argv and the grant cannot drift apart.
240
258
 
259
+ ## Terminal UI (`@celilo/cli-display`)
260
+
261
+ Every CLI-facing primitive celilo renders with. Lives in `packages/cli-display/src/` so module code running in-process (capability functions, hook scripts) can reach it via `@celilo/capabilities`' re-export, and so `@celilo/core` can use it without depending on `@celilo/cli`. `@clack/prompts` was removed tree-wide in celilo#699; nothing here wraps a third-party prompt library.
262
+
263
+ **The stream contract:** stdout carries a command's RESULT and nothing else — no glyph, no box-drawing prefix, no ANSI. Everything else (prompts, progress, banners, log lines, diagnostics) goes to **stderr**. This is what lets `celilo module list | grep '^caddy '` and `celilo events tail | jq` work without a repair step; the previous renderer put chrome on stdout, which cost a full e2e run when a `│ ` prefix read as a missing module (celilo#695) and made ten JSON commands unparseable (celilo#698).
264
+
265
+ - **Prompts** — `packages/cli-display/src/prompt.ts` — `text`, `password`, `confirm`, `select`, `multiselect`, plus `CANCEL`/`isCancel` and `isInteractive`. Raw-mode keypress handling with cursor movement, submit-time validation, and per-keystroke tokenization (one stdin chunk can carry a whole paste). **Refuses a non-TTY stdin** with `PromptUnavailableError` instead of hanging on it or taking the next newline as a considered answer — both real past failures. New interactive decisions must NOT be added here; they belong on the event-bus interview, which a headless responder can answer.
266
+ - **Message chrome** — `packages/cli-display/src/messages.ts` — `intro`, `outro`, `note`, `cancel`, and `log.{success,error,warn,info,message,step}`. All to stderr; routed into the active `ProgressDisplay` when one is set, so the two renderers never fight over the cursor.
267
+ - **Colour** — `packages/cli-display/src/colors.ts` — `colorEnabled()`, `paint()`, and the `colors` token object. Gated per use on `NO_COLOR` → `FORCE_COLOR` → `stderr.isTTY`, so colour never reaches a pipe, a file, or protocol mode.
268
+ - **Progress display** — `packages/cli-display/src/progress-display.ts` — `ProgressDisplay`: a flat append-only stream with a time gutter and one status-glyph vocabulary in `render` mode, or structured `[progress:start|done|fail|sub]` markers in `protocol` mode (what the Remote API server emits and the client re-renders locally).
269
+ - **Active-display singleton** — `packages/cli-display/src/active-display.ts` — `getActiveDisplay`/`setActiveDisplay`; how a long-running command lets everything down its call chain route output through one display.
270
+ - **celilo-side wrapper** — `apps/celilo/src/cli/prompts.ts` — `celiloIntro`/`celiloOutro`, `promptText`/`promptPassword`/`promptConfirm`, `showNote`, `log`. Roughly 160 `log.*` call sites route through this one module, which is why swapping the underlying implementation was a one-file change.
271
+ - **FuelGauge** — `apps/celilo/src/cli/fuel-gauge.ts` — the Cylon-style indicator for operations over ~3s (ESC to background, ^C to cancel); delegates to the active `ProgressDisplay` when one is set.
272
+
241
273
  ## Remote API (drive the CLI over the wire)
242
274
 
243
275
  Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <host> celilo …`. Typed, streamed, per-operation authz, mid-run interviews. Design: `openspec/changes/replace-ssh-cli-api/proposal.md`.
@@ -246,7 +278,7 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
246
278
  - **Wire protocol** — `packages/core/src/protocol.ts` (`@celilo/core`) — versioned NDJSON tagged union (`command`/`progress`/`log`/`result`/`error`/`interview`/`answer`) + `translateOutputLine`.
247
279
  - **Registry serialization** — `apps/celilo/src/cli/commands/commands-json.ts` — `celilo commands --json` prints the full `COMMANDS` tree as JSON; the live source of truth the MCP fetches to generate its tool surface (so it mirrors whatever celilo version the server runs). `service list --json` similarly exposes configured providers for MCP auto-detect. `module list --json` prints the module roster (id/version/state) as stable JSON — the backbone the MCP composite troubleshooting tools correlate `audit --json` findings against.
248
280
  - **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
249
- - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via clack).
281
+ - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
250
282
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
251
283
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
252
284
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
@@ -259,6 +291,6 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
259
291
  - **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
260
292
  - **Run wrapper** — `infra/scripts/cele2e-run.sh` lives in the separate `infra/` clone, **not** in this repo. See the cele2e section of the repo-root `CLAUDE.md` for the operator workflow.
261
293
  - **Signal simulators** — three containers, three jobs. `docker/Dockerfile.signal-cli` runs the REAL unlinked daemon (`network().withSignalCli()`, reachable at `signal-cli.lab`) so `e2e/tests/signal-contract.test.ts` can re-check celilo's understanding of the JSON-RPC surface against the actual binary — the pebble pattern. `docker/Dockerfile.signal-sim` runs `simulators/signal-cli/server.ts` (`withSignalSim()`, `signal-sim.lab`), the drivable stand-in with a control surface (`/_control/inbound`, `/_control/sent`, `/_control/unlink`). `/_control/inbound` from the LINKED account's own number emits a `syncMessage.sentMessage` transcript rather than a `dataMessage`, because that is the only shape the real daemon delivers a note-to-self in — and a note-to-self is what every reply is in the default single-operator setup (#460). `docker/Dockerfile.signal-release` (`withSignalRelease()`, `signal-release.lab`) serves the signal-cli release tarball so the module's deploy-time download resolves inside the sealed network — the download is served, never skipped. The libsignal aarch64 native is compiled at `build-infra` time from the module's own recipe (`packages/e2e/scripts/stage-libsignal.ts` → `modules/signal/build/`) and staged into the apt-repo pool, so the recipe is exercised for real on every rebuild while the deploy stays fast and the network stays sealed.
262
- - **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `100.100.0.100`. The ISP edge `fw-ext` ROUTES the customer's `100.100.0.0/24` and must never re-NAT it (`-s 100.100.0.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
294
+ - **Public-boundary NAT model** — `packages/e2e/config/routing/` — exactly ONE NAT sits between the fleet and the simulated internet: the customer firewall (`fw-main` in `direct-internet`, `fw-isp` in the two-layer default), which MASQUERADEs to `203.0.113.100`. The ISP edge `fw-ext` ROUTES the customer's `203.0.113.0/24` and must never re-NAT it (`-s 203.0.113.0/24 -j RETURN` ahead of its MASQUERADE) — an ISP does not NAT a subscriber that already holds a public address. This is load-bearing, not cosmetic: Namecheap-style DDNS registers the SOURCE address when the caller omits `ip=`, which is how celilo registers public names since #464/#466, so a second NAT here publishes the simulator's own address for every public hostname and quietly breaks ACME, inbound reach, and seeded apex records. Constants: `externalWanIp()` / `externalWanSubnet()` in `src/types.ts`. The corollary: every simulator on `internet-external` must default-route via fw-ext (`100.64.0.1`) so it can reply to the customer's public address — Docker's bridge gateway has no path across networks. `config/routing/public-sim-entrypoint.sh` is the shared two-liner; the DNS hierarchy, pebble, isitup and celilo-website-sim already carried it, and npm-registry / registry / apt-repo / minio / cpanel-host only appeared to work because the double NAT put fw-ext's on-link address in the source field.
263
295
  - **Simulated address plan** — `packages/e2e/src/types.ts` — `SIM_PRIVATE_SUPERNET` (`10.226.0.0/16`), `zoneIp(zone, host)`, `ZONE_SUBNETS`, `ZONE_GATEWAYS`. Every simulated PRIVATE zone is derived from this one table; the compose generator, `zone-classifier.ts` and the harness's `system init` all read it, so renumbering the whole sim is a one-line change. The sim deliberately does NOT reuse a real fleet's zone /24s (#539): the previous plan was byte-identical to production's, so a stack leaked on celilo's own forgejo-builder — which lives in the real dmz — claimed the builder's own subnet and blackholed every containerized CI job's route to the forge for ~135s. Teardown cannot prevent that (a SIGKILL runs no handler), so the addresses moved instead; the second, better reason is that a suite which only passes on production's exact octets is asserting one site's address plan rather than celilo's behaviour. `src/address-plan.test.ts` is the recurrence gate — it fails if a zone leaves the supernet, or if any retired fleet prefix reappears anywhere in `packages/e2e`, `e2e/tests` or `modules/*/e2e`.
264
296
  - **MCP server (agent-driven e2e)** — `packages/mcp-server/src/index.ts` — stdio MCP server exposing `start_run`/`run_status`/`run_result`/`stop_run` (detached cele2e runs read off the event bus, no ANSI scraping) + `env_check` (docker VM, run-lock, shared-infra, mgmt image CLI version, netapps). Dev/ops tool, `private`, not shipped to consumers.
@@ -0,0 +1,20 @@
1
+ -- Module pause/unpause (openspec/changes/module-pause-lifecycle).
2
+ --
3
+ -- Purely additive: `state` gains a new legal value 'PAUSED' (no constraint to
4
+ -- alter — the column is free text typed only in TypeScript), plus two nullable
5
+ -- columns. Every existing module is simply not paused, so there is nothing to
6
+ -- backfill.
7
+ --
8
+ -- `paused_at` is not decoration. A pause suppresses the alerting that would
9
+ -- otherwise report the module as down, so the paused-ness itself has to be
10
+ -- loud; the DURATION is what distinguishes a maintenance window from an outage
11
+ -- someone forgot about, and `state` alone cannot answer it (design D7).
12
+ --
13
+ -- Note for rollback: an older CLI reading state='PAUSED' hits a value outside
14
+ -- its union and fails to recognise it, rather than reading the module as live.
15
+ -- Loud beats silent — but it still has no `unpause`, so unpause everything
16
+ -- before downgrading.
17
+
18
+ ALTER TABLE `modules` ADD `paused_at` integer;--> statement-breakpoint
19
+ ALTER TABLE `modules` ADD `pause_reason` text;--> statement-breakpoint
20
+ CREATE INDEX `modules_state_idx` ON `modules` (`state`);
@@ -169,6 +169,13 @@
169
169
  "when": 1783900000000,
170
170
  "tag": "0023_public_dns_evidence",
171
171
  "breakpoints": true
172
+ },
173
+ {
174
+ "idx": 24,
175
+ "version": "6",
176
+ "when": 1784000000000,
177
+ "tag": "0024_module_pause",
178
+ "breakpoints": true
172
179
  }
173
180
  ]
174
- }
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,11 +57,10 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^1.0.0",
61
- "@celilo/cli-display": "^0.1.10",
62
- "@celilo/core": "^0.5.0",
63
- "@celilo/event-bus": "^0.3.0",
64
- "@clack/prompts": "^1.1.0",
60
+ "@celilo/capabilities": "^1.1.0",
61
+ "@celilo/cli-display": "^0.2.0",
62
+ "@celilo/core": "^0.7.0",
63
+ "@celilo/event-bus": "^0.4.0",
65
64
  "ajv": "^8.18.0",
66
65
  "drizzle-orm": "^0.36.4",
67
66
  "ink": "^7.0.1",
@@ -111,7 +111,11 @@ describe('Container Services and Machine Pool CLI Integration', () => {
111
111
  // Remove service with --force flag to skip confirmation
112
112
  const output = runCli(ctx.cli, 'service remove service-to-remove --force');
113
113
 
114
- expect(output).toContain('removed');
114
+ // The command's RESULT, which is what stdout carries. This used to assert
115
+ // on the lowercase "removed" of the `celiloOutro` banner, which clack put
116
+ // on stdout while putting the result on stderr; celilo#699 swapped those
117
+ // so stdout is the data channel and the banner is chrome on stderr.
118
+ expect(output).toContain('Removed service: service-to-remove');
115
119
 
116
120
  // Verify deletion
117
121
  const services = await db.select().from(containerServices).all();
@@ -236,7 +240,9 @@ describe('Container Services and Machine Pool CLI Integration', () => {
236
240
  // Remove machine with --force flag to skip confirmation
237
241
  const output = runCli(ctx.cli, 'machine remove remove-me --force');
238
242
 
239
- expect(output).toContain('removed');
243
+ // See the service-remove case above: stdout now carries the result, not
244
+ // the outro banner (celilo#699).
245
+ expect(output).toContain('Removed machine: remove-me');
240
246
 
241
247
  // Verify deletion
242
248
  const machinesList = await db.select().from(machines).all();
@@ -93,11 +93,12 @@ test('renders a forwarded interview and sends the answer back', async () => {
93
93
  /**
94
94
  * Regression for the fabricated "operator declined".
95
95
  *
96
- * The default renderer prompts with clack, which reads keypresses off stdin
97
- * whether or not stdin is a terminal. Driven over the MCP (stdin = the JSON-RPC
98
- * stream) the next newline submitted the prompt at its `initialValue` — the
99
- * question's `defaultValue` — so a breaking update nobody saw came back as a
100
- * considered "no". With no terminal the client must say it cannot answer.
96
+ * The default renderer prompts on the terminal. The renderer it replaced read
97
+ * keypresses off stdin whether or not stdin was a terminal, so driven over the
98
+ * MCP (stdin = the JSON-RPC stream) the next newline submitted the prompt at
99
+ * its `initialValue` — the question's `defaultValue` — and a breaking update
100
+ * nobody saw came back as a considered "no". With no terminal the client must
101
+ * say it cannot answer.
101
102
  *
102
103
  * And it must say so as `unanswerable`, NOT as an `answer` of any shape: an
103
104
  * `answer` is what consumes the query and destroys a question nobody decided
package/src/api/serve.ts CHANGED
@@ -27,8 +27,10 @@ import {
27
27
  } from '@celilo/core';
28
28
  import { parseArguments } from '../cli/parser';
29
29
  import { getEventBusPath } from '../config/paths';
30
+ import { getDb } from '../db/client';
30
31
  import { isAuthorized } from '../services/api-access';
31
32
  import { EVENT_TYPES } from '../services/bus-interview';
33
+ import { describePausedModule, listPausedModules } from '../services/module-pause';
32
34
  import { type WireInterview, startRemoteResponder } from '../services/remote-responder';
33
35
  import {
34
36
  SessionWriter,
@@ -50,6 +52,38 @@ function send(msg: ServerMessage): void {
50
52
  process.stdout.write(`${JSON.stringify(msg)}\n`);
51
53
  }
52
54
 
55
+ /**
56
+ * The fleet-level warnings stamped onto every terminal result (design D7).
57
+ *
58
+ * Deliberately unconditional and deliberately on UNRELATED commands: a pause
59
+ * suppresses its module's alerting, so the paused-ness itself is the only
60
+ * remaining signal, and a forgotten pause is found incidentally rather than by
61
+ * someone choosing to look. One indexed query (`modules_state_idx`), so this
62
+ * stays cheap enough to run on every call.
63
+ *
64
+ * Never throws: a broken or missing DB must not turn a working command into a
65
+ * failure over a warning.
66
+ */
67
+ function fleetWarnings(): string[] {
68
+ try {
69
+ const paused = listPausedModules(getDb());
70
+ if (paused.length === 0) return [];
71
+ return [
72
+ `${paused.length} module(s) PAUSED: ${paused.map((m) => describePausedModule(m)).join(', ')}. A paused module is quiesced and its alerts are suppressed. Unpause with "celilo module unpause <id>".`,
73
+ ];
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /** Terminal result, with the fleet warnings attached. */
80
+ function resultMessage(success: boolean, exitCode: number): ServerMessage {
81
+ const warnings = fleetWarnings();
82
+ return warnings.length > 0
83
+ ? { type: 'result', success, exitCode, warnings }
84
+ : { type: 'result', success, exitCode };
85
+ }
86
+
53
87
  function audit(principal: string, op: string, decision: string, exitCode?: number): void {
54
88
  const suffix = exitCode === undefined ? '' : ` exit=${exitCode}`;
55
89
  process.stderr.write(
@@ -107,7 +141,7 @@ async function runCommand(argv: string[], forward: (msg: ServerMessage) => void)
107
141
 
108
142
  await Promise.all([pumpLines(child.stdout, forward), pumpLines(child.stderr, forward)]);
109
143
  const exitCode = await child.exited;
110
- forward({ type: 'result', success: exitCode === 0, exitCode });
144
+ forward(resultMessage(exitCode === 0, exitCode));
111
145
  return exitCode;
112
146
  }
113
147
 
@@ -119,7 +153,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
119
153
  const record = readSession(sessionId);
120
154
  if (!record) {
121
155
  send({ type: 'error', error: `no such session: ${sessionId}` });
122
- send({ type: 'result', success: false, exitCode: 1 });
156
+ send(resultMessage(false, 1));
123
157
  return;
124
158
  }
125
159
  if (record.principal !== principal) {
@@ -127,7 +161,7 @@ async function handleAttach(principal: string, sessionId: string): Promise<void>
127
161
  type: 'error',
128
162
  error: `permission denied: session ${sessionId} belongs to another principal`,
129
163
  });
130
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
164
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
131
165
  audit(principal, `attach:${sessionId}`, 'deny');
132
166
  return;
133
167
  }
@@ -167,7 +201,7 @@ function handleCancel(principal: string, sessionId: string): void {
167
201
  const record = readSession(sessionId);
168
202
  if (!record) {
169
203
  send({ type: 'error', error: `no such session: ${sessionId}` });
170
- send({ type: 'result', success: false, exitCode: 1 });
204
+ send(resultMessage(false, 1));
171
205
  return;
172
206
  }
173
207
  if (record.principal !== principal) {
@@ -175,7 +209,7 @@ function handleCancel(principal: string, sessionId: string): void {
175
209
  type: 'error',
176
210
  error: `permission denied: session ${sessionId} belongs to another principal`,
177
211
  });
178
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
212
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
179
213
  audit(principal, `cancel:${sessionId}`, 'deny');
180
214
  return;
181
215
  }
@@ -185,7 +219,7 @@ function handleCancel(principal: string, sessionId: string): void {
185
219
  emittedBy: `api:${principal}`,
186
220
  });
187
221
  audit(principal, `cancel:${sessionId}`, 'allow');
188
- send({ type: 'result', success: true, exitCode: 0 });
222
+ send(resultMessage(true, 0));
189
223
  }
190
224
 
191
225
  export async function apiServeMode(principal: string): Promise<void> {
@@ -251,7 +285,7 @@ export async function apiServeMode(principal: string): Promise<void> {
251
285
 
252
286
  if (!(await isAuthorized(principal, command, subcommand))) {
253
287
  send({ type: 'error', error: `permission denied: "${principal}" is not granted "${label}"` });
254
- send({ type: 'result', success: false, exitCode: EXIT_PERMISSION_DENIED });
288
+ send(resultMessage(false, EXIT_PERMISSION_DENIED));
255
289
  audit(principal, label, 'deny');
256
290
  return;
257
291
  }
@@ -636,6 +636,40 @@ export class ProxmoxClient {
636
636
  if (!result.success) return result;
637
637
  return { success: true, data: findNodeForVmid(result.data, vmid) };
638
638
  }
639
+
640
+ /**
641
+ * Power a guest down or up. Used by `module pause --stop-infra` (design D2),
642
+ * where stopping the box is opt-in rather than what a pause means.
643
+ *
644
+ * `shutdown` rather than `stop` on the way down: it asks the guest to go
645
+ * quietly instead of pulling its power, and a pause is a planned operation,
646
+ * not a fault. Proxmox returns a task id; callers that need to know it
647
+ * finished poll with `pollTaskUntilDone`.
648
+ */
649
+ async setGuestPower(
650
+ vmid: number,
651
+ kind: 'lxc' | 'qemu',
652
+ power: 'shutdown' | 'start',
653
+ ): Promise<ProxmoxResult<string>> {
654
+ const node = await this.nodeForVmid(vmid);
655
+ if (!node.success) return node;
656
+ if (!node.data) {
657
+ return { success: false, message: `No Proxmox node hosts vmid ${vmid}` };
658
+ }
659
+ return makeProxmoxPost<string>(
660
+ this.credentials,
661
+ `/nodes/${node.data}/${kind}/${vmid}/status/${power}`,
662
+ {},
663
+ );
664
+ }
665
+
666
+ /** Current run state of a guest (`running`, `stopped`, …). */
667
+ async guestStatus(vmid: number): Promise<ProxmoxResult<string | null>> {
668
+ const result = await this.clusterResources();
669
+ if (!result.success) return result;
670
+ const guest = result.data.find((r) => r.vmid === vmid);
671
+ return { success: true, data: guest?.status ?? null };
672
+ }
639
673
  }
640
674
 
641
675
  /**
@@ -21,6 +21,7 @@ import type { SuppressionTopology } from '../../services/alerting/suppression';
21
21
  import { runSweep } from '../../services/alerting/sweep-runner';
22
22
  import { getModuleSystems } from '../../services/deployed-systems';
23
23
  import { runModuleHealthCheck } from '../../services/health-runner';
24
+ import { listPausedModules } from '../../services/module-pause';
24
25
  import type { CommandResult } from '../types';
25
26
 
26
27
  /** Grace window before a newly-fired alert may notify. */
@@ -65,6 +66,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
65
66
  },
66
67
  loadTopology: () => loadTopology(db),
67
68
  loadDeployWindowModules: () => modulesInDeployWindow(db),
69
+ loadPausedModules: () => new Set(listPausedModules(db).map((m) => m.id)),
68
70
  isSuppressible: (alert) => suppressibleByMonitor.get(alert.monitorId) ?? true,
69
71
  notifyDepsFor: (alert) => buildNotifyDeps(db, alert, new Date()),
70
72
  now: () => new Date(),
@@ -4,10 +4,12 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { defineEvents, openBus } from '@celilo/event-bus';
6
6
  import {
7
+ type FailedDeliveryReport,
7
8
  handleEventsAck,
8
9
  handleEventsDrain,
9
10
  handleEventsEmit,
10
11
  handleEventsFail,
12
+ handleEventsListFailed,
11
13
  handleEventsListPending,
12
14
  handleEventsListSubscribers,
13
15
  handleEventsRepair,
@@ -262,3 +264,67 @@ describe('celilo events command handlers', () => {
262
264
  expect(replies[0].payload).toEqual({ value: 'a.net' });
263
265
  });
264
266
  });
267
+
268
+ describe('celilo events list-failed', () => {
269
+ let dir: string;
270
+ let dbPath: string;
271
+
272
+ beforeEach(() => {
273
+ dir = mkdtempSync(join(tmpdir(), 'events-failed-test-'));
274
+ dbPath = join(dir, 'events.db');
275
+ process.env.EVENT_BUS_DB = dbPath;
276
+ const bus = openBus({ dbPath, events: defineEvents({}) });
277
+ const sub = bus.subscribe({ name: 'namecheap.ddns', pattern: 'ddns.*', handler: 'echo' });
278
+ for (let i = 0; i < 60; i++) {
279
+ const event = bus.emitRaw('ddns.refresh', { n: i });
280
+ bus.markFailed({ eventId: event.id, subscriberId: sub.id }, new Error('boom'), {
281
+ abandoned: true,
282
+ });
283
+ }
284
+ bus.close();
285
+ });
286
+ afterEach(() => {
287
+ process.env.EVENT_BUS_DB = undefined;
288
+ try {
289
+ rmSync(dir, { recursive: true, force: true });
290
+ } catch {
291
+ /* ignore */
292
+ }
293
+ });
294
+
295
+ // The whole point of the command: `total` is a COUNT, `shown` is the limit.
296
+ it('separates the true total from the capped sample', async () => {
297
+ const result = await handleEventsListFailed([], { limit: '5' });
298
+ expect(result.success).toBe(true);
299
+ if (!result.success) throw new Error('expected success');
300
+ const report = result.data as FailedDeliveryReport;
301
+ expect(report.total).toBe(60);
302
+ expect(report.shown).toBe(5);
303
+ expect(report.deliveries).toHaveLength(5);
304
+ expect(report.bySubscriber).toEqual([
305
+ { subscriber: 'namecheap.ddns', count: 60, latestFinishedAt: expect.any(Number) },
306
+ ]);
307
+ });
308
+
309
+ it('names the subscriber and event type on every row', async () => {
310
+ const result = await handleEventsListFailed([], { limit: '1' });
311
+ if (!result.success) throw new Error('expected success');
312
+ const row = (result.data as FailedDeliveryReport).deliveries[0];
313
+ expect(row.subscriber).toBe('namecheap.ddns');
314
+ expect(row.eventType).toBe('ddns.refresh');
315
+ expect(row.status).toBe('abandoned');
316
+ expect(row.error).toBe('boom');
317
+ expect(row.finishedAt).toBeGreaterThan(0);
318
+ });
319
+
320
+ it('--subscriber scopes both the rows and the total', async () => {
321
+ const mine = await handleEventsListFailed([], { subscriber: 'namecheap.ddns', limit: '3' });
322
+ if (!mine.success) throw new Error('expected success');
323
+ expect((mine.data as FailedDeliveryReport).total).toBe(60);
324
+
325
+ const other = await handleEventsListFailed([], { subscriber: 'nobody' });
326
+ if (!other.success) throw new Error('expected success');
327
+ expect((other.data as FailedDeliveryReport).total).toBe(0);
328
+ expect((other.data as FailedDeliveryReport).deliveries).toHaveLength(0);
329
+ });
330
+ });