@celilo/cli 0.16.2 → 0.18.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.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +39 -9
- package/drizzle/0019_backup_pid.sql +18 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/schemas/system_config.json +1 -1
- package/src/cli/command-tree-parser.ts +0 -1
- package/src/cli/commands/alerts-poll.ts +26 -1
- package/src/cli/commands/backup-sweep.ts +62 -0
- package/src/cli/commands/module-operations.test.ts +45 -1
- package/src/cli/commands/module-operations.ts +35 -12
- package/src/cli/commands/module-show.ts +1 -0
- package/src/cli/commands/storage-set-path.test.ts +281 -0
- package/src/cli/commands/storage-set-path.ts +190 -0
- package/src/cli/commands/system-audit.ts +14 -0
- package/src/cli/commands/system-migrate.ts +40 -0
- package/src/cli/commands/system-update.ts +6 -0
- package/src/cli/completion.ts +24 -3
- package/src/cli/fuel-gauge.ts +0 -1
- package/src/cli/generate-zsh-completion.ts +1 -1
- package/src/cli/index.ts +12 -0
- package/src/cli/tui/audit-state.test.ts +15 -1
- package/src/cli/tui/audit-state.ts +6 -0
- package/src/cli/tui/audit-tui.test.tsx +0 -1
- package/src/db/schema.ts +53 -9
- package/src/hooks/capability-loader.ts +30 -1
- package/src/ipam/allocator.ts +13 -3
- package/src/services/alerting/builtin-monitors.test.ts +42 -0
- package/src/services/alerting/builtin-monitors.ts +3 -0
- package/src/services/alerting/builtin-source.ts +15 -0
- package/src/services/alerting/inbound-poller.test.ts +63 -1
- package/src/services/alerting/inbound-poller.ts +42 -0
- package/src/services/alerting/read-records.ts +85 -0
- package/src/services/audit/abandoned-operations.test.ts +73 -0
- package/src/services/audit/abandoned-operations.ts +0 -0
- package/src/services/audit/disk-space.test.ts +111 -0
- package/src/services/audit/disk-space.ts +114 -0
- package/src/services/audit/index.test.ts +2 -0
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/transport-reads.test.ts +113 -0
- package/src/services/audit/transport-reads.ts +120 -0
- package/src/services/audit/types.ts +3 -0
- package/src/services/backup-create.ts +4 -4
- package/src/services/backup-in-flight-refusal.test.ts +2 -0
- package/src/services/backup-metadata.ts +4 -0
- package/src/services/backup-staging.test.ts +134 -0
- package/src/services/backup-staging.ts +192 -0
- package/src/services/backup-storage.ts +29 -0
- package/src/services/backup-sweep.test.ts +68 -0
- package/src/services/backup-sweep.ts +62 -0
- package/src/services/config-interview.ts +1 -1
- package/src/services/deploy-ansible.ts +0 -1
- package/src/services/disk-probe.test.ts +74 -0
- package/src/services/disk-probe.ts +145 -0
- package/src/services/fleet-checks.ts +15 -0
- package/src/services/module-operations.test.ts +22 -0
- package/src/services/module-operations.ts +48 -1
- package/src/services/module-subscriptions.test.ts +39 -6
- package/src/services/module-subscriptions.ts +6 -4
- package/src/services/module-types-generator.test.ts +6 -3
- package/src/services/module-types-generator.ts +12 -7
- package/src/services/storage-providers/local.ts +2 -1
- package/src/services/update/orchestrator.test.ts +2 -0
- package/src/variables/context.ts +6 -1
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -26,7 +26,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
26
26
|
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
|
|
27
27
|
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
|
|
28
28
|
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). **provides:** `dns_registrar`.
|
|
29
|
-
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does, `greenwave` does not).
|
|
29
|
+
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. Records `network.control-plane-vpn.subnet`, which the internal resolver's split-horizon view also consumes. Adopts a running tunnel in place (existing key and peers retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does, `greenwave` does not).
|
|
30
30
|
|
|
31
31
|
## Public edge (ingress / identity)
|
|
32
32
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -92,7 +92,7 @@ Module hooks reach a remote box ONLY through these typed primitives
|
|
|
92
92
|
- **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
|
|
93
93
|
- **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
|
|
94
94
|
- **`waitFor`** — predicate-poll combinator.
|
|
95
|
-
- **`applyRenderedConfig`** — converge: write rendered config → validate → apply → rollback (one round-trip). Used by caddy (Caddyfile), knot (views), iptables (ruleset).
|
|
95
|
+
- **`applyRenderedConfig`** — converge: write rendered config → validate → apply → rollback (one round-trip). Used by caddy (Caddyfile), knot (views), iptables (ruleset). On success it RETAINS the file it replaced as `<path>.celilo-prev` — the durable "what celilo last rendered" record a whole-file converge diffs against.
|
|
96
96
|
- **`installAuthorizedKey`** — ONE-TIME credential bootstrap for an off-fleet account: `ssh-copy-id` under a password taken from the child ENV (`SSH_ASKPASS_REQUIRE=force`, so no `sshpass` dependency and the password never lands in a command string). Idempotent; used by `external_web` onboarding so every later publish is key-based.
|
|
97
97
|
- **`tailLog` / `grepLog`** — journald reads (regex / ignoreCase).
|
|
98
98
|
|
|
@@ -112,10 +112,34 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
112
112
|
(`renderRuleset`): registry + firewall state → a complete `iptables-restore`
|
|
113
113
|
file. Default-DROP FORWARD + established/related + egress + the coarse
|
|
114
114
|
zone-tier matrix (dmz→app, app→secure from `network.<zone>.subnet`) + per-service DNAT allows.
|
|
115
|
+
Egress permission and egress TRANSLATION are emitted together or neither: a
|
|
116
|
+
blanket `-o <wan>` MASQUERADE on a leaf, one `-s <subnet> -o <uplink>` rule per
|
|
117
|
+
network on a DOWNSTREAM firewall (no external edge). An upstream does not
|
|
118
|
+
translate on a downstream firewall's behalf.
|
|
119
|
+
`subnetsNeedingTranslation(state)` is that set — EVERY network behind the
|
|
120
|
+
firewall (tiers ∪ every declared zone subnet ∪ control plane ∪ registered
|
|
121
|
+
trusted sources) minus whichever of them CONTAINS the egress interface's own
|
|
122
|
+
address (`egressIp`, read off the box). Not just the data-plane tiers: a
|
|
123
|
+
downstream firewall's egress leg IS its LAN leg, so an untranslated network is
|
|
124
|
+
unreachable from a LAN host as well as from the internet.
|
|
115
125
|
- **Converge** — `modules/iptables/scripts/firewall-functions.ts` (`converge`):
|
|
116
|
-
`exposeService`/`unexposeService` register into the store, then render
|
|
117
|
-
atomically via `applyRenderedConfig`
|
|
118
|
-
|
|
126
|
+
`exposeService`/`unexposeService` register into the store, then render, CHECK,
|
|
127
|
+
DIFF, and only then apply atomically via `applyRenderedConfig`
|
|
128
|
+
(`iptables-restore`). Replaces the old per-rule `iptables -A`; the registry
|
|
129
|
+
(not `iptables -L`) is the source of truth. It does NOT apply unconditionally:
|
|
130
|
+
a failing check or an unexplainable removal throws, leaving the working
|
|
131
|
+
ruleset in place (`config.force` overrides, on the record).
|
|
132
|
+
- **Render-time completeness checks (pure)** — `modules/iptables/scripts/ruleset-checks.ts`
|
|
133
|
+
(`checkRenderedSet`, `blockingFindings`, `formatConvergeRefusal`): the SEMANTIC
|
|
134
|
+
counterpart to `iptables-restore --test`, which only validates syntax. Reports
|
|
135
|
+
a zone permitted to egress with no translation covering it, a control plane
|
|
136
|
+
that cannot reach a zone it manages, and an unknown control plane (a warning,
|
|
137
|
+
distinct from "known and absent"). Runs under `config.dryRun` too.
|
|
138
|
+
- **Pre-apply ruleset diff (pure)** — `modules/iptables/scripts/ruleset-diff.ts`
|
|
139
|
+
(`parseRuleset`, `diffRuleset`, `driftAgainstLive`, `snapshotCommand`,
|
|
140
|
+
`parseSnapshot`): what the converge would remove, and whether a registry change
|
|
141
|
+
explains it. Exact set algebra against the retained previous render, not a
|
|
142
|
+
heuristic. Live-vs-persisted drift is reported, never decided on.
|
|
119
143
|
- **Trusted-source registry (desired state)** — `trusted_sources` DB table
|
|
120
144
|
(migration `0016`) + `apps/celilo/src/services/trusted-sources.ts`
|
|
121
145
|
(`buildTrustedSourceStore`, `composeTrustedSubnets`). The sibling primitive to
|
|
@@ -144,7 +168,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
144
168
|
- **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
|
|
145
169
|
- **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
|
|
146
170
|
- **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`).
|
|
171
|
+
- **In-flight operation lock** — `apps/celilo/src/services/module-operations.ts` — `startOperation`/`completeOperation`/`failOperation` record deploy/uninstall/backup/restore in `module_operations`; `refuseIfInFlight`/`checkInFlight` are what backup and restore consult. Deploy and uninstall REGISTER but never check: it is a one-way guard protecting backup/restore consistency, not a general mutex (`openspec/specs/management-server-backup/spec.md` "In-flight operation refusal"). A row stops holding the lock once it is GONE, STOPPED/zombie (`isPidRunnable`, `ps -o state=` — `kill(pid,0)` calls a Ctrl-Z'd process alive), or older than `OPERATION_TTL_MS` (2h). The TTL is not redundancy: a pid is a recycled number, and once the pid space wraps an old row names an unrelated healthy process. Operator surface: `celilo module operations [list|clear] [--abandoned] [--all]` (`apps/celilo/src/cli/commands/module-operations.ts`); `list` shows only what holds the lock, abandoned rows are summarised unless `--abandoned`. Abandoned rows are reclaimed hourly by the `celilo-operations-sweep` bus subscriber (`timer.tick.1h` → `celilo module operations clear`, armed by `ensureOperationsSweepSubscriber` from module registration and `celilo system migrate`). `clear` MARKS rows failed rather than deleting them, and that is load-bearing: the `abandoned_operations` audit reads exactly those released rows to notice one module's operation dying over and over.
|
|
148
172
|
- **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.
|
|
149
173
|
|
|
150
174
|
## Generation & templating
|
|
@@ -174,7 +198,8 @@ is currently wrong, and routes carry the message to a person's phone. Design:
|
|
|
174
198
|
- **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`.
|
|
175
199
|
- **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.
|
|
176
200
|
- **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 `
|
|
201
|
+
- **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`, and `abandoned_operations` (`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.
|
|
202
|
+
- **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.
|
|
178
203
|
- **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.
|
|
179
204
|
- **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).
|
|
180
205
|
- **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.
|
|
@@ -194,9 +219,12 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
|
|
|
194
219
|
|
|
195
220
|
- **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
221
|
- **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
|
|
222
|
+
- **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.
|
|
223
|
+
- **⚠️ 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.
|
|
224
|
+
- **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.
|
|
198
225
|
- **Freshness audit** — `apps/celilo/src/services/audit/backups.ts` (`auditBackups`) — `backup_missing` / `backup_stale` drift findings against the same declared cadence.
|
|
199
226
|
- **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`.
|
|
227
|
+
- **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).
|
|
200
228
|
|
|
201
229
|
## Persistence
|
|
202
230
|
|
|
@@ -204,7 +232,7 @@ Creation, scheduling and freshness. A module declares an `on_backup` hook and a
|
|
|
204
232
|
|
|
205
233
|
## Events
|
|
206
234
|
|
|
207
|
-
- **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`).
|
|
235
|
+
- **Event bus** — `packages/event-bus/src/index.ts` — `Bus`, `openBus`, `defineEvents`, `defineHandler`, `runDispatcher`, pattern matching + timer ticks (`emitDueTimerTicks`, `retentionSweep`). **Exactly one dispatcher per bus**: `runDispatcher` refuses to start while another dispatcher's process is alive (`assertSoleDispatcher` in `dispatcher.ts`, liveness via `bus.liveDispatchers()` — `kill(pid,0)`, not heartbeat age, since a tick blocks for as long as its slowest handler). The exclusion is here rather than in the systemd unit because a stranded dispatcher can sit outside the unit's cgroup where `KillMode` cannot reach it (#580). `bus.health()` reports `dispatcherCount`/`dispatchers` and a `duplicate_dispatcher` status; `checkDispatcher` (`services/fleet-checks.ts`) fails on more than one.
|
|
208
236
|
|
|
209
237
|
## Remote API (drive the CLI over the wire)
|
|
210
238
|
|
|
@@ -218,7 +246,8 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
218
246
|
- **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
|
|
219
247
|
- **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
|
|
220
248
|
- **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
|
|
221
|
-
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`.
|
|
249
|
+
- **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100). It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
|
|
250
|
+
- **Module auto-upgrade (registry-poll CD)** — the *pull* half of continuous deployment: celilo-mgr polls the registry and upgrades opted-in modules unattended. Spec: `openspec/specs/module-auto-upgrade/spec.md`. Entry points: `apps/celilo/src/cli/commands/module-upgrade.ts` — `runRegistryPoll` (the `--poll` path), `selectPollTargets` (pure: `autoUpgrade && latest && change ∉ {up-to-date, ahead}`), `upgradeOneModule` (update → backup → deploy → verify), `needsPreUpgradeBackup`, `pickAutoUpgrade`/`pickUpgradePolicy` (both fail closed/safe); `classifyVersionChange` in `module-update.ts` (treats a registry `+N` revision as a patch); `resolveDeployPosture` in `apps/celilo/src/services/deploy-posture.ts`. Trigger: celilo-mgmt's `registry-poll` subscription (`modules/celilo-mgmt/manifest.yml`) on `timer.tick.15m` with handler **`celilo module upgrade --poll`** — the flag is REQUIRED, since the dispatcher appends the event id positionally and a bare handler would consume it as the optional module name (silent: 3108 deliveries, 0 successes). Operator controls are framework config keys settable on ANY module (`FRAMEWORK_CONFIG_KEYS` in `module-config.ts`): `auto_upgrade` (opt-in, default false) and `upgrade_policy` (`by-semver`|`always-safe`|`always-fast`), validated at set time because both readers fail open. ⚠️ `always-safe` guarantees safe *posture*, NOT a backup — `needsPreUpgradeBackup` also requires the TARGET manifest to declare an `on_backup` hook, else it warns and proceeds. Confirm a data-bearing module declares `on_backup` before enabling `auto_upgrade` on it. The *build* half (app CI publishing a `.netapp` on merge) is not yet shipped — `openspec/changes/build-bus-poll-cd`.
|
|
222
251
|
- **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `openspec/changes/celilo-mcp-service/proposal.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
|
|
223
252
|
|
|
224
253
|
## E2E simulation
|
|
@@ -227,4 +256,5 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
|
|
|
227
256
|
- **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.
|
|
228
257
|
- **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
258
|
- **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.
|
|
259
|
+
- **Simulated address plan** — `packages/e2e/src/types.ts` — `SIM_PRIVATE_SUPERNET` (`10.226.0.0/16`), `zoneIp(zone, host)`, `ZONE_SUBNETS`, `ZONE_GATEWAYS`. Every simulated PRIVATE zone is derived from this one table; the compose generator, `zone-classifier.ts` and the harness's `system init` all read it, so renumbering the whole sim is a one-line change. The sim deliberately does NOT reuse a real fleet's zone /24s (#539): the previous plan was byte-identical to production's, so a stack leaked on celilo's own forgejo-builder — which lives in the real dmz — claimed the builder's own subnet and blackholed every containerized CI job's route to the forge for ~135s. Teardown cannot prevent that (a SIGKILL runs no handler), so the addresses moved instead; the second, better reason is that a suite which only passes on production's exact octets is asserting one site's address plan rather than celilo's behaviour. `src/address-plan.test.ts` is the recurrence gate — it fails if a zone leaves the supernet, or if any retired fleet prefix reappears anywhere in `packages/e2e`, `e2e/tests` or `modules/*/e2e`.
|
|
230
260
|
- **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,18 @@
|
|
|
1
|
+
-- Record which process owns a backup's staging directory.
|
|
2
|
+
--
|
|
3
|
+
-- `backup-create.ts` assembles every envelope under `/tmp/celilo-backup-<id>`
|
|
4
|
+
-- and removes it in a `finally`. A `finally` does not run when the process is
|
|
5
|
+
-- killed by a signal — a dispatcher timeout, an OOM, a reboot — and those are
|
|
6
|
+
-- exactly the cases that strand the largest directories. On celilo-mgr the
|
|
7
|
+
-- scheduled sweep was killed at 60s against a backup needing ~5.5 minutes,
|
|
8
|
+
-- three times an hour, stranding 27 GB in under six hours.
|
|
9
|
+
--
|
|
10
|
+
-- Reclamation therefore cannot depend on how a backup ends. The staging reaper
|
|
11
|
+
-- (services/backup-staging.ts) asks "is anyone still using this directory?"
|
|
12
|
+
-- instead, and this column is what lets it answer: the directory names its
|
|
13
|
+
-- record, and the record names its process.
|
|
14
|
+
--
|
|
15
|
+
-- Nullable on purpose. Rows written before this column exists have no pid; the
|
|
16
|
+
-- reaper keeps their staging until the TTL expires rather than guessing.
|
|
17
|
+
|
|
18
|
+
ALTER TABLE `backups` ADD `pid` integer;
|
|
@@ -134,6 +134,13 @@
|
|
|
134
134
|
"when": 1783400000000,
|
|
135
135
|
"tag": "0018_drop_alert_policy_snapshot",
|
|
136
136
|
"breakpoints": true
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"idx": 19,
|
|
140
|
+
"version": "6",
|
|
141
|
+
"when": 1783500000000,
|
|
142
|
+
"tag": "0019_backup_pid",
|
|
143
|
+
"breakpoints": true
|
|
137
144
|
}
|
|
138
145
|
]
|
|
139
146
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
60
|
-
"@celilo/capabilities": "^0.
|
|
61
|
-
"@celilo/cli-display": "^0.1.
|
|
62
|
-
"@celilo/core": "^0.3.
|
|
63
|
-
"@celilo/event-bus": "^0.
|
|
60
|
+
"@celilo/capabilities": "^0.10.0",
|
|
61
|
+
"@celilo/cli-display": "^0.1.10",
|
|
62
|
+
"@celilo/core": "^0.3.2",
|
|
63
|
+
"@celilo/event-bus": "^0.2.0",
|
|
64
64
|
"@clack/prompts": "^1.1.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"maximum": 4094,
|
|
75
75
|
"description": "VLAN tag for internal zone (not defaulted; internal is untagged)"
|
|
76
76
|
},
|
|
77
|
-
"network.vpn.subnet": {
|
|
77
|
+
"network.control-plane-vpn.subnet": {
|
|
78
78
|
"type": "string",
|
|
79
79
|
"pattern": "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$",
|
|
80
80
|
"description": "WireGuard VPN client subnet CIDR (remote-access tunnel; consumed by technitium's split-horizon → in-zone caddy view, #275, and by the firewall's trusted sources once the wireguard module registers it)"
|
|
@@ -163,7 +163,6 @@ export class CommandTreeParser {
|
|
|
163
163
|
* Strip ANSI color codes from text
|
|
164
164
|
*/
|
|
165
165
|
private stripAnsiCodes(text: string): string {
|
|
166
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
|
|
167
166
|
return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
168
167
|
}
|
|
169
168
|
|
|
@@ -17,6 +17,7 @@ import { systemConfig } from '../../db/schema';
|
|
|
17
17
|
import { makeReceiver, pollInbound } from '../../services/alerting/inbound-poller';
|
|
18
18
|
import { startNotificationResponder } from '../../services/alerting/notification-responder';
|
|
19
19
|
import { listRoutes } from '../../services/alerting/people';
|
|
20
|
+
import { readLastRead, writeLastRead } from '../../services/alerting/read-records';
|
|
20
21
|
import { loadNotificationTransport } from '../../services/alerting/transport-loader';
|
|
21
22
|
import type { CommandResult } from '../types';
|
|
22
23
|
|
|
@@ -52,6 +53,26 @@ function writeCursor(
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* " (last read OK 3h ago)" or " (never read successfully)".
|
|
58
|
+
*
|
|
59
|
+
* Appended to a read failure because the failure alone does not say how bad it
|
|
60
|
+
* is. One failed poll is a blip; a transport that has not been readable since
|
|
61
|
+
* Tuesday is an outage nobody was told about, and those two produced identical
|
|
62
|
+
* output until this record existed.
|
|
63
|
+
*/
|
|
64
|
+
function sinceLastSuccess(db: ReturnType<typeof getDb>, transportModuleId: string): string {
|
|
65
|
+
const last = readLastRead(db, transportModuleId)?.lastSuccessAt;
|
|
66
|
+
if (!last) return ' (never read successfully)';
|
|
67
|
+
const ms = Date.now() - new Date(last).getTime();
|
|
68
|
+
const mins = Math.floor(ms / 60_000);
|
|
69
|
+
if (mins < 60) return ` (last read OK ${mins}m ago)`;
|
|
70
|
+
const hours = Math.floor(mins / 60);
|
|
71
|
+
return hours < 48
|
|
72
|
+
? ` (last read OK ${hours}h ago)`
|
|
73
|
+
: ` (last read OK ${Math.floor(hours / 24)}d ago)`;
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
export async function handleAlertsPoll(
|
|
56
77
|
flags: Record<string, string | boolean> = {},
|
|
57
78
|
): Promise<CommandResult> {
|
|
@@ -97,6 +118,7 @@ export async function handleAlertsPoll(
|
|
|
97
118
|
receiveFrom: makeReceiver(db),
|
|
98
119
|
readCursor: (t) => readCursor(db, t),
|
|
99
120
|
writeCursor: (t, c) => writeCursor(db, t, c),
|
|
121
|
+
recordRead: (t, r) => writeLastRead(db, t, r),
|
|
100
122
|
now: () => new Date(),
|
|
101
123
|
transportFor: (route) => loadNotificationTransport(db, route.transportModuleId),
|
|
102
124
|
answerInterview: (eventId, value) => responder.answer(eventId, value),
|
|
@@ -122,7 +144,10 @@ export async function handleAlertsPoll(
|
|
|
122
144
|
// an operator has no way to tell which — the ambiguity that made the Signal
|
|
123
145
|
// ack path take a week to diagnose.
|
|
124
146
|
const detail = [
|
|
125
|
-
...report.failures.map(
|
|
147
|
+
...report.failures.map(
|
|
148
|
+
(f) =>
|
|
149
|
+
`${f.transportModuleId} COULD NOT BE READ: ${f.error}${sinceLastSuccess(db, f.transportModuleId)}`,
|
|
150
|
+
),
|
|
126
151
|
...report.unheard.map((u) => `not heard from ${u.senderAddress}: ${u.reason}`),
|
|
127
152
|
...askErrors,
|
|
128
153
|
];
|
|
@@ -9,17 +9,68 @@
|
|
|
9
9
|
* report counts.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { readdirSync, rmSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
12
15
|
import {
|
|
13
16
|
createModuleBackup,
|
|
14
17
|
findBackupEligibleModules,
|
|
15
18
|
isBackupDue,
|
|
16
19
|
} from '../../services/backup-create';
|
|
20
|
+
import { failBackup, getBackup } from '../../services/backup-metadata';
|
|
17
21
|
import { pruneBackupsForModule } from '../../services/backup-retention';
|
|
22
|
+
import {
|
|
23
|
+
ABANDONED_BACKUP_MESSAGE,
|
|
24
|
+
STAGING_PREFIX,
|
|
25
|
+
impliesAbandonedRecord,
|
|
26
|
+
reapOrphanedStaging,
|
|
27
|
+
} from '../../services/backup-staging';
|
|
18
28
|
import { type BackupSweepReport, runBackupSweep } from '../../services/backup-sweep';
|
|
29
|
+
import { isPidRunnable } from '../../services/module-operations';
|
|
19
30
|
import type { CommandResult } from '../types';
|
|
20
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Reclaim orphaned staging, then correct the records that were still claiming
|
|
34
|
+
* to be running.
|
|
35
|
+
*
|
|
36
|
+
* The record fix-up lives here rather than inside the reaper because the reaper
|
|
37
|
+
* is the filesystem decision and this is a database write; both follow from the
|
|
38
|
+
* one liveness lookup, so neither repeats it.
|
|
39
|
+
*/
|
|
40
|
+
function reapStaging() {
|
|
41
|
+
const report = reapOrphanedStaging({
|
|
42
|
+
listStagingDirs: () => {
|
|
43
|
+
// A missing or unreadable temp dir is not a reason to fail the sweep.
|
|
44
|
+
try {
|
|
45
|
+
return readdirSync(tmpdir())
|
|
46
|
+
.filter((name) => name.startsWith(STAGING_PREFIX))
|
|
47
|
+
.map((name) => join(tmpdir(), name));
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
lookupOwner: (recordId) => {
|
|
53
|
+
const record = getBackup(recordId);
|
|
54
|
+
if (!record) return null;
|
|
55
|
+
return { status: record.status, pid: record.pid, startedAt: record.startedAt };
|
|
56
|
+
},
|
|
57
|
+
isPidRunnable,
|
|
58
|
+
remove: (path) => rmSync(path, { recursive: true, force: true }),
|
|
59
|
+
now: () => Date.now(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
for (const reclaimed of report.reclaimed) {
|
|
63
|
+
if (impliesAbandonedRecord(reclaimed.reason)) {
|
|
64
|
+
failBackup(reclaimed.recordId, ABANDONED_BACKUP_MESSAGE);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return report;
|
|
69
|
+
}
|
|
70
|
+
|
|
21
71
|
export async function handleBackupSweep(): Promise<CommandResult> {
|
|
22
72
|
const report = await runBackupSweep({
|
|
73
|
+
reapStaging,
|
|
23
74
|
listEligible: () =>
|
|
24
75
|
findBackupEligibleModules().map(({ module, manifest }) => ({ id: module.id, manifest })),
|
|
25
76
|
isDue: (moduleId, schedule) => isBackupDue(moduleId, schedule),
|
|
@@ -55,6 +106,17 @@ function formatReport(report: BackupSweepReport): string {
|
|
|
55
106
|
if (report.failures.length > 0) parts.push(`${report.failures.length} FAILED`);
|
|
56
107
|
|
|
57
108
|
const lines = [`backup sweep: ${parts.join(', ')}`];
|
|
109
|
+
// Reclamation is reported even though it is housekeeping: it is the only
|
|
110
|
+
// visible evidence that backups have been dying, and a silent reaper would
|
|
111
|
+
// hide the very failure it exists to clean up after.
|
|
112
|
+
if (report.staging.reclaimed.length > 0) {
|
|
113
|
+
lines.push(
|
|
114
|
+
` reclaimed ${report.staging.reclaimed.length} orphaned staging dir(s) from backups that were killed before cleanup`,
|
|
115
|
+
);
|
|
116
|
+
for (const reclaimed of report.staging.reclaimed) {
|
|
117
|
+
lines.push(` ${reclaimed.recordId} (${reclaimed.reason})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
58
120
|
for (const moduleId of report.skippedLocked) {
|
|
59
121
|
lines.push(` skipped ${moduleId}: another operation is in flight — retrying next tick`);
|
|
60
122
|
}
|
|
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
|
|
6
6
|
import { closeDb, getDb } from '../../db/client';
|
|
7
7
|
import { runMigrations } from '../../db/migrate';
|
|
8
8
|
import { moduleOperations } from '../../db/schema';
|
|
9
|
-
import { OPERATION_TTL_MS } from '../../services/module-operations';
|
|
9
|
+
import { ABANDONED_RELEASE_MESSAGE, OPERATION_TTL_MS } from '../../services/module-operations';
|
|
10
10
|
import { handleModuleOperations } from './module-operations';
|
|
11
11
|
|
|
12
12
|
describe('celilo module operations', () => {
|
|
@@ -85,6 +85,50 @@ describe('celilo module operations', () => {
|
|
|
85
85
|
expect(statusOf('expired')).toBe('in_progress');
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
// The recurrence gate for #581: the sweep runs on every hourly tick, so an
|
|
89
|
+
// abandoned row must be reclaimed once and then stop being work. Before
|
|
90
|
+
// this, rows only ever accumulated — 85 of them, the oldest 62 days old.
|
|
91
|
+
it('reclaims an abandoned row and does not re-collect it on the next sweep', () => {
|
|
92
|
+
insert('expired', process.pid, OPERATION_TTL_MS + 60_000);
|
|
93
|
+
|
|
94
|
+
const first = handleModuleOperations(['clear'], {});
|
|
95
|
+
if (!first.success) throw new Error(`expected success, got: ${first.error}`);
|
|
96
|
+
expect(first.message).toContain('Released 1');
|
|
97
|
+
expect(statusOf('expired')).toBe('failed');
|
|
98
|
+
|
|
99
|
+
const second = handleModuleOperations(['clear'], {});
|
|
100
|
+
if (!second.success) throw new Error(`expected success, got: ${second.error}`);
|
|
101
|
+
expect(second.message).toContain('no operations in progress');
|
|
102
|
+
|
|
103
|
+
// Reclaimed, not deleted: the released row is the evidence the
|
|
104
|
+
// abandoned-operations audit reads.
|
|
105
|
+
const row = getDb()
|
|
106
|
+
.select()
|
|
107
|
+
.from(moduleOperations)
|
|
108
|
+
.where(eq(moduleOperations.id, 'expired'))
|
|
109
|
+
.get();
|
|
110
|
+
expect(row?.errorMessage).toBe(ABANDONED_RELEASE_MESSAGE);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('hides abandoned rows from list by default and shows them with --abandoned', () => {
|
|
114
|
+
insert('expired', process.pid, OPERATION_TTL_MS + 60_000);
|
|
115
|
+
|
|
116
|
+
const lines: string[] = [];
|
|
117
|
+
const original = console.log;
|
|
118
|
+
console.log = (msg?: unknown) => lines.push(String(msg));
|
|
119
|
+
try {
|
|
120
|
+
handleModuleOperations(['list'], {});
|
|
121
|
+
expect(lines.join('\n')).not.toContain('pid');
|
|
122
|
+
expect(lines.join('\n')).toContain('1 abandoned row(s) hidden');
|
|
123
|
+
|
|
124
|
+
lines.length = 0;
|
|
125
|
+
handleModuleOperations(['list'], { abandoned: true });
|
|
126
|
+
expect(lines.join('\n')).toContain('abandoned (expired)');
|
|
127
|
+
} finally {
|
|
128
|
+
console.log = original;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
88
132
|
it('rejects an unknown action rather than silently listing', () => {
|
|
89
133
|
const result = handleModuleOperations(['nuke'], {});
|
|
90
134
|
if (result.success) throw new Error('expected an unknown action to fail');
|
|
@@ -10,12 +10,22 @@
|
|
|
10
10
|
*
|
|
11
11
|
* `clear` marks rows failed rather than deleting them — the history of
|
|
12
12
|
* what was abandoned, and when, is worth more than a tidy table.
|
|
13
|
+
*
|
|
14
|
+
* That is load-bearing now, not merely tidy: `clear` runs hourly off the
|
|
15
|
+
* bus, and `services/audit/abandoned-operations.ts` reads exactly these
|
|
16
|
+
* released rows to notice that one module's backup is being killed over
|
|
17
|
+
* and over. Turning this into a DELETE would tidy the table and silently
|
|
18
|
+
* destroy the only signal that a repeatedly-dying operation ever leaves.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import { and, eq } from 'drizzle-orm';
|
|
16
22
|
import { getDb } from '../../db/client';
|
|
17
23
|
import { type ModuleOperation, moduleOperations } from '../../db/schema';
|
|
18
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
ABANDONED_RELEASE_MESSAGE,
|
|
26
|
+
OPERATION_TTL_MS,
|
|
27
|
+
isPidRunnable,
|
|
28
|
+
} from '../../services/module-operations';
|
|
19
29
|
import type { CommandResult } from '../types';
|
|
20
30
|
|
|
21
31
|
/** Why a row is not holding the lock, or null when it still is. */
|
|
@@ -41,36 +51,49 @@ function inProgressRows(): ModuleOperation[] {
|
|
|
41
51
|
.all();
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Abandoned rows are summarised, not listed, unless `--abandoned` asks for
|
|
56
|
+
* them. The question this command answers is "what holds the lock right
|
|
57
|
+
* now", and on a fleet where something is dying repeatedly the answer was
|
|
58
|
+
* buried under 85 corpses. The count still prints, so they never become
|
|
59
|
+
* invisible — the audit is what reads them as a symptom
|
|
60
|
+
* (`services/audit/abandoned-operations.ts`).
|
|
61
|
+
*/
|
|
62
|
+
function handleList(flags: Record<string, boolean | string>): CommandResult {
|
|
45
63
|
const now = Date.now();
|
|
46
64
|
const rows = inProgressRows();
|
|
65
|
+
const showAbandoned = flags.abandoned === true || flags.all === true;
|
|
47
66
|
|
|
48
67
|
if (rows.length === 0) {
|
|
49
68
|
console.log('\nNo module operations in progress.\n');
|
|
50
69
|
return { success: true, message: 'no operations in progress' };
|
|
51
70
|
}
|
|
52
71
|
|
|
72
|
+
const holding = rows.filter((row) => abandonedReason(row, now) === null);
|
|
73
|
+
const abandoned = rows.length - holding.length;
|
|
74
|
+
const shown = showAbandoned ? rows : holding;
|
|
75
|
+
|
|
53
76
|
console.log('\nModule operations in progress:\n');
|
|
54
|
-
|
|
55
|
-
for (const row of rows) {
|
|
77
|
+
for (const row of shown) {
|
|
56
78
|
const reason = abandonedReason(row, now);
|
|
57
|
-
if (!reason) holding++;
|
|
58
79
|
const age = formatAge(now - row.startedAt.getTime());
|
|
59
80
|
const status = reason ? `abandoned (${reason})` : 'HOLDING LOCK';
|
|
60
81
|
console.log(
|
|
61
82
|
` ${row.operation.padEnd(9)} ${row.moduleId.padEnd(16)} pid ${String(row.pid).padEnd(8)} ${age.padStart(4)} ago ${status}`,
|
|
62
83
|
);
|
|
63
84
|
}
|
|
85
|
+
if (shown.length === 0) console.log(' (nothing is holding the lock)');
|
|
64
86
|
|
|
65
|
-
const abandoned = rows.length - holding;
|
|
66
87
|
console.log('');
|
|
67
|
-
if (abandoned > 0) {
|
|
68
|
-
console.log(
|
|
88
|
+
if (abandoned > 0 && !showAbandoned) {
|
|
89
|
+
console.log(
|
|
90
|
+
`${abandoned} abandoned row(s) hidden — "--abandoned" lists them, the hourly sweep clears them.\n`,
|
|
91
|
+
);
|
|
69
92
|
}
|
|
70
93
|
|
|
71
94
|
return {
|
|
72
95
|
success: true,
|
|
73
|
-
message: `${rows.length} in progress (${holding} holding the lock, ${abandoned} abandoned)`,
|
|
96
|
+
message: `${rows.length} in progress (${holding.length} holding the lock, ${abandoned} abandoned)`,
|
|
74
97
|
};
|
|
75
98
|
}
|
|
76
99
|
|
|
@@ -108,7 +131,7 @@ function handleClear(flags: Record<string, boolean | string>): CommandResult {
|
|
|
108
131
|
.set({
|
|
109
132
|
status: 'failed',
|
|
110
133
|
completedAt: new Date(),
|
|
111
|
-
errorMessage:
|
|
134
|
+
errorMessage: ABANDONED_RELEASE_MESSAGE,
|
|
112
135
|
})
|
|
113
136
|
.where(and(eq(moduleOperations.id, row.id), eq(moduleOperations.status, 'in_progress')))
|
|
114
137
|
.run();
|
|
@@ -124,11 +147,11 @@ export function handleModuleOperations(
|
|
|
124
147
|
): CommandResult {
|
|
125
148
|
const action = args[0];
|
|
126
149
|
|
|
127
|
-
if (!action || action === 'list') return handleList();
|
|
150
|
+
if (!action || action === 'list') return handleList(flags);
|
|
128
151
|
if (action === 'clear') return handleClear(flags);
|
|
129
152
|
|
|
130
153
|
return {
|
|
131
154
|
success: false,
|
|
132
|
-
error: `Unknown action "${action}"\n\nUsage: celilo module operations [list|clear] [--all]`,
|
|
155
|
+
error: `Unknown action "${action}"\n\nUsage: celilo module operations [list|clear] [--abandoned] [--all]`,
|
|
133
156
|
};
|
|
134
157
|
}
|
|
@@ -171,6 +171,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
|
|
|
171
171
|
secure: 'Secure (Authentication/Database)',
|
|
172
172
|
'secure-mgmt': "Secure-Mgmt (celilo's own control plane)",
|
|
173
173
|
external: 'External (VPS/Cloud)',
|
|
174
|
+
'control-plane-vpn': 'Control-plane VPN (administrative remote access)',
|
|
174
175
|
};
|
|
175
176
|
|
|
176
177
|
// Cast at the lookup, not the declaration: `zone` comes from config and may be
|