@celilo/cli 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +27 -7
  3. package/package.json +6 -5
  4. package/src/cli/commands/alerts-act.ts +1 -1
  5. package/src/cli/commands/backup-create.ts +26 -11
  6. package/src/cli/commands/backup-list.test.ts +83 -0
  7. package/src/cli/commands/backup-list.ts +67 -3
  8. package/src/cli/commands/backup-prune.ts +17 -17
  9. package/src/cli/commands/backup-sweep.ts +20 -8
  10. package/src/cli/commands/firewall-interface-list.test.ts +85 -0
  11. package/src/cli/commands/firewall-interface-list.ts +123 -0
  12. package/src/cli/commands/machine-add.ts +30 -2
  13. package/src/cli/commands/module-config.test.ts +64 -2
  14. package/src/cli/commands/module-config.ts +159 -8
  15. package/src/cli/commands/module-status.ts +124 -0
  16. package/src/cli/commands/monitor.ts +116 -19
  17. package/src/cli/commands/system-migrate.ts +14 -0
  18. package/src/cli/commands/system-update.ts +4 -1
  19. package/src/cli/completion.ts +35 -9
  20. package/src/cli/index.ts +59 -2
  21. package/src/cli/tui/audit-state.ts +2 -0
  22. package/src/hooks/capability-loader.ts +130 -4
  23. package/src/hooks/types.ts +2 -1
  24. package/src/manifest/contracts/v1.ts +16 -0
  25. package/src/manifest/schema.ts +40 -65
  26. package/src/services/alerting/builtin-monitors.test.ts +18 -10
  27. package/src/services/alerting/cadence-migration.test.ts +155 -0
  28. package/src/services/alerting/cadence-migration.ts +90 -0
  29. package/src/services/alerting/coverage-source.ts +8 -11
  30. package/src/services/alerting/deploy-hooks.test.ts +16 -7
  31. package/src/services/alerting/deploy-hooks.ts +11 -5
  32. package/src/services/alerting/health-cadence.test.ts +58 -0
  33. package/src/services/alerting/health-cadence.ts +128 -0
  34. package/src/services/alerting/health-coverage.ts +18 -8
  35. package/src/services/alerting/monitors.ts +50 -15
  36. package/src/services/alerting/sweep-runner.test.ts +51 -3
  37. package/src/services/alerting/sweep-runner.ts +30 -7
  38. package/src/services/audit/backup-source.ts +24 -1
  39. package/src/services/audit/backups.test.ts +95 -10
  40. package/src/services/audit/backups.ts +40 -37
  41. package/src/services/audit/interface-classification.test.ts +220 -0
  42. package/src/services/audit/interface-classification.ts +167 -0
  43. package/src/services/audit/types.ts +2 -1
  44. package/src/services/backup-age-agreement.test.ts +118 -0
  45. package/src/services/backup-create.ts +36 -30
  46. package/src/services/backup-metadata.ts +52 -1
  47. package/src/services/backup-retention.test.ts +123 -0
  48. package/src/services/backup-retention.ts +66 -5
  49. package/src/services/backup-schedule.test.ts +166 -0
  50. package/src/services/backup-schedule.ts +105 -15
  51. package/src/services/backup-staging.ts +14 -1
  52. package/src/services/backup-sweep.test.ts +22 -3
  53. package/src/services/backup-sweep.ts +15 -5
  54. package/src/services/cadence.test.ts +97 -0
  55. package/src/services/cadence.ts +165 -0
  56. package/src/services/machine-detector.ts +23 -1
  57. package/src/services/module-config.ts +33 -0
  58. package/src/services/storage-providers/s3.test.ts +96 -13
  59. package/src/services/storage-providers/s3.ts +48 -15
  60. package/src/services/zone-detector.test.ts +34 -3
  61. package/src/services/zone-detector.ts +33 -13
@@ -21,13 +21,13 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
21
21
 
22
22
  ## Network fabric (DNS / firewall / DHCP)
23
23
 
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**.
24
+ - **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds 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**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
25
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**.
26
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`.
27
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/`).
28
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/`).
29
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`.
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
+ - **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. **Declares `network.control-plane-vpn.subnet` BEFORE it creates the interface** — the first side effect of `on_install`, before any command touches the box. The ordering is load-bearing, not tidiness: the firewall classifies every interface on its host and one no declaration accounts for is `alien`, so creating `wg0` first would leave a window in which a converge — a timer, the registry-poll CD, any unrelated module deploy — could shut the admin VPN down as an intruder's interface. The module declares the NETWORK, never the device: only the firewall decides what an interface is for. `health_check` asserts the declaration still holds, because the other checks all pass while it is missing. That same key is what the internal resolver's split-horizon view 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).
31
31
 
32
32
  ## Public edge (ingress / identity)
33
33
 
@@ -25,7 +25,10 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
25
25
 
26
26
  - **IPAM (IP/VMID allocation)** — `apps/celilo/src/ipam/allocator.ts` — `allocateIPFromSubnet`, `allocateVMID`, `reserveIP`/`unreserveIP`, `inferZoneFromIP`, `getAllocation`. Auto-wrapper: `apps/celilo/src/ipam/auto-allocator.ts` — `allocateForModule` / `deallocateForModule`.
27
27
  - **Infrastructure selection (container-service vs machine pool)** — `apps/celilo/src/services/machine-pool.ts` (`getMachineByHostname`, `addMachine`, `assignModuleToMachine`) and `apps/celilo/src/services/container-service.ts` (`getContainerServiceByName`, `addContainerService`, `verifyContainerService`). Provider API clients: `apps/celilo/src/api-clients/proxmox.ts`, `apps/celilo/src/api-clients/digitalocean.ts`.
28
- - **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table.
28
+ - **Zone detection / system config** — `apps/celilo/src/services/zone-detector.ts` — `detectZoneFromIp` reads `network.<zone>.subnet` from the `systemConfig` table and returns `NetworkZone | 'unknown'`. It answers CONTAINMENT ONLY. It used to return `'external'` on no-match, which conflated "no declared subnet contains this" with "the internet can route to this" — on a firewall with five RFC1918 legs that reported four of them as facing the internet. `'unknown'` is the honest answer; the caller resolves it (see `machine add`: publicly routable → `external`, otherwise fail asking for `--zone`). The subnet-backed zone list is derived from `NETWORK_ZONES` minus `external`, which has no subnet and must never be given one.
29
+ - **Interface classification** — `packages/capabilities/src/interface-classification.ts` — THE shared classifier, used by the backend and every firewall provider module so the two cannot drift apart again. `isPubliclyRoutable(ip)` is a property of the address alone (false for RFC 1918, RFC 6598 carrier-grade NAT, loopback, link-local, multicast, reserved). `classifyInterfaces(interfaces, zones)` assigns each interface `zone → external → alien`, first match winning, where `external` is the RESIDUAL — routable and claimed by no declared zone — and is never subnet-matched. `externalEdge()` returns none/single/**ambiguous** rather than silently picking the first public address. `defaultRouteFinding()` enforces the invariant that the default route leaves through `internal` or `external`. **`subnetContains(cidr, ip)` lives here and is the ONLY implementation** — three existed and disagreed (the backend's mishandled `/0`); the other two are deleted, not aliased. Design: `openspec/changes/firewall-interface-classification/design.md`.
30
+ - **Declared networks (classification input)** — `readDeclaredNetworks(db)` in `apps/celilo/src/hooks/capability-loader.ts` — every `network.<name>.subnet` in system config, which is what an interface is attributed against. Read from the CONFIG, not from `NETWORK_ZONES`: celilo declares networks that are not placement zones (`network.control-plane-vpn.subnet`, written by `wireguard` before it brings `wg0` up). Injected into the firewall capability as a LIVE reader (`declaredNetworks`), alongside the trusted-source store and for the same reason — a declaration written during a hook run must be visible to the converge that follows it in that same run, which a snapshot taken at capability-build time cannot be.
31
+ - **Firewall interface audit** — `apps/celilo/src/services/audit/interface-classification.ts` — reports per-firewall classification in `celilo audit`: alien interfaces by name and address (drift), and the blocking findings a converge refuses on — a carrier-grade NAT leg, an ambiguous external edge, a default route on the wrong leg.
29
32
  - **Zone taxonomy (canonical list)** — `apps/celilo/src/db/schema.ts` — `NETWORK_ZONES` is the single array; `NetworkZone` is DERIVED from it. Never hand-maintain a second copy: a duplicate that dropped a member made zone validation return null and silently fall back to a wrong-but-valid zone.
30
33
  - **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
34
  - **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.
@@ -217,7 +220,10 @@ is currently wrong, and routes carry the message to a person's phone. Design:
217
220
  - **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`.
218
221
  - **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.
219
222
  - **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).
220
- - **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`, `backups`, `disk_space`, `abandoned_operations`, and `public_dns` (`apps/celilo/src/services/audit/abandoned-operations.ts` ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). 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`. Targets are tab-completable `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
223
+ - **Health-check cadence (one accessor)** — `apps/celilo/src/services/alerting/health-cadence.ts` — `effectiveHealthCheckCadence(manifest, override)`, `isScheduled`, `loadModuleHealthCadences`, `reconcileModuleWatchState`. The manifest's `hooks.health_check.interval` SUGGESTS; the operator's `health_check_interval` decides; both resolve at read time. `null` means nobody named a cadence (a coverage gap); `'manual'` means the operator opted out (a decision raises no coverage finding, and resolves the module's live alerts on the way down, since nothing will report on them again).
224
+ - **⚠️ `monitors.intervalMinutes` and `monitors.enabled` are `builtin_check`-only.** A `module_hook` row carries severity, escalation policy and `lastRunAt`; its cadence and whether it is watched resolve through the accessor above. The columns' meaning depending on `kind` is a named smell (design.md D8) — the alternatives are a cached resolved value that rots, or splitting the table, which needs a synthetic monitor identity for `alerts.monitorId`. Gates: `sweep-runner.test.ts` asserts a module row's stored values are NOT consulted; `cadence-migration.test.ts` asserts the same through `loadModuleHealthCadences`.
225
+ - **Carrying an existing fleet over** — `apps/celilo/src/services/alerting/cadence-migration.ts` (`migrateMonitorCadences`), run from `celilo system migrate` (the `.deb` postinst runs it on every apt upgrade). A monitor row whose cadence diverges from its manifest gets that cadence written as an override; a disabled one gets `manual`. Not bookkeeping: without it the upgrade that ships read-time resolution silently reverts every hand-set cadence to the author's suggestion and resumes watching modules an operator deliberately disabled. Idempotent — writes only where no override exists.
226
+ - **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`, `backups`, `disk_space`, `abandoned_operations`, and `public_dns` (`apps/celilo/src/services/audit/abandoned-operations.ts` — ≥3 abandonments of the same (module, operation) in 7d, the fingerprint of an operation being killed mid-flight). 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`, and re-cadence it later with `celilo monitor set-interval backups 6h` (in place, because the monitor id owns the alert history). `monitor set-interval`/`enable`/`disable` REFUSE a module target and name `celilo module config set <m> health_check_interval` — two ways to set one module's cadence would disagree about what `module status` shows. Targets are tab-completable — `completion.ts` reads `SCHEDULABLE_BUILTIN_CHECKS` directly rather than a hand-copied list, so a newly-schedulable check is completable immediately.
221
227
  - **Disk-space check** — `apps/celilo/src/services/audit/disk-space.ts` (`auditDiskSpace`, pure over measurements) + `apps/celilo/src/services/disk-probe.ts` (`probeDiskUsage`). Thresholds: `drift` at 85%, `blocked` at 95% — early enough to act on, since a check that fires at exhaustion reports an outage rather than preventing one. ⚠️ **The local management box is MEASURED, not exempted.** `probeMachines()` deliberately reports the local box reachable without probing it (celilo has no SSH key for itself, and the question is meaningless there); copying that shortcut into a disk check would skip the host most likely to fill — the one that stages backups, caches modules and writes the logs, and the one that DID fill. Local reads `statfs`; remote runs `df -P /` over the same bounded SSH. `percentUsed` matches `df`'s capacity semantics (excludes root-reserved blocks) so an alert and an operator's own `df` agree. An unmeasurable host yields a `todo` finding — recorded, never paged, because `machines_reachable` is already paging for that host. Findings are subjected on the **hostname**, not the machine UUID, because suppression resolves a machine's ancestor key from the hostname (see #596, where `machines_reachable` gets this wrong and its alerts therefore never suppress anything). 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.
222
228
  - **Public-DNS reachability check (the only check with an OFF-FLEET vantage)** — `apps/celilo/src/services/audit/public-dns.ts` (`auditPublicDns`, pure over an injected probe and the previous run's counters) + `apps/celilo/src/services/public-dns-probe.ts` (the probe) + `audit/public-dns-source.ts` (ledger names + the `public_dns_evidence` counters). Every other check in celilo looks from INSIDE, behind a split-horizon resolver that deliberately answers with an in-zone address — correct for its purpose, and why all of them reported healthy for the nine days of celilo#626. This one resolves every `dns_registrations` FQDN through an **off-fleet resolver** (`public_dns.resolver`, default `1.1.1.1`) and compares it against the address the fleet appears to come from per an independent **echo service** (`public_dns.echo_url`, default `https://api.ipify.org`). Three properties are load-bearing: `assertOffFleetResolver` REFUSES a resolver matching `dns.primary`/`dns.fallback` (a check that quietly used the fleet's resolver would pass forever — the original bug one layer up); the expectation never comes from the registrar's own response (self-agreement, and Namecheap returns `ErrCount 0` for `www` updates it does not apply); and a divergence is a finding only once it OUTLIVES the record's own TTL, measured from the last assert, or it would page on every ISP re-lease. Missing evidence is counted rather than read as success — one undetermined run is silent, N consecutive ones are their own finding (`public_dns_evidence`), which is the hole celilo-website's isitup.org probe demonstrated live. Codes: `public_dns_stale`, `public_dns_missing`, `public_dns_companion_unclaimed`, `public_dns_unverifiable`. Spec: `openspec/specs/public-dns-reachability/spec.md`.
223
229
  - **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.
@@ -234,18 +240,32 @@ is currently wrong, and routes carry the message to a person's phone. Design:
234
240
 
235
241
  ## Backups
236
242
 
237
- Creation, scheduling and freshness. A module declares an `on_backup` hook and a
238
- `backup.schedule`; celilo runs it on that cadence and alerts when it stops.
243
+ Creation, scheduling and freshness. A module declares an `on_backup` hook and
244
+ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
245
+ the resolved cadence and alerts when it stops.
239
246
 
240
- - **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`.
241
- - **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.
247
+ - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
248
+ - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. 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.
249
+ - **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
250
+ - **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
242
251
  - **The sweep** — `apps/celilo/src/services/backup-sweep.ts` — `runBackupSweep` (the pass that makes backups run by themselves: reclaim orphaned staging → 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 from BOTH `registerModuleSubscriptions` (any module declaring an `on_backup` hook, so it appears on install or `module update`) AND `celilo system migrate`, which the `.deb` postinst runs on every apt upgrade — registering only from module install/update meant a corrected budget never reached an existing fleet, since the row already exists and module updates can be weeks apart. A run refused by the in-flight operation lock is a skip retried next tick, never a failure.
243
252
  - **⚠️ The sweep's budget is stated, never inherited** — `BACKUP_SWEEP_TIMEOUT_MS` (4h) and `BACKUP_SWEEP_MAX_ATTEMPTS` (1) in `backup-sweep.ts`. The event bus defaults to `timeout_ms: 60000` / `max_attempts: 3`, and inheriting them made scheduled backups structurally impossible: one forgejo backup measured ~5.5 minutes (1.3 GB result, 3.9 GB peak staging), so the dispatcher SIGTERMed it at 60s — three times an hour, for days. The retry count is half the bug, not a detail: an impossible pass retried 3x strands 3x the staging (27 GB in 5.7 hours on celilo-mgr). The hourly tick IS the retry.
244
253
  - **Staging reclamation** — `apps/celilo/src/services/backup-staging.ts` — `reapOrphanedStaging`, `stagingDirFor` (the single source of truth for `/tmp/celilo-backup-<record.id>`, shared with `backup-create.ts` so writer and reaper cannot drift). `backup-create.ts` removes its staging in a `finally`, which is correct and NOT enough: a `finally` never runs when the process is killed by a signal — dispatcher timeout, OOM, Ctrl-C, reboot — and those strand the LARGEST directories. So reclamation is kill-mode agnostic by construction: it asks "is anyone still using this?", answered from the `backups.pid` column plus `isPidRunnable`, both of which outlive the process. A directory is removed only when its owner is provably gone (record absent, record terminal, pid dead, or past `STAGING_TTL_MS` = 6h — the TTL is the only check surviving pid reuse). A live backup is always kept; an unrecognised name is ignored, never deleted. Reclaiming a record that still claimed `in_progress` also marks it failed with `ABANDONED_BACKUP_MESSAGE`, so `celilo backup list` stops showing phantom in-flight backups and the `backups` drift check cannot read a dead attempt as a fresh backup.
245
- - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
254
+ - **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`, `backupStaleThresholdMs`) — `backup_missing` / `backup_stale` drift findings against the same EFFECTIVE cadence, loaded (with each module's override) by `audit/backup-source.ts`. The stale threshold is a formula, `cadence + max(1h, cadence × 0.1)`, not a lookup table: a table cannot answer for `6h`. An effective cadence of `manual` short-circuits BEFORE the never-backed-up check — a module the operator opted out of used to be reported as missing a backup forever, remediated only by the thing they declined.
255
+ - **⚠️ Both age measurements read `backups.completedAt`** — `loadBackupHistory` (`backup-metadata.ts`) and `latestSuccessfulBackupByModule` (`audit/backup-source.ts`). The run path used to measure from `startedAt` and the audit from `completedAt`, so the two disagreed by the duration of the backup itself. Gate: `services/backup-age-agreement.test.ts`.
246
256
  - **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`.
247
257
  - **Storage destinations CLI** — `storage-add-local.ts`, `storage-add-s3.ts`, `storage-list.ts`, `storage-verify.ts`, `storage-set-default.ts`, `storage-set-path.ts` (relocate a local destination, migrating existing archives unless `--no-migrate`), `storage-remove.ts`. Any credential change goes through `updateStorageCredentials` in `backup-storage.ts`, which clears the verification stamp — a `✓ Verified` must never describe a destination it was not measured against (#566).
248
258
 
259
+ ## Per-module operator policy
260
+
261
+ How celilo TREATS a module — its backup cadence, its health-check cadence, its
262
+ upgrade controls — as opposed to how the module configures itself.
263
+
264
+ - **The keys** — `FRAMEWORK_CONFIG_KEYS` in `apps/celilo/src/cli/commands/module-config.ts` — keys EVERY module accepts whether or not its manifest declares them: `auto_upgrade`, `upgrade_policy`, `backup_schedule`, `health_check_interval`, `backup_retention_count`, `backup_retention_max_age_days`. Each carries `{schema, why}`: a Zod schema, and the reason a wrong value is REFUSED rather than coerced. That reason is per key on purpose — the substance is what a wrong value would silently do (a cadence typo falls back to the manifest's suggestion; an `upgrade_policy` typo falls back to `by-semver`, which skips the pre-deploy backup on a patch), and no validator knows that.
265
+ - **Storage** — rows in `module_configs`, unique on `(module_id, key)`, cascading on module removal. Nothing deletes them on `module update`, and `backup-create.ts` archives them into backup envelopes while `backup-restore.ts` restores them — so an override survives update, upgrade and restore with no new table and no migration.
266
+ - **Operator surface** — `celilo module config set|get|unset <module> <key> [value]`. `unset` (→ `deleteModuleConfig` in `services/module-config.ts`) is what returns a module to following its manifest; without it an operator who once set a value could never go back, and later manifest corrections would stop reaching them permanently. Unsetting an absent key reports that and SUCCEEDS — it states a desired end state. All three are MCP tools with no bespoke code, generated from `packages/core/src/command-registry.ts`.
267
+ - **Display** — `formatCadencePolicy` in `apps/celilo/src/cli/commands/module-status.ts` shows the effective value AND its source, naming the manifest's suggestion when an override is in effect. The stored override alone does not say what it was changed from; the effective value alone does not say who chose it.
268
+
249
269
  ## Persistence
250
270
 
251
271
  - **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/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,11 +56,12 @@
56
56
  "check:schema": "bun run scripts/export-manifest-schema.ts --check"
57
57
  },
58
58
  "dependencies": {
59
- "@aws-sdk/client-s3": "^3.1024.0",
60
- "@celilo/capabilities": "^1.1.0",
59
+ "@aws-sdk/client-s3": "^3.1109.0",
60
+ "@aws-sdk/lib-storage": "^3.1101.0",
61
+ "@celilo/capabilities": "^1.2.0",
61
62
  "@celilo/cli-display": "^0.2.0",
62
- "@celilo/core": "^0.7.0",
63
- "@celilo/event-bus": "^0.4.0",
63
+ "@celilo/core": "^0.8.0",
64
+ "@celilo/event-bus": "^0.5.0",
64
65
  "ajv": "^8.18.0",
65
66
  "drizzle-orm": "^0.36.4",
66
67
  "ink": "^7.0.1",
@@ -7,7 +7,6 @@
7
7
  */
8
8
 
9
9
  import { getDb } from '../../db/client';
10
- import { parseIntervalMinutes } from '../../manifest/schema';
11
10
  import {
12
11
  acknowledgeAlert,
13
12
  findLiveAlertByKey,
@@ -15,6 +14,7 @@ import {
15
14
  silenceAlert,
16
15
  } from '../../services/alerting/ack';
17
16
  import { findPerson, listPeople } from '../../services/alerting/people';
17
+ import { parseIntervalMinutes } from '../../services/cadence';
18
18
  import type { CommandResult } from '../types';
19
19
 
20
20
  function resolveKey(key: string | undefined) {
@@ -10,7 +10,17 @@ import {
10
10
  isBackupDue,
11
11
  } from '../../services/backup-create';
12
12
  import { formatSize } from '../../services/backup-metadata';
13
- import { pruneBackupsForModule } from '../../services/backup-retention';
13
+ import {
14
+ effectiveBackupRetention,
15
+ pruneBackupsForModule,
16
+ prunesNothing,
17
+ } from '../../services/backup-retention';
18
+ import {
19
+ BACKUP_SCHEDULE_CONFIG_KEY,
20
+ effectiveBackupSchedule,
21
+ } from '../../services/backup-schedule';
22
+ import { formatCadence } from '../../services/cadence';
23
+ import { configOverride } from '../../services/module-config';
14
24
  import { celiloIntro, celiloOutro } from '../prompts';
15
25
  import type { CommandResult } from '../types';
16
26
 
@@ -56,12 +66,20 @@ export async function handleBackupCreate(
56
66
  if (eligible.length > 0) {
57
67
  console.log('\nChecking module backup schedules...');
58
68
 
59
- for (const { module: mod, manifest } of eligible) {
60
- const schedule = manifest.backup?.schedule ?? 'manual';
69
+ for (const { module: mod, manifest, configs } of eligible) {
70
+ // Through the shared accessor, like every other reader. This site
71
+ // used to read the manifest directly AND default an absent cadence
72
+ // to `manual` — the opposite of what the accessor has always said —
73
+ // so `celilo backup create` quietly skipped modules the hourly sweep
74
+ // was backing up daily.
75
+ const schedule = effectiveBackupSchedule(
76
+ manifest,
77
+ configOverride(configs, BACKUP_SCHEDULE_CONFIG_KEY),
78
+ );
61
79
  const due = force || isBackupDue(mod.id, schedule);
62
80
 
63
81
  if (!due) {
64
- console.log(` ${mod.id}: skipping (not due, schedule: ${schedule})`);
82
+ console.log(` ${mod.id}: skipping (not due, schedule: ${formatCadence(schedule)})`);
65
83
  moduleSkipCount++;
66
84
  continue;
67
85
  }
@@ -77,13 +95,10 @@ export async function handleBackupCreate(
77
95
  console.log(` → ${result.storagePath}`);
78
96
  moduleBackupCount++;
79
97
 
80
- // Auto-prune per retention policy
81
- const retention = manifest.backup?.retention;
82
- if (retention) {
83
- const pruneResult = await pruneBackupsForModule(mod.id, {
84
- count: retention.count,
85
- maxAgeDays: retention.max_age_days,
86
- });
98
+ // Auto-prune per the effective retention policy
99
+ const policy = effectiveBackupRetention(manifest, configs);
100
+ if (!prunesNothing(policy)) {
101
+ const pruneResult = await pruneBackupsForModule(mod.id, policy);
87
102
  if (pruneResult.deleted > 0) {
88
103
  console.log(` ✓ Pruned ${pruneResult.deleted} old backup(s)`);
89
104
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The per-module recovery summary under `celilo backup list`.
3
+ *
4
+ * The listing itself is a log of attempts, and on celilo-mgr a column of
5
+ * `✗ 0 B` rows on a tidy hourly rhythm was true for a day while forgejo had no
6
+ * usable backup at all (celilo#685). These assert the two facts that column
7
+ * structurally cannot carry: `never`, and how much has failed since.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import type { Backup } from '../../db/schema';
12
+ import { backedUpModuleIds, lastSuccessSummaryLines } from './backup-list';
13
+
14
+ const NOW = Date.UTC(2026, 7, 13, 12, 0, 0);
15
+ const ago = (ms: number) => new Date(NOW - ms);
16
+ const HOUR = 60 * 60 * 1000;
17
+
18
+ function backup(partial: Partial<Backup>): Backup {
19
+ return { backupType: 'module_data', moduleId: 'forgejo', ...partial } as Backup;
20
+ }
21
+
22
+ describe('backedUpModuleIds', () => {
23
+ test('deduplicates and sorts the modules a listing names', () => {
24
+ expect(
25
+ backedUpModuleIds([
26
+ backup({ moduleId: 'forgejo' }),
27
+ backup({ moduleId: 'authentik' }),
28
+ backup({ moduleId: 'forgejo' }),
29
+ ]),
30
+ ).toEqual(['authentik', 'forgejo']);
31
+ });
32
+
33
+ test('ignores system backups, which have no module and no cadence', () => {
34
+ expect(
35
+ backedUpModuleIds([
36
+ backup({ backupType: 'system_state', moduleId: null }),
37
+ backup({ moduleId: 'forgejo' }),
38
+ ]),
39
+ ).toEqual(['forgejo']);
40
+ });
41
+ });
42
+
43
+ describe('lastSuccessSummaryLines', () => {
44
+ test('says never, out loud, when a module has never been captured', () => {
45
+ // `signal` on celilo-mgr. A listing of its attempts shows rows on a
46
+ // schedule; none of them is a backup.
47
+ const [line] = lastSuccessSummaryLines(
48
+ [{ moduleId: 'signal', lastSuccessAt: null, consecutiveFailures: 8 }],
49
+ NOW,
50
+ );
51
+ expect(line).toContain('signal');
52
+ expect(line).toContain('never');
53
+ expect(line).toContain('8 failed attempts since');
54
+ });
55
+
56
+ test('reports how stale a real success is, with the failures since it', () => {
57
+ const [line] = lastSuccessSummaryLines(
58
+ [{ moduleId: 'forgejo', lastSuccessAt: ago(7 * 24 * HOUR), consecutiveFailures: 20 }],
59
+ NOW,
60
+ );
61
+ // The existing relative formatter buckets 7 days as "last week".
62
+ expect(line).toContain('last week');
63
+ expect(line).toContain('20 failed attempts since');
64
+ });
65
+
66
+ test('a healthy module carries no failure clause at all', () => {
67
+ const [line] = lastSuccessSummaryLines(
68
+ [{ moduleId: 'authentik', lastSuccessAt: ago(3 * HOUR), consecutiveFailures: 0 }],
69
+ NOW,
70
+ );
71
+ expect(line).toContain('3h ago');
72
+ expect(line).not.toContain('failed');
73
+ });
74
+
75
+ test('counts one failure in the singular', () => {
76
+ const [line] = lastSuccessSummaryLines(
77
+ [{ moduleId: 'caddy', lastSuccessAt: ago(2 * HOUR), consecutiveFailures: 1 }],
78
+ NOW,
79
+ );
80
+ expect(line).toContain('1 failed attempt since');
81
+ expect(line).not.toContain('attempts');
82
+ });
83
+ });
@@ -3,7 +3,13 @@
3
3
  * Lists available backups with metadata.
4
4
  */
5
5
 
6
- import { formatSize, getBackup, listBackups } from '../../services/backup-metadata';
6
+ import type { Backup } from '../../db/schema';
7
+ import {
8
+ formatSize,
9
+ getBackup,
10
+ listBackups,
11
+ loadBackupHistory,
12
+ } from '../../services/backup-metadata';
7
13
  import { getBackupStorage } from '../../services/backup-storage';
8
14
  import { celiloIntro } from '../prompts';
9
15
  import type { CommandResult } from '../types';
@@ -12,8 +18,7 @@ import type { CommandResult } from '../types';
12
18
  * Format a timestamp as a human-friendly relative string.
13
19
  * Uses relative terms for recent backups and dates for older ones.
14
20
  */
15
- function formatRelativeDate(date: Date): string {
16
- const now = Date.now();
21
+ function formatRelativeDate(date: Date, now: number = Date.now()): string {
17
22
  const diffMs = now - date.getTime();
18
23
  const diffMins = Math.floor(diffMs / 60_000);
19
24
  const diffHours = Math.floor(diffMs / 3_600_000);
@@ -73,6 +78,64 @@ function showBackupDetail(backupId: string): CommandResult {
73
78
  return { success: true, message: `Backup detail: ${backupId}` };
74
79
  }
75
80
 
81
+ /**
82
+ * Say, per module, when its data was last actually captured.
83
+ *
84
+ * The listing above is a log of ATTEMPTS, and an attempt log is the one thing
85
+ * that cannot answer the question an operator is really asking. On celilo-mgr
86
+ * the forgejo rows read:
87
+ *
88
+ * ✗ 17cedba4 forgejo 0 B 1h ago
89
+ * ✗ 99a9f819 forgejo 0 B 2h ago
90
+ * ✗ fbb4d355 forgejo 0 B 3h ago
91
+ *
92
+ * — a tidy hourly cadence, every row on time, and not one byte of backup
93
+ * anywhere in it. Regular failure and regular success have the same silhouette
94
+ * at a glance; the `✗` is one character wide and the rhythm is what the eye
95
+ * reads. That listing was true for a day while production had no forgejo
96
+ * backup at all (celilo#685).
97
+ *
98
+ * So the recovery question gets answered directly rather than left to be
99
+ * inferred from a column of marks. `never` and a failure count are the two
100
+ * facts a column of attempts structurally cannot show.
101
+ *
102
+ * System backups are skipped: they are not per-module and have no cadence.
103
+ */
104
+ function printLastSuccessSummary(backupList: Backup[]): void {
105
+ const moduleIds = backedUpModuleIds(backupList);
106
+ if (moduleIds.length === 0) return;
107
+
108
+ const lines = lastSuccessSummaryLines(
109
+ moduleIds.map((moduleId) => ({ moduleId, ...loadBackupHistory(moduleId) })),
110
+ Date.now(),
111
+ );
112
+ console.log('\nLast successful backup:');
113
+ for (const line of lines) console.log(line);
114
+ }
115
+
116
+ /** The modules named by a listing, deduplicated. System rows have no module. */
117
+ export function backedUpModuleIds(backupList: Backup[]): string[] {
118
+ const ids = backupList
119
+ .filter((b) => b.backupType === 'module_data' && b.moduleId)
120
+ .map((b) => b.moduleId as string);
121
+ return [...new Set(ids)].sort();
122
+ }
123
+
124
+ /** One line per module: when it was last captured, and what has failed since. */
125
+ export function lastSuccessSummaryLines(
126
+ entries: Array<{ moduleId: string; lastSuccessAt: Date | null; consecutiveFailures: number }>,
127
+ now: number,
128
+ ): string[] {
129
+ return entries.map(({ moduleId, lastSuccessAt, consecutiveFailures }) => {
130
+ const when = lastSuccessAt ? formatRelativeDate(lastSuccessAt, now) : 'never';
131
+ const since =
132
+ consecutiveFailures > 0
133
+ ? ` (${consecutiveFailures} failed attempt${consecutiveFailures === 1 ? '' : 's'} since)`
134
+ : '';
135
+ return ` ${moduleId.padEnd(20)} ${when}${since}`;
136
+ });
137
+ }
138
+
76
139
  export async function handleBackupList(
77
140
  args: string[],
78
141
  flags: Record<string, boolean | string> = {},
@@ -120,6 +183,7 @@ export async function handleBackupList(
120
183
  }
121
184
 
122
185
  console.log(`\n${backupList.length} backup${backupList.length === 1 ? '' : 's'} shown.`);
186
+ printLastSuccessSummary(backupList);
123
187
  console.log('Run "celilo backup list <id>" for details.\n');
124
188
 
125
189
  return { success: true, message: `Found ${backupList.length} backup(s)` };
@@ -8,7 +8,12 @@ import { getDb } from '../../db/client';
8
8
  import { modules } from '../../db/schema';
9
9
  import type { ModuleManifest } from '../../manifest/schema';
10
10
  import { findBackupEligibleModules } from '../../services/backup-create';
11
- import { pruneBackupsForModule } from '../../services/backup-retention';
11
+ import {
12
+ effectiveBackupRetention,
13
+ pruneBackupsForModule,
14
+ prunesNothing,
15
+ } from '../../services/backup-retention';
16
+ import { loadModuleConfigs } from '../../services/module-config';
12
17
  import { celiloIntro, celiloOutro } from '../prompts';
13
18
  import type { CommandResult } from '../types';
14
19
 
@@ -37,17 +42,16 @@ export async function handleBackupPrune(
37
42
  }
38
43
 
39
44
  const manifest = mod.manifestData as unknown as ModuleManifest;
40
- const retention = manifest.backup?.retention;
41
- if (!retention) {
42
- console.log(`Module '${specificModule}' has no retention policy defined.`);
45
+ const policy = effectiveBackupRetention(manifest, loadModuleConfigs(db, specificModule));
46
+ if (prunesNothing(policy)) {
47
+ console.log(
48
+ `Module '${specificModule}' has no retention policy — every backup is kept.\n` +
49
+ `Set one with: celilo module config set ${specificModule} backup_retention_count <n>`,
50
+ );
43
51
  return { success: true, message: 'No retention policy' };
44
52
  }
45
53
 
46
- const result = await pruneBackupsForModule(
47
- specificModule,
48
- { count: retention.count, maxAgeDays: retention.max_age_days },
49
- dryRun,
50
- );
54
+ const result = await pruneBackupsForModule(specificModule, policy, dryRun);
51
55
 
52
56
  if (result.deleted > 0) {
53
57
  const verb = dryRun ? 'Would delete' : 'Deleted';
@@ -63,15 +67,11 @@ export async function handleBackupPrune(
63
67
  // Prune all modules with retention policies
64
68
  const eligible = findBackupEligibleModules();
65
69
 
66
- for (const { module: mod, manifest } of eligible) {
67
- const retention = manifest.backup?.retention;
68
- if (!retention) continue;
70
+ for (const { module: mod, manifest, configs } of eligible) {
71
+ const policy = effectiveBackupRetention(manifest, configs);
72
+ if (prunesNothing(policy)) continue;
69
73
 
70
- const result = await pruneBackupsForModule(
71
- mod.id,
72
- { count: retention.count, maxAgeDays: retention.max_age_days },
73
- dryRun,
74
- );
74
+ const result = await pruneBackupsForModule(mod.id, policy, dryRun);
75
75
 
76
76
  if (result.deleted > 0) {
77
77
  const verb = dryRun ? 'Would delete' : 'Deleted';
@@ -18,13 +18,19 @@ import {
18
18
  isBackupDue,
19
19
  } from '../../services/backup-create';
20
20
  import { failBackup, getBackup, listInProgressBackups } from '../../services/backup-metadata';
21
- import { pruneBackupsForModule } from '../../services/backup-retention';
21
+ import {
22
+ effectiveBackupRetention,
23
+ pruneBackupsForModule,
24
+ prunesNothing,
25
+ } from '../../services/backup-retention';
26
+ import { BACKUP_SCHEDULE_CONFIG_KEY } from '../../services/backup-schedule';
22
27
  import {
23
28
  STAGING_PREFIX,
24
29
  reapOrphanedStaging,
25
30
  resolveAbandonedBackups,
26
31
  } from '../../services/backup-staging';
27
32
  import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
33
+ import { configOverride } from '../../services/module-config';
28
34
  import { isPidRunnable } from '../../services/module-operations';
29
35
  import type { CommandResult } from '../types';
30
36
 
@@ -79,20 +85,26 @@ function resolveAbandonedRecords() {
79
85
  }
80
86
 
81
87
  export async function handleBackupSweep(): Promise<CommandResult> {
88
+ // Read once, so the cadence the sweep schedules on and the retention it
89
+ // prunes with come from the same snapshot of the operator's config.
90
+ const eligible = findBackupEligibleModules();
91
+ const configsByModule = new Map(eligible.map(({ module, configs }) => [module.id, configs]));
92
+
82
93
  const report = await runBackupSweep({
83
94
  reapStaging,
84
95
  resolveAbandonedRecords,
85
96
  listEligible: () =>
86
- findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
97
+ eligible.map(({ module, manifest, configs }) => ({
98
+ id: module.id,
99
+ manifest,
100
+ scheduleOverride: configOverride(configs, BACKUP_SCHEDULE_CONFIG_KEY),
101
+ })),
87
102
  isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
88
103
  backup: (moduleId) => createModuleBackup(moduleId),
89
104
  prune: async ({ id, manifest }) => {
90
- const retention = manifest.backup?.retention;
91
- if (!retention) return;
92
- await pruneBackupsForModule(id, {
93
- count: retention.count,
94
- maxAgeDays: retention.max_age_days,
95
- });
105
+ const policy = effectiveBackupRetention(manifest, configsByModule.get(id));
106
+ if (prunesNothing(policy)) return;
107
+ await pruneBackupsForModule(id, policy);
96
108
  },
97
109
  });
98
110
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * §8.2 — the classification, on demand.
3
+ *
4
+ * The value of this command is entirely in what it SAYS, so that is what these
5
+ * assert: the role of every interface, and — for anything unaccounted for —
6
+ * what the next converge will actually do about it. "1 alien interface" with no
7
+ * further guidance would repeat the failure this whole change is about, which
8
+ * was never a lack of information but a lack of celilo saying it.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ let testDir: string;
17
+ let logs: string[];
18
+ const originalLog = console.log;
19
+
20
+ /** Capture stdout — the command's output IS its contract. */
21
+ function captureLogs() {
22
+ logs = [];
23
+ console.log = (...args: unknown[]) => {
24
+ logs.push(args.map(String).join(' '));
25
+ };
26
+ }
27
+
28
+ beforeEach(() => {
29
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-fw-iface-'));
30
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
31
+ process.env.CELILO_DATA_DIR = testDir;
32
+ captureLogs();
33
+ });
34
+
35
+ afterEach(() => {
36
+ console.log = originalLog;
37
+ rmSync(testDir, { recursive: true, force: true });
38
+ process.env.CELILO_DB_PATH = undefined;
39
+ });
40
+
41
+ describe('celilo firewall interface list', () => {
42
+ test('an empty pool says so, and says what makes a machine a firewall', async () => {
43
+ const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
44
+ const result = await handleFirewallInterfaceList([], {});
45
+
46
+ expect(result.success).toBe(false);
47
+ if (result.success) throw new Error('unreachable — asserted above');
48
+ expect(result.error).toContain('No firewalls');
49
+ // Not a dead end: it explains the rule rather than leaving the operator to
50
+ // guess why their box is not listed.
51
+ expect(result.error).toContain('router');
52
+ });
53
+
54
+ test('a named machine that does not exist names it, and points at machine list', async () => {
55
+ const { handleFirewallInterfaceList } = await import('./firewall-interface-list');
56
+ const result = await handleFirewallInterfaceList(['no-such-box'], {});
57
+
58
+ expect(result.success).toBe(false);
59
+ if (result.success) throw new Error('unreachable — asserted above');
60
+ expect(result.error).toContain('no-such-box');
61
+ expect(result.error).toContain('celilo machine list');
62
+ });
63
+ });
64
+
65
+ /**
66
+ * The classification itself is `@celilo/capabilities`' job and is tested there
67
+ * against far more cases than a CLI test should duplicate.
68
+ *
69
+ * What is worth pinning here is STRUCTURAL. Asserting the wording of the output
70
+ * by grepping this command's own source would just be reading my strings back to
71
+ * me — it would pass no matter what they said. The property below is different:
72
+ * it is a claim about what the command may DO, and it can genuinely fail.
73
+ */
74
+ describe('read-only by construction', () => {
75
+ test('never touches the box', async () => {
76
+ // Safe to run against a firewall whose converge is currently refusing —
77
+ // which is exactly when an operator reaches for it. If this ever grows an
78
+ // SSH call it is a different command and should be named like one.
79
+ const src = await Bun.file(join(import.meta.dir, 'firewall-interface-list.ts')).text();
80
+
81
+ expect(src).not.toContain('runAppCommand');
82
+ expect(src).not.toContain('execFileSync');
83
+ expect(src).not.toContain('ssh ');
84
+ });
85
+ });