@celilo/cli 0.26.1 → 1.0.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 (48) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +4 -2
  3. package/drizzle/0025_port_forward_owner.sql +29 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +3 -3
  6. package/src/__integration__/container-services-cli.integration.test.ts +0 -4
  7. package/src/ansible/dependencies.test.ts +233 -289
  8. package/src/ansible/dependencies.ts +151 -83
  9. package/src/cli/commands/alerts-sweep.ts +14 -3
  10. package/src/cli/commands/machine-add.ts +0 -1
  11. package/src/cli/commands/machine-list.ts +10 -4
  12. package/src/cli/commands/machine-remove.ts +13 -7
  13. package/src/cli/commands/machine-status.ts +9 -11
  14. package/src/cli/commands/module-remove.ts +26 -23
  15. package/src/cli/commands/system-audit.ts +5 -1
  16. package/src/cli/commands/system-update.ts +10 -2
  17. package/src/db/schema.ts +30 -10
  18. package/src/hooks/capability-loader.ts +65 -13
  19. package/src/hooks/define-hook.test.ts +4 -6
  20. package/src/hooks/executor.ts +2 -1
  21. package/src/hooks/types.ts +9 -17
  22. package/src/infrastructure/property-extractor.test.ts +0 -2
  23. package/src/manifest/contracts/index.ts +20 -0
  24. package/src/manifest/contracts/v1.ts +33 -1
  25. package/src/manifest/schema.ts +48 -58
  26. package/src/services/alerting/sweep-runner.test.ts +5 -1
  27. package/src/services/alerting/sweep-runner.ts +14 -8
  28. package/src/services/aspect-runner.test.ts +0 -1
  29. package/src/services/audit/undeployed-modules.ts +18 -1
  30. package/src/services/consumer-cleanup.test.ts +347 -0
  31. package/src/services/consumer-cleanup.ts +244 -0
  32. package/src/services/infrastructure-selector.test.ts +0 -7
  33. package/src/services/infrastructure-selector.ts +24 -25
  34. package/src/services/infrastructure-variable-resolver.test.ts +0 -6
  35. package/src/services/infrastructure-variable-resolver.ts +0 -3
  36. package/src/services/machine-pool.test.ts +53 -85
  37. package/src/services/machine-pool.ts +68 -84
  38. package/src/services/module-deploy.ts +17 -39
  39. package/src/services/module-validator/index.test.ts +9 -0
  40. package/src/services/port-forwards.test.ts +93 -40
  41. package/src/services/port-forwards.ts +74 -48
  42. package/src/services/ssh-key-manager.test.ts +0 -10
  43. package/src/services/trusted-sources.test.ts +52 -13
  44. package/src/services/trusted-sources.ts +25 -15
  45. package/src/test-utils/cli-context.ts +15 -2
  46. package/src/types/infrastructure.ts +11 -1
  47. package/src/services/web-route-cleanup.test.ts +0 -250
  48. package/src/services/web-route-cleanup.ts +0 -144
@@ -28,10 +28,12 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
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
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. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
31
+ - **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so the `admin_group` gate on the idp application is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control. Enrolment works from the `internal` zone ONLY (a device not yet on the VPN cannot reach a manager reachable only over it), and a revocation takes effect at the next reconcile — that interval IS the window in which a revoked device still has reach, which is why the UI shows it pending and why `celilo module run-hook wireguard-manager reconcile_clients` exists. **requires:** `control_plane_vpn` (wireguard), `idp` (authentik), `private_web` (caddy-internal).
31
32
 
32
33
  ## Public edge (ingress / identity)
33
34
 
34
35
  - **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
36
+ - **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never calls `firewall.exposeService`, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those three absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`internal`** zone, with the LAN devices it serves: a dmz placement would need the firewall DNAT ingress to be reachable at all, which is the one thing it must not install. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — declared under `optional.capabilities` ONLY to route around celilo#854 (the import-time secret gate refuses a consumer over a secret it never reads); `on_install` throws without it.
35
37
  - **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
36
38
  - **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
37
39
 
@@ -57,6 +59,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
57
59
  ## E2E fixtures & probes (not production apps)
58
60
 
59
61
  - **hello-foo** / **hello-bar** — hostless single-page sites (one hand-written `site/index.html`, no build step) published at a configurable `domain` + `path`. They exist so the caddy route-lifecycle e2e can put several independent routes on one caddy and watch them interact — celilo-website is the only other static-site module, and one site can't answer "does removing `/` break `/foo`?". **requires:** `public_web`, `dns_registrar`.
62
+ - **hello-private-foo** / **hello-private-bar** — the `private_web` twins of the pair above: hostless single-page sites published through the fleet-only ingress. Two of them for the same reason — one consumer cannot answer "does withdrawing `/foo` leave `/bar` alone?", and per-consumer scoping is exactly what `unregisterRoutes()` promises. Their hook is `private_web ?? public_web` with no branching after it, which is the spec's "same consumer code, either ingress" requirement made executable. **hello-private-foo** additionally calls `getCaCertificate()` and writes the PEM into its own site before publishing — the CA-bundling pattern a real consumer uses, and the only way to observe that the capability returned something usable. **requires:** `private_web`.
60
63
  - **source-forge-probe** — hostless e2e probe that exercises the `source_forge` capability (issue/revoke runner token) against a deployed Forgejo. **requires:** `source_forge`.
61
64
  - **test-ansible-output** — e2e fixture that runs 25×1s sleep tasks to assert Ansible output streams in real time. Not a real app.
62
65
  - **vm-smoke** — throwaway module proving celilo's generic `type: vm` deploy path end-to-end (template clone → cloud-init → Ansible reach). No capabilities.
@@ -49,7 +49,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
49
49
 
50
50
  | capability | impl | provider module(s) |
51
51
  |---|---|---|
52
- | `public_web` | `packages/capabilities/src/public-web.ts` (`createPublicWeb`). **Route lifecycle** is framework-owned at both ends, not per-module: removing a consumer runs `apps/celilo/src/services/web-route-cleanup.ts` (delete rows → reclaim `/srv/www/<slug>` emit `routes_changed`) whether or not the module has an `on_uninstall`; deploying a provider runs `apps/celilo/src/services/public-web-republish.ts` (re-run every static consumer's `on_install`) so a provider rebuild refills the web roots it destroyed. | caddy |
52
+ | `public_web` | `packages/capabilities/src/public-web.ts` (`createPublicWeb`). **Route lifecycle** is framework-owned at both ends, not per-module: removing a consumer dispatches caddy's `on_consumer_removed` hook (re-render the Caddyfile without that module's routes → reclaim `/srv/www/<slug>`) via the generic **consumer-removal cleanup** below, whether or not the module has an `on_uninstall`; deploying a provider runs `apps/celilo/src/services/public-web-republish.ts` (re-run every static consumer's `on_install`) so a provider rebuild refills the web roots it destroyed. There is no consumer-facing `unregister_routes` — withdrawal is the provider's, by design (openspec/changes/consumer-removal-cleanup D11). | caddy |
53
53
  | `idp` | `packages/capabilities/src/idp.ts` | authentik |
54
54
  | `dns_registrar` | `packages/capabilities/src/dns-registrar.ts` (`registerHost({ fqdn })` — **there is no way to supply an address**; the provider re-derives it from its own update's source IP on every assert) | namecheap |
55
55
  | `external_web` | `packages/capabilities/src/external-web.ts` (`publishStaticSite`). **OFF-FLEET static publishing** — the counterpart to `public_web`. See the in-fleet vs off-fleet note below. | generic-cpanel-hosting-provider |
@@ -61,7 +61,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
61
61
  | `notification` | `packages/capabilities/src/notification.ts` (`send`, optional `receive`) | signal (planned) — transports are ordinary modules, both self-hosted and credential-only |
62
62
  | `cross_module_read` | `packages/capabilities/src/cross-module-read.ts` | framework (read other modules' capability data) |
63
63
  | `control_plane_vpn` | `packages/capabilities/src/control-plane-vpn.ts` (`registerClient`, `revokeClient`, `listClients`, `getEndpoint`). **Its state is NOT in a celilo table** — registrations live in the PROVIDER's own module config under `registered_peers`, kept separate from the operator's `peers` so a machine can never rewrite what an operator typed, and so `revokeClient` structurally cannot reach a declared peer. Do not go looking for a `vpn_clients` table; there isn't one and that is deliberate. Addresses are allocated by the CONSUMER from `client_pool` — celilo's IPAM deliberately does not cover VPN clients. ⚠️ Every client registered here reaches every managed zone: the tunnel's client subnet is a registered trusted source. | wireguard |
64
- | `private_web` | `packages/capabilities/src/private-web.ts` (`publishStaticSite`, `registerReverseProxy`, `unregisterRoutes`, `getCaCertificate`). **Fleet-only HTTP ingress** — internal DNS, an internally-issued certificate, and no public exposure. A SIBLING of `public_web` rather than a flag on it, because `public_web` treats an unreachable route as a deploy failure and publishes a public record to prevent one. Reuses `public_web`'s request types so a consumer can write `private_web ?? public_web` and call through the union without branching. | caddy-internal (planned) |
64
+ | `private_web` | `packages/capabilities/src/private-web.ts` (`publishStaticSite`, `registerReverseProxy`, `unregisterRoutes`, `getCaCertificate`). **Fleet-only HTTP ingress** — internal DNS, an internally-issued certificate, and no public exposure. A SIBLING of `public_web` rather than a flag on it, because `public_web` treats an unreachable route as a deploy failure and publishes a public record to prevent one. Reuses `public_web`'s request types so a consumer can write `private_web ?? public_web` and call through the union without branching. **MODULE-provided**, unlike `public_web`: the implementation is `modules/caddy-internal/scripts/private-web-functions.ts` (wired via `CAPABILITY_MODULE_MAP`), and the route table lives in that module's own config rather than celilo's `web_routes` — a private route in `web_routes` would be picked up and served by the PUBLIC caddy, which derives its served hostnames from every row (celilo#846). | caddy-internal |
65
65
 
66
66
  ### In-fleet (`public_web`) vs off-fleet (`external_web`)
67
67
 
@@ -179,6 +179,8 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
179
179
  - **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.
180
180
  - **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/changes/module-pause-lifecycle/`.
181
181
  - **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
182
+ - **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
183
+ - **Firewall registry ownership** — `apps/celilo/src/services/port-forwards.ts` + `apps/celilo/src/services/trusted-sources.ts`. Both stores are bound to the CONSUMING module and stamp `registered_by` themselves; a caller cannot supply it, so a registration is never attributed to the wrong module. Both writes are **declarative**: `replace()` states a consumer's COMPLETE set for a target, so a port or subnet it previously registered and now omits is withdrawn (celilo#855 — before this, a module that exposed `:8080` and redeployed exposing `:9090` kept both, forever). The owner is IN both unique indexes, not merely beside them: two consumers wanting the same forward are two ROWS, so one leaving cannot delete a rule the other still needs; `modules/iptables/scripts/ruleset-renderer.ts` dedupes on the rule tuple so the pair renders once. Neither column is a FK, so `runConsumerCleanup` deletes these rows explicitly after every provider has converged without them.
182
184
  - **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.
183
185
 
184
186
  ### Paused modules are conspicuous
@@ -0,0 +1,29 @@
1
+ -- Ownership of a firewall registration is a SET, not a label
2
+ -- (openspec/changes/consumer-removal-cleanup, D5a).
3
+ --
4
+ -- `port_forwards` had no owner column at all, so nothing could tell which
5
+ -- consumer a DNAT rule was minted for — the reason removing a module left the
6
+ -- rule on the box with nothing in the registry to explain it.
7
+ --
8
+ -- Adding the column alone would introduce a WORSE bug than it fixes. Both
9
+ -- unique indexes keyed the row WITHOUT an owner, and both stores
10
+ -- delete-then-insert on that tuple, so consumer B registering what consumer A
11
+ -- already has silently replaces A's row. Put an owner beside such an index and
12
+ -- removing B deletes a rule A still needs. So the owner joins the index: two
13
+ -- owners of one forward are two rows, and the row dies exactly when the last
14
+ -- module that wants it goes away. `renderRuleset` dedupes on the rule tuple so
15
+ -- the pair still emits one DNAT rule.
16
+ --
17
+ -- NOT destructive, though Phase 0 would permit it. Existing rows keep serving
18
+ -- with `registered_by = ''` (unattributed): dropping them would empty the
19
+ -- desired-state registry while the boxes keep their applied rules, and the
20
+ -- first firewall converge after the upgrade would then render a ruleset missing
21
+ -- every forward except the one the deploying module just re-registered. The
22
+ -- unattributed rows are adopted the next time their owner re-declares the same
23
+ -- target (see `buildPortForwardStore.replace`).
24
+
25
+ ALTER TABLE `port_forwards` ADD `registered_by` text DEFAULT '' NOT NULL;--> statement-breakpoint
26
+ DROP INDEX `port_forwards_unique_idx`;--> statement-breakpoint
27
+ CREATE UNIQUE INDEX `port_forwards_unique_idx` ON `port_forwards` (`firewall_ip`,`internal_ip`,`port`,`protocol`,`ingress_ip`,`registered_by`);--> statement-breakpoint
28
+ DROP INDEX `trusted_sources_unique_idx`;--> statement-breakpoint
29
+ CREATE UNIQUE INDEX `trusted_sources_unique_idx` ON `trusted_sources` (`firewall_ip`,`subnet`,`registered_by`);
@@ -176,6 +176,13 @@
176
176
  "when": 1784000000000,
177
177
  "tag": "0024_module_pause",
178
178
  "breakpoints": true
179
+ },
180
+ {
181
+ "idx": 25,
182
+ "version": "6",
183
+ "when": 1784100000000,
184
+ "tag": "0025_port_forward_owner",
185
+ "breakpoints": true
179
186
  }
180
187
  ]
181
188
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.26.1",
3
+ "version": "1.0.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^1.4.0",
61
+ "@celilo/capabilities": "^2.0.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.8.0",
63
+ "@celilo/core": "^0.8.1",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -151,7 +151,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
151
151
  sshUser: 'ubuntu',
152
152
  sshKeyEncrypted: JSON.stringify(sshKey),
153
153
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
154
- assignedModuleIds: [],
155
154
  createdAt: new Date(),
156
155
  updatedAt: new Date(),
157
156
  });
@@ -187,7 +186,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
187
186
  sshUser: 'root',
188
187
  sshKeyEncrypted: JSON.stringify(sshKey),
189
188
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
190
- assignedModuleIds: [],
191
189
  createdAt: new Date(),
192
190
  updatedAt: new Date(),
193
191
  },
@@ -199,7 +197,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
199
197
  sshUser: 'ubuntu',
200
198
  sshKeyEncrypted: JSON.stringify(sshKey),
201
199
  hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
202
- assignedModuleIds: [],
203
200
  createdAt: new Date(),
204
201
  updatedAt: new Date(),
205
202
  },
@@ -232,7 +229,6 @@ describe('Container Services and Machine Pool CLI Integration', () => {
232
229
  sshUser: 'root',
233
230
  sshKeyEncrypted: JSON.stringify(sshKey),
234
231
  hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 64 },
235
- assignedModuleIds: [],
236
232
  createdAt: new Date(),
237
233
  updatedAt: new Date(),
238
234
  });