@celilo/cli 1.7.0 → 1.9.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 (74) hide show
  1. package/CELILO_CORE_MODULES.md +3 -0
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  4. package/drizzle/0028_capability_bindings.sql +26 -0
  5. package/drizzle/0029_module_instances.sql +58 -0
  6. package/drizzle/meta/_journal.json +22 -1
  7. package/package.json +2 -2
  8. package/src/capabilities/validation.test.ts +51 -0
  9. package/src/capabilities/validation.ts +22 -8
  10. package/src/cli/commands/module-show.ts +1 -0
  11. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  12. package/src/db/foreign-keys.test.ts +101 -0
  13. package/src/db/schema.ts +182 -9
  14. package/src/hooks/broker.test.ts +152 -0
  15. package/src/hooks/broker.ts +307 -0
  16. package/src/hooks/capability-loader-bindings.test.ts +163 -0
  17. package/src/hooks/capability-loader-firewall.test.ts +108 -0
  18. package/src/hooks/capability-loader.test.ts +10 -2
  19. package/src/hooks/capability-loader.ts +59 -2
  20. package/src/hooks/executor.ts +234 -111
  21. package/src/hooks/hook-protocol.test.ts +192 -0
  22. package/src/hooks/hook-protocol.ts +275 -0
  23. package/src/hooks/hook-runner.ts +231 -0
  24. package/src/hooks/hook-timeout.test.ts +103 -0
  25. package/src/hooks/hook-trespass.test.ts +201 -0
  26. package/src/hooks/injected-capabilities.test.ts +75 -0
  27. package/src/hooks/test-fixtures/capability-calling-hook.ts +79 -0
  28. package/src/hooks/test-fixtures/runaway-hook.ts +26 -0
  29. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +22 -0
  30. package/src/manifest/template-validator.test.ts +47 -0
  31. package/src/manifest/template-validator.ts +18 -1
  32. package/src/manifest/validate-provider-views.test.ts +61 -0
  33. package/src/manifest/validate.ts +21 -14
  34. package/src/module/import.ts +19 -1
  35. package/src/module/packaging/module-state-directory.test.ts +99 -0
  36. package/src/module/packaging/package-rules.ts +10 -2
  37. package/src/policy/capability-shape-baseline.ts +96 -0
  38. package/src/policy/capability-shape-drift.test.ts +162 -0
  39. package/src/policy/capability-shape.ts +129 -0
  40. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  41. package/src/policy/module-business-baseline.ts +68 -7
  42. package/src/services/alerting/ack.test.ts +2 -2
  43. package/src/services/alerting/deferral.test.ts +2 -2
  44. package/src/services/alerting/delivery-loop.test.ts +2 -2
  45. package/src/services/alerting/deploy-hooks.test.ts +2 -2
  46. package/src/services/alerting/inbound-poller.test.ts +2 -2
  47. package/src/services/alerting/inbound.test.ts +2 -2
  48. package/src/services/alerting/notification-responder.test.ts +2 -2
  49. package/src/services/alerting/run-monitor.test.ts +2 -2
  50. package/src/services/alerting/store.test.ts +2 -2
  51. package/src/services/alerting/sweep-runner.test.ts +2 -2
  52. package/src/services/alerting/tokens.test.ts +2 -2
  53. package/src/services/capability-bindings.test.ts +104 -0
  54. package/src/services/capability-bindings.ts +107 -0
  55. package/src/services/capability-table-rows.test.ts +191 -0
  56. package/src/services/capability-table-rows.ts +103 -0
  57. package/src/services/consumer-cleanup.test.ts +40 -3
  58. package/src/services/consumer-cleanup.ts +13 -7
  59. package/src/services/dns-internal-records.test.ts +74 -3
  60. package/src/services/fleet-checks.test.ts +4 -4
  61. package/src/services/module-instances.test.ts +198 -0
  62. package/src/services/module-instances.ts +96 -0
  63. package/src/services/module-journal.test.ts +2 -2
  64. package/src/services/module-subscriptions.test.ts +1 -1
  65. package/src/services/module-validator/capability-versions.test.ts +6 -1
  66. package/src/services/port-forwards.test.ts +8 -4
  67. package/src/services/port-forwards.ts +0 -11
  68. package/src/services/trusted-sources.test.ts +3 -3
  69. package/src/services/trusted-sources.ts +0 -5
  70. package/src/templates/ingress-ip.test.ts +31 -0
  71. package/src/test-utils/database.ts +31 -1
  72. package/src/variables/context.ts +75 -10
  73. package/src/variables/lxc-nameserver.test.ts +144 -0
  74. package/src/test-utils/setup-test-db.ts +0 -80
@@ -37,7 +37,9 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
37
37
  - **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.
38
38
  - **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**.
39
39
  - **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). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
40
+ - **dnsmasq-dhcp** — a DHCP-only dnsmasq in **`internal`**, providing the zone-scoped `dhcp_server` capability for a network with no upstream router to serve it. **provides:** `dhcp_server` (zone `internal`). The other two `dhcp_server` providers, **axon** and **greenwave**, do not run a DHCP server at all — they drive an ISP router's built-in one over its API, which works only while the home network shares a broadcast domain with that router. Put `internal` behind the celilo firewall and DHCP discovery, being a broadcast, no longer reaches it: celilo could describe that topology and not make it work. Importing this module at all depends on well-known-provider uniqueness being scoped PER ZONE (celilo#1028), since axon/greenwave already claim `dhcp_server` fleet-wide. **`port=0` is load-bearing**: dnsmasq ships a resolver as well as a DHCP server, and `dns_internal` is a different capability with its own providers — two resolvers answering on one network is a split-brain that is very hard to see from outside. celilo's transactional artifacts go to `/var/lib/celilo/config-state/` via `applyRenderedConfig`'s `statePath`, NOT beside the managed fragment, because dnsmasq parses every file in `/etc/dnsmasq.d` and a `.celilo-bak` there is configuration the daemon reads. It declares a `requires.system` and is placed by celilo like any other module; an earlier shape declared none, which meant no host in the inventory, an `apt-get` shelled over SSH from a hook, and an operator-typed address — and which also evaded `validateMachineRoleForModule`, the check that refuses to place a non-firewall module on a multi-interface router.
40
41
  - **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/`).
42
+ - **knot-unbound-secondary** — a read-only secondary for **knot-unbound-internal**: Knot transfers every managed zone from the primary and Unbound serves the same split-horizon view. Lives in **`dmz`**, symmetric with the primary (design D5e) — NOT in `internal` as first written. That placement was chosen deliberately and the reasoning is worth keeping: design D5a records the secondary's justification as weak on purpose, since steady state is already a single resolver address and the one recurring non-bug case a secondary covers is redeploying the primary, which blanks fleet DNS for the duration. Same-zone covers that completely. A split placement buys zone-level independence, which D5a explicitly declined, and would not deliver it anyway — celilo has no host anti-affinity, so both LXCs land on the container service's `default_target_node`. **provides:** `dns_internal_secondary`, carrying its OWN addresses rather than being advertised by the primary: if the primary carried them its published data would be stale from the moment this module deployed until something recomputed it, and recomputing means redeploying the primary — the manual step D5 exists to remove. **requires:** `dns_internal` (hard, and honestly declared: it sat under `optional` until celilo#854, because the import-time secret gate refused a consumer over a `tsig_key` it never reads). Its protected-zone ingress is a `*_ingress_ip` / `source: infrastructure` variable that `ensureIngressIps` allocates from `network.internal.subnet`, so the operator types none of the three addresses the fork's version asked for. Registering it as a transfer peer on the primary is `registerTransferPeer` (design D5f) — ⚠️ the empty peer state MUST render an explicit `deny: on`, because a knot acl with `action: transfer` and no `address:` matches ANY source and `knotc conf-check` accepts that silently.
41
43
  - **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/`).
42
44
  - **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`.
43
45
  - **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).
@@ -73,6 +75,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
73
75
  ## E2E fixtures & probes (not production apps)
74
76
 
75
77
  - **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`.
78
+ - **hello-trespass** — TEST FIXTURE, never deploy. Its `on_install` hook attempts three things a hook has no business doing (read the master key at its default location, write into a sibling module's install tree, read the SSH private key / `remoteExec` another module's system) and REPORTS which succeeded rather than failing. It is the recurrence gate for `openspec/changes/hook-process-boundary`: each stage flips exactly one row and the test asserts the rows that stage does NOT claim still succeed, so a gate cannot quietly grow to cover work that was never done. **requires:** nothing.
76
79
  - **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`.
77
80
  - **source-forge-probe** — hostless e2e probe that exercises the `source_forge` capability (issue/revoke runner token) against a deployed Forgejo. **requires:** `source_forge`.
78
81
  - **test-ansible-output** — e2e fixture that runs 25×1s sleep tasks to assert Ansible output streams in real time. Not a real app.
@@ -32,7 +32,11 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
32
32
  - **Network requirement + ensure (celilo owns the namespace)** — `apps/celilo/src/services/network-ensure.ts` (`ensureRequiredNetworks`), `NetworkRequirementSchema` / `getRequiredNetworkNames` in `apps/celilo/src/manifest/schema.ts`. A module declares `requires.networks: [{name}]` — a NAME, never a value; the schema is `.strict()` so a `subnet:` on the requirement is rejected with a message saying why. The deploy calls `ensureRequiredNetworks` in its interview phase, BEFORE generation and before any hook, and asks over the generic bus interview (`askText`, so it is answerable headless) for anything undefined. Which attributes a network has is celilo's answer, taken from `apps/celilo/schemas/system_config.json`: that file declares `network.<n>.gateway` for the routed segments and omits it for `control-plane-vpn`, so a gateway is never asked for a network that has none. Well-known names carry a `suggested` range there — deliberately NOT `default`, which `getDefaultConfiguration()` would seed at `system init`. Spec: `openspec/specs/network-declaration/spec.md`.
33
33
  - **The network write path (closed) + celilo's own discovery** — `celilo system apply-config` (`apps/celilo/src/cli/commands/system-apply-config.ts`) REFUSES the whole `network.` namespace, `network.bridge` excepted (a Proxmox bridge name is not addressing, and it is the one network key with a schema default). That is the automation surface a module hook shells out to, so closing it there is what makes "networks are celilo's" an authority rather than a convention every module has to remember. The refusal names the alternative — declare the network, read it with `$system:` — because a bare rejection sends a module author hunting for a typo. The one write that legitimately needed the surface moved INTO celilo: `celilo system discover-network` (`apps/celilo/src/services/network-discovery.ts`, `cli/commands/system-discover-network.ts`) parses the box's own `ip route` and records `network.internal.*` — or `network.secure-mgmt.*` when the box is off the internal LAN (#300). `celilo-mgmt` calls it and decides nothing; it used to parse and write this itself. Idempotent and never overwrites addressing already set. Recurrence gate: `test-integration/module/no-module-writes-networks.test.ts`.
34
34
  - **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.
35
- - **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.
35
+ - **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. Two copies are unavoidable and BOTH are guarded: `modules/iptables`' `zones` picker (`test-integration/module/zone-options-cover-network-zones.test.ts` fails on any drift, and did when `isp-transit` was added to core alone) and the generated `schemas/module-manifest.schema.json` (`bun run check:schema`, run from `apps/celilo` — a FOURTH gate CI runs that the "Validation gates" list in CLAUDE.md does not name).
36
+ - **`isp-transit`** — the private segment between a celilo firewall and the router upstream of it. Non-placement and non-allocatable (it joins `external` and `control-plane-vpn` in the excluded set) because that router owns the addressing. It exists because on a DOWNSTREAM firewall the default route and the workload network share one interface, so `internal` meant both at once and every egress rule also reasoned about the workload network. A firewall with no separate transit leg declares `default_route_zone: internal` and behaves as it always did.
37
+ - **Declared egress zone** — `modules/iptables` `default_route_zone`, consumed by `defaultRouteFinding` in `packages/capabilities/src/interface-classification.ts` — celilo already classifies whichever interface carries the default route; what it could not do is tell an INTENDED egress leg from a drifted one, so the comparison was hardcoded to "internal or external". Declaring the intent makes a wandered default route a finding. Defaults to `internal`.
38
+ - **Capability-secret access is reference-driven** — `apps/celilo/src/capabilities/validation.ts` — `validateCapabilityAccess` fires on the `$capability:<name>.<secret>` paths the CONSUMER references, collected from its manifest AND from the templates `validateModuleTemplates` already parses at import. It used to iterate the secrets the PROVIDER declares, so requiring a capability that merely carries a secret put a module on the hook for that secret's `readable_by` list (celilo#854, celilo#1038). Reading a secret still goes through `checkCapabilitySecretAccess` at resolution time.
39
+ - **Zone-first capability provider selection** — `apps/celilo/src/capabilities/lookup.ts` — one shared selection function used by both the direct lookup and the hook capability loader, which had drifted apart. Well-known-provider uniqueness is scoped per zone, which is what lets a second `dhcp_server` provider exist at all while `axon`/`greenwave` claim it fleet-wide.
36
40
  - **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.
37
41
  - **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.
38
42
  - **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
@@ -43,6 +47,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
43
47
  - **Define a capability function** — `packages/capabilities/src/define-capability-function.ts` — `defineCapabilityFunction`.
44
48
  - **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Public surface: `packages/capabilities/src/index.ts`.
45
49
  - **Loader (wires provider factories into hook contexts)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadCapabilityFunctions`, `resolveFirewallNatIp`.
50
+ - **Which provider a consumer actually resolved to (`capability_bindings`)** — `apps/celilo/src/services/capability-bindings.ts` — `recordCapabilityBinding`, `listCapabilityBindings`, `withBindingRecord`, written from the single return of `loadCapabilityFunctions`. The `capabilities` table is provider-side only, so celilo could answer "who COULD provide this" and never "who does this module actually use"; every caller needing the second question rebuilt the same approximation (the consumer's `requires` + `optional` crossed with `capabilities`), which names every provider a module MIGHT have bound to. `tango-nexus` declares four optional capabilities, is deployed against one off-fleet host, and that approximation carries three edges that do not exist. **The CALL is the binding, not the resolution** — the loader injects every registered capability regardless of what the consumer declared (its own comment: "not just required ones"), so recording what it resolves is a SUPERSET of even the permissive set. A consumer's hook invoking a method is the only event that separates the optional it uses from the ones it merely declares, which is why this matters most for the capabilities that mint nothing (`external_web`, `idp`, `source_forge`, `registry_publish`, `control_plane_vpn`, `apt_publish`) and therefore leave no artefact anywhere else. Recorded on ATTEMPT rather than success: a consumer that called a provider and got an error is standing on that provider, and hiding the edge exactly when the call fails hides it when it matters most. Unique on (consumer, capability) so a redeploy re-asserts and a provider swap rewrites in place; `bound_at` is LAST-SEEN, so a binding last exercised forty deploys ago is a queryable fact rather than a silent lie. Cascades on the consumer. It runs BESIDE `planConsumerCleanup`, never narrowing it — cleanup must still reach every provider that might hold minted state, including one whose binding row was never written. See celilo#1072.
46
51
  - **Ledger wrappers (stateful capabilities)** — `apps/celilo/src/services/dns-registrations.ts` (`withDnsRegistrationLedger`), `apps/celilo/src/services/dns-internal-records.ts` (`withDnsInternalLedger`).
47
52
  - **Public DNS ledger (`dns_registrations` + `dns_registration_consumers`)** — `apps/celilo/src/services/dns-registrations.ts` — records WHICH MODULE ASKED FOR WHICH NAME, and deliberately **no address**: the published value is source-detected on every assert and observable on demand from public DNS, and a stored copy is one careless read away from becoming an instruction again (celilo#626 — the 15-minute `refresh_registrations` replayed addresses written by an older celilo and took five public names dark for nine days while every in-fleet check stayed green). Consumers are a SET, not a column: the FK cascade on a single `consumer_module_id` decided when a LIVE record was forgotten, so a row now survives until its LAST consumer is removed (`openspec/changes/public-dns-reachability/design.md` D5). Orphaned rows are pruned on read (`pruneOrphanedRegistrations`) rather than by trigger — SQLite fires delete triggers for FK-cascaded deletes only with `recursive_triggers` on. `companion: true` marks a name celilo claimed on a module's behalf (`www.<domain>` ↔ `<domain>`) rather than one a module asked for; the provider reports it as `outputs.companion_fqdn` whether or not the claim reported success, because a provider's success response is not evidence of publication. Operator surface: `celilo dns registrations`.
48
53
 
@@ -175,6 +180,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
175
180
  ## Hooks & deploy
176
181
 
177
182
  - **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
183
+ - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_*`, `CELILO_DEBUG`), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stage 1 of `openspec/changes/hook-process-boundary`; it claims the environment and the process, not the filesystem (a hook can still compute the default master-key path) and not the network.
178
184
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
179
185
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
180
186
  - **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). Two directions, with DELIBERATELY OPPOSITE failure semantics:
@@ -0,0 +1,43 @@
1
+ -- `dns_internal_records` cascades on its PROVIDER as well as its consumer, so
2
+ -- swapping the internal DNS provider deletes the fleet's entire internal DNS
3
+ -- ledger — including the `zone_routable_ip` view overrides the resolver's
4
+ -- split-horizon config is reconciled from (celilo#1010).
5
+ --
6
+ -- `web_routes` does not do this. Its rows cascade on the consumer only, which is
7
+ -- why a `public_web` provider swap keeps every route. The two tables' docblocks
8
+ -- claim to be siblings and nothing marks the difference as a decision, because it
9
+ -- was not one.
10
+ --
11
+ -- The claim on a capability-owned table is the CONSUMER
12
+ -- (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
13
+ -- declaration cannot express anything else. This makes the table agree with it.
14
+ --
15
+ -- `provider_module_id` STAYS. It is how a record is attributed to the resolver
16
+ -- serving it and it is half the uniqueness key; only its ON DELETE action is
17
+ -- wrong. It becomes a plain column with no foreign key rather than a cascade,
18
+ -- so removing a provider leaves the ledger intact for the next one to reconcile
19
+ -- from — which is the behaviour stage 1's provider-arrival backfill assumes.
20
+ --
21
+ -- SQLite cannot alter a foreign key, so the table is rebuilt. Order matters:
22
+ -- `foreign_keys` is ON during migration, so the copy is made BEFORE the drop and
23
+ -- the index is recreated after the rename. Same shape as
24
+ -- `0021_dns_registration_consumers`.
25
+
26
+ CREATE TABLE `__new_dns_internal_records` (
27
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
28
+ `provider_module_id` text NOT NULL,
29
+ `consumer_module_id` text NOT NULL,
30
+ `host` text NOT NULL,
31
+ `ip` text NOT NULL,
32
+ `zone_routable_ip` text,
33
+ `registered_at` integer DEFAULT (unixepoch()) NOT NULL,
34
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
35
+ );
36
+ --> statement-breakpoint
37
+ INSERT INTO `__new_dns_internal_records` SELECT `id`, `provider_module_id`, `consumer_module_id`, `host`, `ip`, `zone_routable_ip`, `registered_at` FROM `dns_internal_records`;
38
+ --> statement-breakpoint
39
+ DROP TABLE `dns_internal_records`;
40
+ --> statement-breakpoint
41
+ ALTER TABLE `__new_dns_internal_records` RENAME TO `dns_internal_records`;
42
+ --> statement-breakpoint
43
+ CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
@@ -0,0 +1,26 @@
1
+ -- Which provider a consumer actually resolved to.
2
+ --
3
+ -- `capabilities` is provider-side only. It answers "who could provide this" and
4
+ -- has never answered "who does this module actually use", so every caller that
5
+ -- needed the second question rebuilt the same approximation: the consumer's
6
+ -- `requires` + `optional` crossed with `capabilities`. That names every provider
7
+ -- a module MIGHT have bound to. A module declaring four optional capabilities
8
+ -- and using one gets three false edges and no way to tell which is real.
9
+ --
10
+ -- A row is written when a consumer's hook first calls a method on an injected
11
+ -- capability. Unique on (consumer, capability) so a redeploy re-asserts rather
12
+ -- than duplicates, and a provider swap rewrites the provider in place.
13
+ --
14
+ -- `provider_module_id` is a plain column with no foreign key, matching
15
+ -- `dns_internal_records` after 0027: removing a provider must not erase the
16
+ -- record that a consumer was bound to it.
17
+ CREATE TABLE `capability_bindings` (
18
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
19
+ `consumer_module_id` text NOT NULL,
20
+ `capability_name` text NOT NULL,
21
+ `provider_module_id` text NOT NULL,
22
+ `bound_at` integer DEFAULT (unixepoch()) NOT NULL,
23
+ FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
24
+ );
25
+ --> statement-breakpoint
26
+ CREATE UNIQUE INDEX `capability_bindings_consumer_capability_idx` ON `capability_bindings` (`consumer_module_id`,`capability_name`);
@@ -0,0 +1,58 @@
1
+ -- Instances of a submodule (openspec/changes/submodules, D2 and D4).
2
+ --
3
+ -- An instance IS a row in `modules`, under a celilo-derived id. That is the
4
+ -- decision this table exists to support rather than to work around: every
5
+ -- deployment-state table in celilo keys on `module_id` and only on `module_id`
6
+ -- (`module_systems` is PRIMARY KEY (module_id, name), `module_configs` is
7
+ -- UNIQUE (module_id, key), `module_infrastructure` references it), so making an
8
+ -- instance a module row means health, backup, fleet status, `module list` and
9
+ -- the removal path all needed no change at all.
10
+ --
11
+ -- The alternative was adding an `instance_id` column to each of those tables and
12
+ -- widening every key. It touches every reader and it fails QUIETLY: a query that
13
+ -- still filters on module_id alone compiles clean and returns N rows where it
14
+ -- expected one.
15
+ --
16
+ -- `module_id` is both the primary key and the FK, so an instance cannot exist
17
+ -- without the module row it names, and dropping the module row drops this.
18
+ --
19
+ -- IDENTITY IS THE TRIPLE (parent_id, submodule, instance_key), never
20
+ -- instance_key alone. Two parents may use the same key string and one parent may
21
+ -- use the same key across two of its submodules; both are legal and neither
22
+ -- collides. `instance_key` is opaque: celilo stores it and never interprets it,
23
+ -- because a key derived from a display name orphans a running system the first
24
+ -- time somebody is renamed at the identity provider.
25
+ --
26
+ -- `failure_reason` and `retryable` are NULL unless state = 'failed'. That is the
27
+ -- Rule 3.4 exception (null meaning this whole section is absent) rather than a
28
+ -- missing default. `retryable` is a column rather than something a caller infers
29
+ -- from the reason text: a parent's reconcile loop reads it to decide whether to
30
+ -- rebuild, and a missing interview answer will fail identically forever while a
31
+ -- timed-out provision is worth another go. Forcing a caller to string-match the
32
+ -- reason is how a reporter gets trusted by its shape and quietly answers wrong.
33
+
34
+ CREATE TABLE `module_instances` (
35
+ `module_id` text PRIMARY KEY NOT NULL,
36
+ `parent_id` text NOT NULL,
37
+ `submodule` text NOT NULL,
38
+ `instance_key` text NOT NULL,
39
+ `label` text,
40
+ `state` text DEFAULT 'pending' NOT NULL,
41
+ `failure_reason` text,
42
+ `retryable` integer,
43
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
44
+ `updated_at` integer DEFAULT (unixepoch()) NOT NULL,
45
+ FOREIGN KEY (`module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
46
+ FOREIGN KEY (`parent_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
47
+ );
48
+ --> statement-breakpoint
49
+ -- The real identity. A database fact rather than a check somebody has to
50
+ -- remember, so "this parent already has an instance under this key" cannot be
51
+ -- got wrong by a caller.
52
+ CREATE UNIQUE INDEX `module_instances_identity_idx` ON `module_instances` (`parent_id`,`submodule`,`instance_key`);--> statement-breakpoint
53
+ -- Every reconcile call lists one parent's instances. That must not become a
54
+ -- table scan once a fleet carries tens of them.
55
+ CREATE INDEX `module_instances_parent_idx` ON `module_instances` (`parent_id`);--> statement-breakpoint
56
+ -- `list` filters by state, and the collapsed operator view rolls health up per
57
+ -- parent, which reads the same way.
58
+ CREATE INDEX `module_instances_state_idx` ON `module_instances` (`state`);
@@ -190,6 +190,27 @@
190
190
  "when": 1784200000000,
191
191
  "tag": "0026_module_integrity_version",
192
192
  "breakpoints": true
193
+ },
194
+ {
195
+ "idx": 27,
196
+ "version": "6",
197
+ "when": 1784300000000,
198
+ "tag": "0027_dns_internal_records_consumer_cascade",
199
+ "breakpoints": true
200
+ },
201
+ {
202
+ "idx": 28,
203
+ "version": "6",
204
+ "when": 1784400000000,
205
+ "tag": "0028_capability_bindings",
206
+ "breakpoints": true
207
+ },
208
+ {
209
+ "idx": 29,
210
+ "version": "6",
211
+ "when": 1784400000000,
212
+ "tag": "0029_module_instances",
213
+ "breakpoints": true
193
214
  }
194
215
  ]
195
- }
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^2.4.0",
61
+ "@celilo/capabilities": "^3.0.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.9.1",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -824,5 +824,56 @@ describe('Capability Access Validation', () => {
824
824
  expect(refused.success).toBe(false);
825
825
  expect(refused.error).toContain('tsig_key');
826
826
  });
827
+
828
+ /**
829
+ * A reference in a TEMPLATE counts too. `resolver.ts` does refuse one at
830
+ * the point of use, so nothing is unsafe without this, but the refusal
831
+ * lands at generation rather than at import.
832
+ *
833
+ * That gap sits exactly where the repo sends people. CLAUDE.md's module
834
+ * Definition of Done says "Capability variable usage — Templates use
835
+ * `$capability:` syntax" and gives a `.tf.tpl` example, so the first author
836
+ * who follows that instruction with a RESTRICTED secret is the one who
837
+ * finds out late. No module in the tree references a capability from a
838
+ * template today, which is why the gap is currently invisible rather than
839
+ * absent.
840
+ */
841
+ test('a secret named only in a template is refused at import', async () => {
842
+ const manifest: ModuleManifest = {
843
+ celilo_contract: '1.0',
844
+ id: 'nosy-app',
845
+ name: 'Nosy App',
846
+ version: '1.0.0',
847
+ description: 'Test',
848
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
849
+ provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
850
+ variables: { owns: [], imports: [] },
851
+ };
852
+
853
+ // The manifest names nothing. Only the .tf.tpl does.
854
+ const result = await validateCapabilityAccess(manifest, knotDb, ['dns_internal.tsig_key']);
855
+
856
+ expect(result.success).toBe(false);
857
+ expect(result.error).toContain("cannot access secret 'tsig_key'");
858
+ });
859
+
860
+ test('a non-secret template reference is still not a secret reference', async () => {
861
+ const manifest: ModuleManifest = {
862
+ celilo_contract: '1.0',
863
+ id: 'caddy-internal',
864
+ name: 'Caddy (fleet-only ingress)',
865
+ version: '1.0.0',
866
+ description: 'Test',
867
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
868
+ provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
869
+ variables: { owns: [], imports: [] },
870
+ };
871
+
872
+ const result = await validateCapabilityAccess(manifest, knotDb, [
873
+ 'dns_internal.server.ip.primary',
874
+ ]);
875
+
876
+ expect(result.success).toBe(true);
877
+ });
827
878
  });
828
879
  });
@@ -21,11 +21,16 @@ export interface ValidationResult {
21
21
  *
22
22
  * @param manifest - Consumer module manifest
23
23
  * @param db - Database connection
24
+ * @param templateReferences - `<capability>.<path>` references found outside
25
+ * the manifest, i.e. in the module's templates. Empty is the honest default
26
+ * for a caller holding no template context; `module import` passes what the
27
+ * template validator already parsed.
24
28
  * @returns Validation result
25
29
  */
26
30
  export async function validateCapabilityAccess(
27
31
  manifest: ModuleManifest,
28
32
  db: Database,
33
+ templateReferences: readonly string[] = [],
29
34
  ): Promise<ValidationResult> {
30
35
  // If module doesn't require capabilities, validation passes
31
36
  if (!manifest.requires?.capabilities || manifest.requires.capabilities.length === 0) {
@@ -35,7 +40,7 @@ export async function validateCapabilityAccess(
35
40
  // Get list of capabilities this module provides
36
41
  const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name);
37
42
 
38
- const references = collectCapabilityReferences(manifest);
43
+ const references = collectCapabilityReferences(manifest, templateReferences);
39
44
 
40
45
  // Check each required capability
41
46
  for (const requiredCapability of manifest.requires.capabilities) {
@@ -105,16 +110,25 @@ export async function validateCapabilityAccess(
105
110
  * lives — a `variables.owns[].derive_from`, a default, a capability data block —
106
111
  * without this having to track which fields may hold one.
107
112
  *
108
- * References made from a module's TEMPLATES are deliberately not collected here.
109
- * `src/variables/resolver.ts` checks access at the point of use, which is the
110
- * only place a template reference can be seen, and it refuses there.
113
+ * Template references are included too, supplied by the caller. `resolver.ts`
114
+ * does refuse a template reference at the point of use, so nothing is unsafe
115
+ * without them, but the refusal lands at generation rather than at import. That
116
+ * matters because a module's TEMPLATES are where this repo tells authors to put
117
+ * these: the Definition of Done in CLAUDE.md says "Capability variable usage —
118
+ * Templates use `$capability:` syntax" and gives a `.tf.tpl` example. A gate
119
+ * that does not fire where the documentation sends people is a gate with a hole
120
+ * in the shape of the instructions.
111
121
  */
112
- function collectCapabilityReferences(manifest: ModuleManifest): Set<string> {
113
- return new Set(
114
- parseVariables(JSON.stringify(manifest))
122
+ function collectCapabilityReferences(
123
+ manifest: ModuleManifest,
124
+ templateReferences: readonly string[] = [],
125
+ ): Set<string> {
126
+ return new Set([
127
+ ...parseVariables(JSON.stringify(manifest))
115
128
  .filter((variable) => variable.type === 'capability')
116
129
  .map((variable) => variable.path),
117
- );
130
+ ...templateReferences,
131
+ ]);
118
132
  }
119
133
 
120
134
  /**
@@ -165,6 +165,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
165
165
  // compile error here rather than silently rendering as "Unknown zone". Both
166
166
  // `internal` and `secure-mgmt` were missing from the previous string-keyed map.
167
167
  const zoneDescriptions: Record<NetworkZone, string> = {
168
+ 'isp-transit': 'ISP transit (Private segment to the router upstream of the firewall)',
168
169
  internal: 'Internal (Semi-trusted network behind the firewall)',
169
170
  dmz: 'DMZ (Public-facing services)',
170
171
  app: 'Application (Internal services)',
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Migration gate for celilo#1010, over a database that already holds records.
3
+ *
4
+ * `0027` rebuilds `dns_internal_records` to drop the foreign key on
5
+ * `provider_module_id`. A rebuild is copy, drop, rename — the most destructive
6
+ * shape a migration takes — and it will run on celilo-mgr, which holds the live
7
+ * internal DNS ledger including the `zone_routable_ip` view overrides the
8
+ * resolver's split-horizon config is reconciled from.
9
+ *
10
+ * Every other test in the suite starts from an empty database and can only prove
11
+ * the forward invariant. This one builds a database at the schema BEFORE the
12
+ * change, puts real rows in it, and runs the real migrator over it — the same
13
+ * shape as `dns-registrations-migration.test.ts`, and for the same reason: the
14
+ * installed base is where the risk is, and a clean-start suite cannot see it.
15
+ *
16
+ * Task 2.6 asks for backup and restore proven before this runs on celilo-mgr.
17
+ * This is the stronger half of that: it proves the migration PRESERVES the rows,
18
+ * so a restore is the fallback rather than the plan.
19
+ */
20
+
21
+ import { Database } from 'bun:sqlite';
22
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
23
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
27
+ import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
28
+ import { findMigrationsFolder } from './client';
29
+
30
+ /** The last migration before this change — the schema celilo-mgr is on today. */
31
+ const LAST_LEGACY_TAG = '0026_module_integrity_version';
32
+
33
+ interface JournalEntry {
34
+ idx: number;
35
+ version: string;
36
+ when: number;
37
+ tag: string;
38
+ breakpoints: boolean;
39
+ }
40
+
41
+ /** A migrations folder truncated at `LAST_LEGACY_TAG`, built from the real files. */
42
+ function legacyMigrationsFolder(into: string): string {
43
+ const source = findMigrationsFolder();
44
+ const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as {
45
+ version: string;
46
+ dialect: string;
47
+ entries: JournalEntry[];
48
+ };
49
+ const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG);
50
+ if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`);
51
+ const kept = journal.entries.slice(0, cutoff + 1);
52
+
53
+ mkdirSync(join(into, 'meta'), { recursive: true });
54
+ for (const entry of kept) {
55
+ cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`));
56
+ }
57
+ writeFileSync(
58
+ join(into, 'meta', '_journal.json'),
59
+ JSON.stringify({ ...journal, entries: kept }, null, 2),
60
+ );
61
+ return into;
62
+ }
63
+
64
+ describe('0027 over a database that already holds an internal DNS ledger', () => {
65
+ let tempDir: string;
66
+ let dbPath: string;
67
+
68
+ beforeEach(() => {
69
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsint-'));
70
+ dbPath = join(tempDir, 'legacy.db');
71
+ });
72
+
73
+ afterEach(() => {
74
+ rmSync(tempDir, { recursive: true, force: true });
75
+ });
76
+
77
+ function openLegacyDatabase(): Database {
78
+ const sqlite = new Database(dbPath, { create: true });
79
+ sqlite.run('PRAGMA foreign_keys = ON');
80
+ migrate(drizzle(sqlite), {
81
+ migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')),
82
+ });
83
+ return sqlite;
84
+ }
85
+
86
+ function seedModule(sqlite: Database, id: string): void {
87
+ sqlite.run(
88
+ 'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)',
89
+ [id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })],
90
+ );
91
+ }
92
+
93
+ function seedLedger(sqlite: Database): void {
94
+ seedModule(sqlite, 'technitium');
95
+ seedModule(sqlite, 'knot-unbound-internal');
96
+ seedModule(sqlite, 'caddy');
97
+ seedModule(sqlite, 'forgejo');
98
+ sqlite.run(
99
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, ?)',
100
+ ['technitium', 'caddy', 'auth.example.org', '192.168.0.253', '10.0.10.14'],
101
+ );
102
+ sqlite.run(
103
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, NULL)',
104
+ ['technitium', 'forgejo', 'git.example.org', '192.168.0.253'],
105
+ );
106
+ }
107
+
108
+ function runCurrentMigrations(sqlite: Database): void {
109
+ migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() });
110
+ }
111
+
112
+ test('the rebuild carries every row across, values intact', () => {
113
+ const sqlite = openLegacyDatabase();
114
+ seedLedger(sqlite);
115
+
116
+ runCurrentMigrations(sqlite);
117
+
118
+ const rows = sqlite
119
+ .query<
120
+ { host: string; ip: string; zone_routable_ip: string | null; provider_module_id: string },
121
+ []
122
+ >(
123
+ 'SELECT host, ip, zone_routable_ip, provider_module_id FROM dns_internal_records ORDER BY host',
124
+ )
125
+ .all();
126
+ expect(rows).toHaveLength(2);
127
+ expect(rows[0]?.host).toBe('auth.example.org');
128
+ // The override is the value whose loss is silent: the resolver keeps
129
+ // answering, just with an address in-zone clients cannot route to.
130
+ expect(rows[0]?.zone_routable_ip).toBe('10.0.10.14');
131
+ expect(rows[1]?.zone_routable_ip).toBeNull();
132
+ // Attribution survives; only its ON DELETE action changed.
133
+ expect(rows[0]?.provider_module_id).toBe('technitium');
134
+ sqlite.close();
135
+ });
136
+
137
+ test('after migrating, removing the PROVIDER no longer empties the ledger', () => {
138
+ const sqlite = openLegacyDatabase();
139
+ seedLedger(sqlite);
140
+ runCurrentMigrations(sqlite);
141
+
142
+ sqlite.run('DELETE FROM modules WHERE id = ?', ['technitium']);
143
+
144
+ expect(
145
+ sqlite.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM dns_internal_records').get()?.c,
146
+ ).toBe(2);
147
+ sqlite.close();
148
+ });
149
+
150
+ /**
151
+ * The behaviour that did NOT change. Dropping a foreign key is an easy way to
152
+ * lose the one you meant to keep, and nothing else would notice.
153
+ */
154
+ test('the consumer cascade still fires after the rebuild', () => {
155
+ const sqlite = openLegacyDatabase();
156
+ seedLedger(sqlite);
157
+ runCurrentMigrations(sqlite);
158
+
159
+ sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']);
160
+
161
+ const left = sqlite
162
+ .query<{ consumer_module_id: string }, []>(
163
+ 'SELECT consumer_module_id FROM dns_internal_records',
164
+ )
165
+ .all();
166
+ expect(left.map((r) => r.consumer_module_id)).toEqual(['forgejo']);
167
+ sqlite.close();
168
+ });
169
+
170
+ /** The unique index is recreated by the rebuild, not left behind with the old table. */
171
+ test('the provider+host uniqueness survives the rebuild', () => {
172
+ const sqlite = openLegacyDatabase();
173
+ seedLedger(sqlite);
174
+ runCurrentMigrations(sqlite);
175
+
176
+ expect(() =>
177
+ sqlite.run(
178
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip) VALUES (?, ?, ?, ?)',
179
+ ['technitium', 'forgejo', 'auth.example.org', '192.168.0.253'],
180
+ ),
181
+ ).toThrow();
182
+ sqlite.close();
183
+ });
184
+ });