@celilo/cli 0.14.4 → 0.16.1
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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +19 -2
- package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/cli/commands/alerts-list.ts +10 -0
- package/src/cli/commands/alerts-poll.ts +12 -6
- package/src/cli/commands/alerts-sweep.ts +22 -81
- package/src/cli/commands/backup-sweep.ts +65 -0
- package/src/cli/commands/module-config.test.ts +77 -1
- package/src/cli/commands/module-config.ts +45 -3
- package/src/cli/commands/module-journal.test.ts +47 -0
- package/src/cli/commands/module-journal.ts +98 -0
- package/src/cli/commands/module-operations.test.ts +93 -0
- package/src/cli/commands/module-operations.ts +134 -0
- package/src/cli/commands/module-upgrade.test.ts +32 -20
- package/src/cli/commands/module-upgrade.ts +37 -32
- package/src/cli/commands/monitor.ts +26 -6
- package/src/cli/commands/system-audit.ts +3 -30
- package/src/cli/completion.ts +20 -1
- package/src/cli/generate-zsh-completion.ts +4 -0
- package/src/cli/index.ts +14 -0
- package/src/db/schema.ts +5 -3
- package/src/manifest/schema.ts +4 -1
- package/src/module/packaging/build.ts +4 -0
- package/src/services/alerting/builtin-source.ts +17 -2
- package/src/services/alerting/delivery-loop.test.ts +5 -1
- package/src/services/alerting/format.test.ts +0 -1
- package/src/services/alerting/inbound-poller.test.ts +235 -8
- package/src/services/alerting/inbound-poller.ts +95 -34
- package/src/services/alerting/inbound.test.ts +213 -2
- package/src/services/alerting/inbound.ts +161 -32
- package/src/services/alerting/interview-responder.test.ts +0 -32
- package/src/services/alerting/interview-responder.ts +6 -17
- package/src/services/alerting/notify-deps.ts +113 -0
- package/src/services/alerting/run-monitor.ts +0 -1
- package/src/services/alerting/store.test.ts +1 -1
- package/src/services/alerting/store.ts +0 -2
- package/src/services/alerting/sweep-runner.test.ts +11 -2
- package/src/services/alerting/sweep-runner.ts +14 -7
- package/src/services/alerting/tokens.ts +39 -1
- package/src/services/audit/backup-source.ts +54 -0
- package/src/services/audit/backups.test.ts +7 -2
- package/src/services/audit/backups.ts +10 -18
- package/src/services/backup-cipher.test.ts +188 -0
- package/src/services/backup-cipher.ts +178 -0
- package/src/services/backup-create.ts +20 -30
- package/src/services/backup-envelope-roundtrip.test.ts +6 -26
- package/src/services/backup-restore.ts +10 -16
- package/src/services/backup-schedule.ts +35 -0
- package/src/services/backup-sweep.test.ts +148 -0
- package/src/services/backup-sweep.ts +124 -0
- package/src/services/deploy-posture.ts +15 -2
- package/src/services/module-journal.test.ts +302 -0
- package/src/services/module-journal.ts +160 -0
- package/src/services/module-operations.test.ts +67 -6
- package/src/services/module-operations.ts +69 -19
- package/src/services/module-subscriptions.test.ts +33 -2
- package/src/services/module-subscriptions.ts +10 -1
- package/src/services/module-validator/typescript-build.test.ts +20 -1
- package/src/services/module-validator/typescript-build.ts +9 -5
- package/src/services/restore-from-file.ts +6 -21
- package/src/templates/generator.test.ts +88 -0
- package/src/templates/generator.ts +119 -16
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -39,7 +39,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
39
39
|
- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
|
|
40
40
|
- **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
|
|
41
41
|
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
|
|
42
|
-
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public). Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
|
42
|
+
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
|
43
43
|
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
|
|
44
44
|
|
|
45
45
|
## Git forge & CI pipeline
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -30,6 +30,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
|
|
|
30
30
|
- **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
|
|
31
31
|
- **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
|
|
32
32
|
- **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
|
|
33
|
+
- **Daemon-journal diagnostic ("what does the module's daemon know that celilo doesn't?")** — `apps/celilo/src/services/module-journal.ts` + `apps/celilo/src/cli/commands/module-journal.ts` (`celilo module journal <id> [--unit|--lines|--since|--grep|--json]`, MCP `celilo_module_journal` on the RO principal) — resolves hosts via `getModuleSystems` and reads each one's systemd journal through the `tailLog` remote primitive. General to every module, not one. **READ-ONLY by construction**: `planJournalRead` is pure and the only remote command the op can emit is a `journalctl` read, so it cannot reconfigure a module, send anything, or consume inbound messages the collection path is waiting for (`module-journal.test.ts` asserts on the exact command reaching the SSH seam). Sibling to `celilo module logs`, which reads the LOCAL Ansible deploy log, not the remote daemon. Unit defaults to the glob `<module-id>*`. An unreachable host reports UNREADABLE, never as an empty journal. Rationale: openspec/changes/fix-signal-inbound-delivery Decision 5.
|
|
33
34
|
|
|
34
35
|
## Capability system (cross-module data & functions)
|
|
35
36
|
|
|
@@ -143,6 +144,8 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
143
144
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
144
145
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
145
146
|
- **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).
|
|
147
|
+
- **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] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`).
|
|
148
|
+
- **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.
|
|
146
149
|
|
|
147
150
|
## Generation & templating
|
|
148
151
|
|
|
@@ -171,6 +174,7 @@ is currently wrong, and routes carry the message to a person's phone. Design:
|
|
|
171
174
|
- **Alert identity** — `apps/celilo/src/services/alerting/keys.ts` — the key grammar (`module:<id>[/check:<name>]`, `builtin:<check>[/<kind>:<target>]`) that makes "the same problem" the same alert across runs. `moduleAlertKey`, `moduleCheckAlertKey`, `builtinAlertKey`, `parseAlertKey`.
|
|
172
175
|
- **Reconciliation** — `apps/celilo/src/services/alerting/reconcile.ts` (`reconcile`) — a successful run's failing-key set is authoritative and resolution is by SET DIFFERENCE (absent ⇒ resolved). A run whose outcome is `error` resolves NOTHING and fires a module-level alert instead: the false-all-clear guard.
|
|
173
176
|
- **Monitor execution** — `apps/celilo/src/services/alerting/run-monitor.ts` (`runOneMonitor`) + `sweep.ts` (`selectDueMonitors`) + `builtin-monitors.ts` / `health-coverage.ts` (the built-in checks and the "module with no health check" coverage check).
|
|
177
|
+
- **Scheduled audit categories (`builtin_check` monitors)** — `apps/celilo/src/services/alerting/builtin-source.ts` — `SCHEDULABLE_BUILTIN_CHECKS` is the list of `celilo system audit` categories cheap enough to run every sweep: `machines_reachable` and `backups`. Everything else in the audit needs the whole world injected (proxmox, terraform, registry) and is not schedulable. Enable one with `celilo monitor add backups --interval 1h`. The `backups` roster comes from `apps/celilo/src/services/audit/backup-source.ts` (`loadBackupAuditInfo`), shared with `celilo system audit` so both judge the same fleet.
|
|
174
178
|
- **The sweep** — `apps/celilo/src/services/alerting/sweep-runner.ts` (`runSweep`) — the ordered pass that makes alerting run by itself: run due monitors → promote past-grace alerts → re-evaluate suppression → flush quiet-hours deferrals → notify. Driven by `celilo alerts sweep` on `timer.tick.5m`. Never throws for one bad monitor.
|
|
175
179
|
- **Suppression (topology-derived, never configured)** — `apps/celilo/src/services/alerting/suppression.ts` — `ancestorKeysFor`/`findSuppressor`/`machineAlertKey`. A firing machine explains its modules' failures; a firing capability provider explains its zone's consumers. Derived from `module_systems`, so it cannot drift from reality. Deploy windows: `deploy-hooks.ts` (`openDeployWindow`/`closeDeployWindows` — closed by module, so a crashed deploy self-heals).
|
|
176
180
|
- **Escalation & quiet hours** — `escalation.ts` (`decideEscalation`, every reason to stay silent enumerated) + `quiet-hours.ts` (`isWithinQuietHours`, Intl-based and DST-safe). Quiet hours defer the MESSAGE while the escalation clock keeps running.
|
|
@@ -179,9 +183,21 @@ is currently wrong, and routes carry the message to a person's phone. Design:
|
|
|
179
183
|
- **Ack / silence / resolve** — `ack.ts` — three deliberately distinct operations. An ack is broadcast to every other paged route.
|
|
180
184
|
- **Interviews over a transport** — `interview-responder.ts` (delivery policy; `secret.*` families are REFUSED) + `notification-responder.ts` (queries the bus's events table for unanswered questions: a watch only sees events emitted after it registers, and `alerts poll` is one-shot. Leaves a question alone for 90s so an attached responder — terminal, or the `api-serve` wire bridge — answers first). Alerts and interviews share the send path, the token table, and the inbound path; only `targetId` differs.
|
|
181
185
|
- **People, routes, policies** — `people.ts` — `createPerson`/`createRoute`/`createPolicy`/`addPolicyStep`.
|
|
182
|
-
- **
|
|
186
|
+
- **Policy resolution** — `apps/celilo/src/services/alerting/notify-deps.ts` — `policyForAlert` (the single answer to "who would this page") + `buildNotifyDeps` (assembles steps, routes, quiet hours, transport, reply token). The policy is read from the MONITOR on every sweep, never snapshotted onto the alert, so `escalation-policy assign` takes effect on alerts that are already firing.
|
|
187
|
+
- **Persistence** — `apps/celilo/src/services/alerting/store.ts`; tables `people`, `routes`, `escalation_policies`, `escalation_steps`, `monitors`, `monitor_runs`, `suppression_windows`, `alerts`, `notification_deliveries` (`apps/celilo/src/db/schema.ts`, migrations `drizzle/0017_alerting.sql`, `drizzle/0018_drop_alert_policy_snapshot.sql`).
|
|
183
188
|
- **CLI** — `apps/celilo/src/cli/commands/` — `alerts-list.ts`, `alerts-act.ts` (ack/silence/resolve), `alerts-sweep.ts`, `alerts-poll.ts`, `monitor.ts`, `notify-config.ts` (`person`/`route`/`escalation-policy`).
|
|
184
189
|
|
|
190
|
+
## Backups
|
|
191
|
+
|
|
192
|
+
Creation, scheduling and freshness. A module declares an `on_backup` hook and a
|
|
193
|
+
`backup.schedule`; celilo runs it on that cadence and alerts when it stops.
|
|
194
|
+
|
|
195
|
+
- **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules`, `isBackupDue`. Storage destinations: `backup-storage.ts`. Retention: `backup-retention.ts` (`pruneBackupsForModule`). Restore: `backup-restore.ts`.
|
|
196
|
+
- **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest)`. An absent `backup.schedule` means `daily`, NOT `manual`; opting out takes an explicit `manual`. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
|
|
197
|
+
- **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: for each eligible module, is its declared cadence due → back it up → apply declared retention) + `ensureBackupSweepSubscriber`. Driven by `celilo backup sweep` on `timer.tick.1h` — the coarsest tick that can still serve an `hourly` cadence. Armed automatically: `registerModuleSubscriptions` registers the subscriber for any module declaring an `on_backup` hook, so it appears on install or `module update`. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
|
|
198
|
+
- **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
|
|
199
|
+
- **CLI** — `apps/celilo/src/cli/commands/` — `backup-sweep.ts`, `backup-create.ts` (also `celilo module backup`), `backup-list.ts`, `backup-restore.ts`, `backup-prune.ts`, `backup-delete.ts`, `backup-import.ts`, `backup-pull.ts`, `backup-name.ts`.
|
|
200
|
+
|
|
185
201
|
## Persistence
|
|
186
202
|
|
|
187
203
|
- **DB schema** — `apps/celilo/src/db/schema.ts`. Client: `apps/celilo/src/db/client.ts`. Migration runner: `apps/celilo/src/db/migrate.ts`. Migrations: `apps/celilo/drizzle/`.
|
|
@@ -209,5 +225,6 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
209
225
|
|
|
210
226
|
- **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
|
|
211
227
|
- **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.
|
|
212
|
-
- **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`). `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.
|
|
228
|
+
- **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.
|
|
229
|
+
- **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.
|
|
213
230
|
- **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,46 @@
|
|
|
1
|
+
-- Drop `alerts.escalation_policy_id`.
|
|
2
|
+
--
|
|
3
|
+
-- The escalation policy was copied onto each alert when the alert was created,
|
|
4
|
+
-- and the sweep resolved it from that copy. An operator assigning a policy to a
|
|
5
|
+
-- monitor therefore changed nothing for any alert that was ALREADY firing —
|
|
6
|
+
-- which is the only situation in which anyone runs `escalation-policy assign`.
|
|
7
|
+
-- The policy now lives only on the monitor and is resolved on every sweep.
|
|
8
|
+
--
|
|
9
|
+
-- Table rebuild rather than DROP COLUMN: SQLite refuses to drop a column named
|
|
10
|
+
-- in a FOREIGN KEY clause.
|
|
11
|
+
|
|
12
|
+
CREATE TABLE `__new_alerts` (
|
|
13
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
14
|
+
`key` text NOT NULL,
|
|
15
|
+
`active_key` text,
|
|
16
|
+
`monitor_id` text NOT NULL,
|
|
17
|
+
`state` text DEFAULT 'pending' NOT NULL,
|
|
18
|
+
`severity` text NOT NULL,
|
|
19
|
+
`first_fired_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
20
|
+
`last_seen_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
21
|
+
`grace_until` integer NOT NULL,
|
|
22
|
+
`suppressed_by_alert_id` text,
|
|
23
|
+
`suppressed_by_window_id` text,
|
|
24
|
+
`unsuppressed_at` integer,
|
|
25
|
+
`awaiting_confirmation` integer DEFAULT false NOT NULL,
|
|
26
|
+
`acked_by` text,
|
|
27
|
+
`acked_at` integer,
|
|
28
|
+
`silenced_until` integer,
|
|
29
|
+
`escalation_step` integer DEFAULT 0 NOT NULL,
|
|
30
|
+
`next_escalation_at` integer,
|
|
31
|
+
`deferred_until` integer,
|
|
32
|
+
`deferred_route_id` text,
|
|
33
|
+
`message` text NOT NULL,
|
|
34
|
+
`details` text,
|
|
35
|
+
`resolved_at` integer,
|
|
36
|
+
FOREIGN KEY (`monitor_id`) REFERENCES `monitors`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
37
|
+
FOREIGN KEY (`suppressed_by_window_id`) REFERENCES `suppression_windows`(`id`) ON UPDATE no action ON DELETE set null,
|
|
38
|
+
FOREIGN KEY (`acked_by`) REFERENCES `people`(`id`) ON UPDATE no action ON DELETE set null,
|
|
39
|
+
FOREIGN KEY (`deferred_route_id`) REFERENCES `routes`(`id`) ON UPDATE no action ON DELETE set null
|
|
40
|
+
);
|
|
41
|
+
--> statement-breakpoint
|
|
42
|
+
INSERT INTO `__new_alerts` SELECT `id`, `key`, `active_key`, `monitor_id`, `state`, `severity`, `first_fired_at`, `last_seen_at`, `grace_until`, `suppressed_by_alert_id`, `suppressed_by_window_id`, `unsuppressed_at`, `awaiting_confirmation`, `acked_by`, `acked_at`, `silenced_until`, `escalation_step`, `next_escalation_at`, `deferred_until`, `deferred_route_id`, `message`, `details`, `resolved_at` FROM `alerts`;--> statement-breakpoint
|
|
43
|
+
DROP TABLE `alerts`;--> statement-breakpoint
|
|
44
|
+
ALTER TABLE `__new_alerts` RENAME TO `alerts`;--> statement-breakpoint
|
|
45
|
+
CREATE UNIQUE INDEX `alerts_live_key_idx` ON `alerts` (`active_key`);--> statement-breakpoint
|
|
46
|
+
CREATE INDEX `alerts_key_idx` ON `alerts` (`key`);
|
|
@@ -127,6 +127,13 @@
|
|
|
127
127
|
"when": 1783300000000,
|
|
128
128
|
"tag": "0017_alerting",
|
|
129
129
|
"breakpoints": true
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"idx": 18,
|
|
133
|
+
"version": "6",
|
|
134
|
+
"when": 1783400000000,
|
|
135
|
+
"tag": "0018_drop_alert_policy_snapshot",
|
|
136
|
+
"breakpoints": true
|
|
130
137
|
}
|
|
131
138
|
]
|
|
132
|
-
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
60
|
"@celilo/capabilities": "^0.9.1",
|
|
61
61
|
"@celilo/cli-display": "^0.1.9",
|
|
62
|
-
"@celilo/core": "^0.
|
|
63
|
-
"@celilo/event-bus": "^0.1.
|
|
62
|
+
"@celilo/core": "^0.3.0",
|
|
63
|
+
"@celilo/event-bus": "^0.1.9",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { getDb } from '../../db/client';
|
|
10
10
|
import { renderAlertTable, toAlertRow } from '../../services/alerting/format';
|
|
11
|
+
import { listMonitors } from '../../services/alerting/monitors';
|
|
12
|
+
import { policyForAlert } from '../../services/alerting/notify-deps';
|
|
11
13
|
import { loadAllLiveAlerts } from '../../services/alerting/store';
|
|
12
14
|
import type { CommandResult } from '../types';
|
|
13
15
|
|
|
@@ -20,6 +22,7 @@ export async function handleAlertsList(
|
|
|
20
22
|
const live = loadAllLiveAlerts(db);
|
|
21
23
|
|
|
22
24
|
if (flags.json) {
|
|
25
|
+
const monitorsById = new Map(listMonitors(db).map((m) => [m.id, m.target]));
|
|
23
26
|
return {
|
|
24
27
|
success: true,
|
|
25
28
|
message: JSON.stringify(
|
|
@@ -27,6 +30,13 @@ export async function handleAlertsList(
|
|
|
27
30
|
key: alert.key,
|
|
28
31
|
state: alert.state,
|
|
29
32
|
severity: alert.severity,
|
|
33
|
+
// Who owns this alert and who it would page. Without these an
|
|
34
|
+
// operator told "1 live alert has no escalation policy" had no way to
|
|
35
|
+
// find out which one, from any command celilo offered (#481).
|
|
36
|
+
monitor: monitorsById.get(alert.monitorId) ?? null,
|
|
37
|
+
escalationPolicy: policyForAlert(db, alert)?.name ?? null,
|
|
38
|
+
escalationStep: alert.escalationStep,
|
|
39
|
+
nextEscalationAt: alert.nextEscalationAt,
|
|
30
40
|
firstFiredAt: alert.firstFiredAt,
|
|
31
41
|
lastSeenAt: alert.lastSeenAt,
|
|
32
42
|
suppressed: alert.state === 'suppressed',
|
|
@@ -104,16 +104,12 @@ export async function handleAlertsPoll(
|
|
|
104
104
|
|
|
105
105
|
responder.stop();
|
|
106
106
|
|
|
107
|
-
// Rejections are worth surfacing rather than counting silently: a run of
|
|
108
|
-
// them means someone is replying and not being heard, which looks identical
|
|
109
|
-
// to the alerting system being broken.
|
|
110
107
|
const parts = [
|
|
111
108
|
`${report.transportsPolled} transport(s)`,
|
|
112
109
|
`${report.messagesRead} message(s)`,
|
|
113
110
|
`${report.acked} acked`,
|
|
114
111
|
];
|
|
115
|
-
if (report.
|
|
116
|
-
if (report.ignored > 0) parts.push(`${report.ignored} from unknown senders`);
|
|
112
|
+
if (report.unheard.length > 0) parts.push(`${report.unheard.length} NOT HEARD`);
|
|
117
113
|
if (report.broadcast > 0) parts.push(`${report.broadcast} told someone has it`);
|
|
118
114
|
if (report.answered > 0) parts.push(`${report.answered} deploy question(s) answered`);
|
|
119
115
|
if (ask.asked > 0) parts.push(`${ask.asked} question(s) asked`);
|
|
@@ -121,9 +117,19 @@ export async function handleAlertsPoll(
|
|
|
121
117
|
// indistinguishable from a deploy that is merely slow.
|
|
122
118
|
if (ask.failed > 0) parts.push(`${ask.failed} question(s) COULD NOT BE DELIVERED`);
|
|
123
119
|
|
|
120
|
+
// The summary line counts; these lines say WHY. Without them "0 message(s)"
|
|
121
|
+
// means both "nobody replied" and "the reply was read and thrown away", and
|
|
122
|
+
// an operator has no way to tell which — the ambiguity that made the Signal
|
|
123
|
+
// ack path take a week to diagnose.
|
|
124
|
+
const detail = [
|
|
125
|
+
...report.failures.map((f) => `${f.transportModuleId} COULD NOT BE READ: ${f.error}`),
|
|
126
|
+
...report.unheard.map((u) => `not heard from ${u.senderAddress}: ${u.reason}`),
|
|
127
|
+
...askErrors,
|
|
128
|
+
];
|
|
129
|
+
|
|
124
130
|
const message = `inbound poll: ${parts.join(', ')}`;
|
|
125
131
|
return {
|
|
126
132
|
success: true,
|
|
127
|
-
message:
|
|
133
|
+
message: detail.length > 0 ? `${message}\n ${detail.join('\n ')}` : message,
|
|
128
134
|
};
|
|
129
135
|
}
|
|
@@ -10,19 +10,15 @@
|
|
|
10
10
|
* report counts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { eq } from 'drizzle-orm';
|
|
14
13
|
import { getDb } from '../../db/client';
|
|
15
|
-
import {
|
|
14
|
+
import { modules } from '../../db/schema';
|
|
16
15
|
import { runBuiltinCheckForMonitor } from '../../services/alerting/builtin-source';
|
|
17
16
|
import { loadModuleCoverage } from '../../services/alerting/coverage-source';
|
|
18
17
|
import { modulesInDeployWindow } from '../../services/alerting/deploy-hooks';
|
|
19
18
|
import { listMonitors } from '../../services/alerting/monitors';
|
|
20
|
-
import
|
|
21
|
-
import { listPeople, listPolicySteps, listRoutes } from '../../services/alerting/people';
|
|
19
|
+
import { buildNotifyDeps } from '../../services/alerting/notify-deps';
|
|
22
20
|
import type { SuppressionTopology } from '../../services/alerting/suppression';
|
|
23
21
|
import { runSweep } from '../../services/alerting/sweep-runner';
|
|
24
|
-
import { mintDelivery } from '../../services/alerting/tokens';
|
|
25
|
-
import { loadNotificationTransport } from '../../services/alerting/transport-loader';
|
|
26
22
|
import { getModuleSystems } from '../../services/deployed-systems';
|
|
27
23
|
import { runModuleHealthCheck } from '../../services/health-runner';
|
|
28
24
|
import type { CommandResult } from '../types';
|
|
@@ -30,13 +26,6 @@ import type { CommandResult } from '../types';
|
|
|
30
26
|
/** Grace window before a newly-fired alert may notify. */
|
|
31
27
|
const DEFAULT_GRACE_MS = 60_000;
|
|
32
28
|
|
|
33
|
-
/**
|
|
34
|
-
* How long a reply token stays valid. A day is long enough that someone who
|
|
35
|
-
* sees a page overnight can still act on it in the morning, and short enough
|
|
36
|
-
* that a token found later is useless.
|
|
37
|
-
*/
|
|
38
|
-
const TOKEN_TTL_MS = 24 * 60 * 60_000;
|
|
39
|
-
|
|
40
29
|
/**
|
|
41
30
|
* Deployment topology for suppression, read once per sweep.
|
|
42
31
|
*
|
|
@@ -59,69 +48,6 @@ function loadTopology(db: ReturnType<typeof getDb>): SuppressionTopology {
|
|
|
59
48
|
return { moduleSystems, zoneProviders: [] };
|
|
60
49
|
}
|
|
61
50
|
|
|
62
|
-
/**
|
|
63
|
-
* Assemble everything needed to page for one alert, or null when nobody can be.
|
|
64
|
-
*
|
|
65
|
-
* Returning null rather than an empty policy is deliberate: "no escalation
|
|
66
|
-
* policy assigned" and "policy exists but nobody is eligible" are different
|
|
67
|
-
* situations, and only the second is worth an escalation decision.
|
|
68
|
-
*/
|
|
69
|
-
function buildNotifyDeps(db: ReturnType<typeof getDb>, alert: Alert): NotifyDeps | null {
|
|
70
|
-
if (!alert.escalationPolicyId) return null;
|
|
71
|
-
|
|
72
|
-
const policy = db
|
|
73
|
-
.select()
|
|
74
|
-
.from(escalationPolicies)
|
|
75
|
-
.where(eq(escalationPolicies.id, alert.escalationPolicyId))
|
|
76
|
-
.get();
|
|
77
|
-
if (!policy) return null;
|
|
78
|
-
|
|
79
|
-
const steps = listPolicySteps(db, policy.id).map((step) => ({
|
|
80
|
-
stepIndex: step.stepIndex,
|
|
81
|
-
routeId: step.routeId,
|
|
82
|
-
delayMinutes: step.delayMinutes,
|
|
83
|
-
}));
|
|
84
|
-
if (steps.length === 0) return null;
|
|
85
|
-
|
|
86
|
-
const routeRows = listRoutes(db);
|
|
87
|
-
const people = listPeople(db);
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
steps,
|
|
91
|
-
routes: new Map(
|
|
92
|
-
routeRows.map((r) => [
|
|
93
|
-
r.id,
|
|
94
|
-
{ id: r.id, severityFloor: r.severityFloor, enabled: r.enabled },
|
|
95
|
-
]),
|
|
96
|
-
),
|
|
97
|
-
routeDetails: new Map(routeRows.map((r) => [r.id, r])),
|
|
98
|
-
quietHoursByPerson: new Map(
|
|
99
|
-
people
|
|
100
|
-
.filter((p) => p.quietHoursStart && p.quietHoursEnd)
|
|
101
|
-
.map((p) => [
|
|
102
|
-
p.id,
|
|
103
|
-
{
|
|
104
|
-
personId: p.id,
|
|
105
|
-
start: p.quietHoursStart,
|
|
106
|
-
end: p.quietHoursEnd,
|
|
107
|
-
timezone: p.timezone,
|
|
108
|
-
},
|
|
109
|
-
]),
|
|
110
|
-
),
|
|
111
|
-
bypassQuietHours: policy.bypassQuietHours,
|
|
112
|
-
transportFor: (route) => loadNotificationTransport(db, route.transportModuleId),
|
|
113
|
-
mintToken: (alertId, routeId) =>
|
|
114
|
-
mintDelivery(db, {
|
|
115
|
-
kind: 'alert',
|
|
116
|
-
targetId: alertId,
|
|
117
|
-
routeId,
|
|
118
|
-
now: new Date(),
|
|
119
|
-
ttlMs: TOKEN_TTL_MS,
|
|
120
|
-
}).token,
|
|
121
|
-
now: new Date(),
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
51
|
export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
126
52
|
const db = getDb();
|
|
127
53
|
const monitorRows = listMonitors(db);
|
|
@@ -132,7 +58,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
132
58
|
monitorDeps: {
|
|
133
59
|
runModuleCheck: (moduleId: string) =>
|
|
134
60
|
runModuleHealthCheck(moduleId, db, { unattended: true, noInteractive: true }),
|
|
135
|
-
runBuiltinCheck: (category) => runBuiltinCheckForMonitor(category),
|
|
61
|
+
runBuiltinCheck: (category) => runBuiltinCheckForMonitor(category, db),
|
|
136
62
|
loadModuleCoverage: () => loadModuleCoverage(db),
|
|
137
63
|
now: () => new Date(),
|
|
138
64
|
graceMs: DEFAULT_GRACE_MS,
|
|
@@ -140,7 +66,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
140
66
|
loadTopology: () => loadTopology(db),
|
|
141
67
|
loadDeployWindowModules: () => modulesInDeployWindow(db),
|
|
142
68
|
isSuppressible: (alert) => suppressibleByMonitor.get(alert.monitorId) ?? true,
|
|
143
|
-
notifyDepsFor: (alert) => buildNotifyDeps(db, alert),
|
|
69
|
+
notifyDepsFor: (alert) => buildNotifyDeps(db, alert, new Date()),
|
|
144
70
|
now: () => new Date(),
|
|
145
71
|
});
|
|
146
72
|
|
|
@@ -160,7 +86,7 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
160
86
|
if (report.deferred > 0) parts.push(`${report.deferred} deferred`);
|
|
161
87
|
if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
|
|
162
88
|
if (report.failed > 0) parts.push(`${report.failed} FAILED`);
|
|
163
|
-
if (report.noPolicy > 0) parts.push(`${report.noPolicy} no-policy`);
|
|
89
|
+
if (report.noPolicy.length > 0) parts.push(`${report.noPolicy.length} no-policy`);
|
|
164
90
|
|
|
165
91
|
const lines = [`alert sweep: ${parts.join(', ')}`];
|
|
166
92
|
|
|
@@ -176,10 +102,25 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
|
|
|
176
102
|
for (const failure of report.failures) {
|
|
177
103
|
lines.push(` FAILED ${failure}`);
|
|
178
104
|
}
|
|
179
|
-
|
|
105
|
+
// Name the alert AND the monitor that owns it. The previous wording printed a
|
|
106
|
+
// count and a template command, which is unusable: the operator cannot tell
|
|
107
|
+
// which of their live alerts is the unrouted one, so the only way to act on it
|
|
108
|
+
// is to assign a policy to every monitor and hope (#481).
|
|
109
|
+
//
|
|
110
|
+
// The wording says "no policy, or a policy with no steps" because that is
|
|
111
|
+
// genuinely all the sweep knows here — both produce a null NotifyDeps. Naming
|
|
112
|
+
// only the first would send an operator whose policy is merely empty chasing
|
|
113
|
+
// an assignment they have already made, which is the failure being fixed.
|
|
114
|
+
if (report.noPolicy.length > 0) {
|
|
180
115
|
lines.push(
|
|
181
|
-
` ${report.noPolicy} live alert(s)
|
|
116
|
+
` ${report.noPolicy.length} live alert(s) reach nobody — no escalation policy, or a policy with no steps:`,
|
|
182
117
|
);
|
|
118
|
+
for (const { alertKey, monitor } of report.noPolicy) {
|
|
119
|
+
lines.push(` ${alertKey} (monitor: ${monitor})`);
|
|
120
|
+
lines.push(` celilo escalation-policy assign <policy> ${monitor}`);
|
|
121
|
+
}
|
|
122
|
+
lines.push(' Which policy each monitor uses: celilo monitor list');
|
|
123
|
+
lines.push(' Whether that policy has steps: celilo escalation-policy list');
|
|
183
124
|
}
|
|
184
125
|
|
|
185
126
|
return { success: true, message: lines.join('\n') };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo backup sweep` — one pass of the scheduled backup runner.
|
|
3
|
+
*
|
|
4
|
+
* Invoked by the event-bus dispatcher on `timer.tick.1h`, not by a human. It is
|
|
5
|
+
* a normal command so the dispatcher's existing subprocess isolation, retry,
|
|
6
|
+
* and timeout apply unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Thin adapter (Rule 10.5): compose the real dependencies, call runBackupSweep,
|
|
9
|
+
* report counts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createModuleBackup,
|
|
14
|
+
findBackupEligibleModules,
|
|
15
|
+
isBackupDue,
|
|
16
|
+
} from '../../services/backup-create';
|
|
17
|
+
import { pruneBackupsForModule } from '../../services/backup-retention';
|
|
18
|
+
import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
|
|
19
|
+
import type { CommandResult } from '../types';
|
|
20
|
+
|
|
21
|
+
export async function handleBackupSweep(): Promise<CommandResult> {
|
|
22
|
+
const report = await runBackupSweep({
|
|
23
|
+
listEligible: () =>
|
|
24
|
+
findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
|
|
25
|
+
isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
|
|
26
|
+
backup: (moduleId) => createModuleBackup(moduleId),
|
|
27
|
+
prune: async ({ id, manifest }) => {
|
|
28
|
+
const retention = manifest.backup?.retention;
|
|
29
|
+
if (!retention) return;
|
|
30
|
+
await pruneBackupsForModule(id, {
|
|
31
|
+
count: retention.count,
|
|
32
|
+
maxAgeDays: retention.max_age_days,
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const summary = formatReport(report);
|
|
38
|
+
if (report.failures.length > 0) {
|
|
39
|
+
return { success: false, error: summary };
|
|
40
|
+
}
|
|
41
|
+
return { success: true, message: summary };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A quiet sweep stays quiet: counts that are zero are omitted. But a failure or
|
|
46
|
+
* a lock-skip is always named, never aggregated away — "0 backed up" and
|
|
47
|
+
* nothing else is indistinguishable from "nothing was due", which is exactly
|
|
48
|
+
* how a backup runner that has silently stopped working looks.
|
|
49
|
+
*/
|
|
50
|
+
function formatReport(report: BackupSweepReport): string {
|
|
51
|
+
const parts = [`${report.backedUp.length} backed up`];
|
|
52
|
+
if (report.skippedNotDue.length > 0) parts.push(`${report.skippedNotDue.length} not due`);
|
|
53
|
+
if (report.skippedManual.length > 0) parts.push(`${report.skippedManual.length} manual`);
|
|
54
|
+
if (report.skippedLocked.length > 0) parts.push(`${report.skippedLocked.length} locked`);
|
|
55
|
+
if (report.failures.length > 0) parts.push(`${report.failures.length} FAILED`);
|
|
56
|
+
|
|
57
|
+
const lines = [`backup sweep: ${parts.join(', ')}`];
|
|
58
|
+
for (const moduleId of report.skippedLocked) {
|
|
59
|
+
lines.push(` skipped ${moduleId}: another operation is in flight — retrying next tick`);
|
|
60
|
+
}
|
|
61
|
+
for (const failure of report.failures) {
|
|
62
|
+
lines.push(` FAILED ${failure.moduleId}: ${failure.error}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
@@ -11,7 +11,13 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { type DbClient, getDb } from '../../db/client';
|
|
13
13
|
import { modules } from '../../db/schema';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDeployPosture } from '../../services/deploy-posture';
|
|
15
|
+
import {
|
|
16
|
+
FRAMEWORK_CONFIG_KEYS,
|
|
17
|
+
handleModuleConfigSet,
|
|
18
|
+
validateFrameworkConfigValue,
|
|
19
|
+
} from './module-config';
|
|
20
|
+
import { pickUpgradePolicy } from './module-upgrade';
|
|
15
21
|
|
|
16
22
|
describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
17
23
|
let tempDir: string;
|
|
@@ -75,4 +81,74 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
|
|
|
75
81
|
expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
|
|
76
82
|
}
|
|
77
83
|
});
|
|
84
|
+
|
|
85
|
+
// #515: these describe how celilo TREATS a module, so they must not depend on
|
|
86
|
+
// the module having declared them. `testmod` declares neither — which is the
|
|
87
|
+
// bug exactly: `upgrade_policy` was declared by NO module, so `always-safe`
|
|
88
|
+
// (the only control over unattended-upgrade risk) could never be set.
|
|
89
|
+
test('accepts upgrade_policy on a module that does not declare it', async () => {
|
|
90
|
+
const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'always-safe']);
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('accepts auto_upgrade on a module that does not declare it', async () => {
|
|
95
|
+
const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'true']);
|
|
96
|
+
expect(result.success).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('rejects a mistyped policy instead of silently falling back to by-semver', async () => {
|
|
100
|
+
// `pickUpgradePolicy` fails OPEN: an unrecognized value becomes `by-semver`,
|
|
101
|
+
// which on a patch means fast posture and NO backup. Accepting this would
|
|
102
|
+
// leave the operator believing the safe floor was armed.
|
|
103
|
+
const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'alwayssafe']);
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
expect(result.error).toContain('always-safe');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('rejects a non-boolean auto_upgrade', async () => {
|
|
111
|
+
const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'yes']);
|
|
112
|
+
expect(result.success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the valid-keys hint advertises the celilo-managed keys', async () => {
|
|
116
|
+
const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
|
|
117
|
+
expect(result.success).toBe(false);
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
expect(result.error).toContain('upgrade_policy');
|
|
120
|
+
expect(result.error).toContain('auto_upgrade');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('validateFrameworkConfigValue (pure)', () => {
|
|
126
|
+
test('passes through non-framework keys untouched', () => {
|
|
127
|
+
expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('accepts every documented value', () => {
|
|
131
|
+
for (const [key, values] of Object.entries(FRAMEWORK_CONFIG_KEYS)) {
|
|
132
|
+
for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('rejects an unlisted value', () => {
|
|
137
|
+
expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The point of the whole control: with always-safe set, a PATCH upgrade — which
|
|
142
|
+
// by default is fast posture and skips the pre-deploy backup — becomes safe.
|
|
143
|
+
// #515 made this unreachable, so this asserts the chain end to end.
|
|
144
|
+
describe('always-safe actually changes posture on a patch (#515)', () => {
|
|
145
|
+
test('patch is fast by default, safe once always-safe is chosen', () => {
|
|
146
|
+
const patch = { installed: '1.0.3', next: '1.0.4' };
|
|
147
|
+
expect(
|
|
148
|
+
resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy(undefined) }).posture,
|
|
149
|
+
).toBe('fast');
|
|
150
|
+
expect(
|
|
151
|
+
resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy('always-safe') }).posture,
|
|
152
|
+
).toBe('safe');
|
|
153
|
+
});
|
|
78
154
|
});
|
|
@@ -14,6 +14,42 @@ import {
|
|
|
14
14
|
import { getArg, validateRequiredArgs } from '../parser';
|
|
15
15
|
import type { CommandResult } from '../types';
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Operator keys that EVERY module accepts, whether or not its manifest declares
|
|
19
|
+
* them, with their permitted values.
|
|
20
|
+
*
|
|
21
|
+
* These describe how celilo TREATS a module (its CD policy), not how the module
|
|
22
|
+
* configures itself, so gating them on `variables.owns` had it backwards: it
|
|
23
|
+
* required each module author to opt into being manageable. The failure was
|
|
24
|
+
* silent — `upgrade_policy` was declared by no module at all, so
|
|
25
|
+
* `pickUpgradePolicy()` could only ever read `undefined` and fall back to
|
|
26
|
+
* `by-semver`, leaving `always-safe` (the ONLY control over unattended-upgrade
|
|
27
|
+
* risk) permanently unreachable. `auto_upgrade` worked only because lunacycle
|
|
28
|
+
* happened to declare it. See #515.
|
|
29
|
+
*
|
|
30
|
+
* Values are checked at SET time rather than coerced at read time. Both readers
|
|
31
|
+
* fail OPEN on an unrecognized value — `pickUpgradePolicy()` returns
|
|
32
|
+
* `by-semver`, which for a patch means fast posture and NO backup. So a typo
|
|
33
|
+
* like `alwayssafe` would leave the operator believing they had armed the safe
|
|
34
|
+
* floor while nothing changed. A safety control that fails open on a typo is
|
|
35
|
+
* worse than no control.
|
|
36
|
+
*/
|
|
37
|
+
export const FRAMEWORK_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
38
|
+
auto_upgrade: ['true', 'false'],
|
|
39
|
+
upgrade_policy: ['by-semver', 'always-safe', 'always-fast'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PURE (Rule 10.1): validate a framework key's value. Returns an error message,
|
|
44
|
+
* or null when the key is not a framework key or the value is permitted.
|
|
45
|
+
*/
|
|
46
|
+
export function validateFrameworkConfigValue(key: string, value: string): string | null {
|
|
47
|
+
const allowed = FRAMEWORK_CONFIG_KEYS[key];
|
|
48
|
+
if (!allowed) return null;
|
|
49
|
+
if (allowed.includes(value)) return null;
|
|
50
|
+
return `Invalid value '${value}' for '${key}'.\n\nAllowed: ${allowed.join(', ')}\n\nRejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
/**
|
|
18
54
|
* Handle module config set command
|
|
19
55
|
*
|
|
@@ -54,6 +90,11 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
54
90
|
};
|
|
55
91
|
}
|
|
56
92
|
|
|
93
|
+
// Framework keys bypass the manifest check entirely — see FRAMEWORK_CONFIG_KEYS.
|
|
94
|
+
const frameworkError = validateFrameworkConfigValue(key, value);
|
|
95
|
+
if (frameworkError) return { success: false, error: frameworkError };
|
|
96
|
+
const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
|
|
97
|
+
|
|
57
98
|
// Validate key against manifest
|
|
58
99
|
const manifest = module.manifestData as Record<string, unknown>;
|
|
59
100
|
const variables = manifest.variables as
|
|
@@ -63,14 +104,15 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
63
104
|
|
|
64
105
|
// Check if key is declared in manifest
|
|
65
106
|
const declaredVar = declaredVars.find((v) => v.name === key);
|
|
66
|
-
if (!declaredVar) {
|
|
107
|
+
if (!declaredVar && !isFrameworkKey) {
|
|
67
108
|
const settableKeys = declaredVars
|
|
68
109
|
.filter((v) => v.source !== 'infrastructure')
|
|
69
110
|
.map((v) => v.name)
|
|
70
111
|
.join(', ');
|
|
112
|
+
const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
|
|
71
113
|
return {
|
|
72
114
|
success: false,
|
|
73
|
-
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
|
|
115
|
+
error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}\nCelilo-managed keys (any module): ${frameworkKeys}`,
|
|
74
116
|
};
|
|
75
117
|
}
|
|
76
118
|
|
|
@@ -79,7 +121,7 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
|
|
|
79
121
|
// variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
|
|
80
122
|
// derived by the deploy (IPAM allocates vmid/IP; the container service supplies
|
|
81
123
|
// node/template/gateway/vlan), so a value set here would be ignored.
|
|
82
|
-
if (declaredVar
|
|
124
|
+
if (declaredVar?.source === 'infrastructure') {
|
|
83
125
|
return {
|
|
84
126
|
success: false,
|
|
85
127
|
error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
|