@celilo/cli 1.6.0 → 1.8.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 +3 -1
- package/CELILO_SUBSYSTEMS.md +7 -1
- package/MODULE_PRIMITIVES.md +6 -1
- package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/capabilities/lookup.ts +39 -29
- package/src/capabilities/secret-ref.test.ts +24 -0
- package/src/capabilities/secret-validation.ts +50 -0
- package/src/capabilities/validation.test.ts +238 -2
- package/src/capabilities/validation.ts +67 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +149 -2
- package/src/cli/commands/module-update.ts +113 -25
- package/src/cli/commands/service-set-credentials.test.ts +108 -0
- package/src/cli/commands/service-set-credentials.ts +115 -0
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +9 -0
- package/src/db/client.ts +10 -8
- package/src/db/dns-internal-cascade-migration.test.ts +184 -0
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +21 -4
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/template-validator.test.ts +47 -0
- package/src/manifest/template-validator.ts +18 -1
- package/src/module/import.ts +39 -6
- package/src/policy/capability-shape-baseline.ts +88 -0
- package/src/policy/capability-shape-drift.test.ts +162 -0
- package/src/policy/capability-shape.ts +117 -0
- package/src/policy/dns-aspect-coverage.test.ts +100 -0
- package/src/policy/module-business-baseline.ts +32 -18
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/capability-table-rows.test.ts +191 -0
- package/src/services/capability-table-rows.ts +103 -0
- package/src/services/consumer-cleanup.ts +18 -10
- package/src/services/container-service.test.ts +34 -0
- package/src/services/container-service.ts +44 -0
- package/src/services/deployed-systems.test.ts +101 -0
- package/src/services/deployed-systems.ts +43 -11
- package/src/services/dns-internal-records.test.ts +72 -1
- package/src/services/dns-provider-backfill.ts +30 -0
- package/src/services/fleet-checks.test.ts +26 -0
- package/src/services/fleet-checks.ts +11 -1
- package/src/services/module-deploy.ts +88 -41
- package/src/services/module-validator/capability-versions.test.ts +6 -1
- package/src/services/port-forwards.test.ts +6 -2
- package/src/services/port-forwards.ts +0 -11
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/trusted-sources.ts +0 -5
- package/src/templates/generator.test.ts +35 -0
- package/src/templates/generator.ts +29 -1
- package/src/variables/context.test.ts +63 -0
- package/src/variables/context.ts +85 -12
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- package/src/variables/lxc-nameserver.test.ts +144 -0
- package/src/services/public-web-republish.test.ts +0 -189
- package/src/services/public-web-republish.ts +0 -84
package/CELILO_CORE_MODULES.md
CHANGED
|
@@ -37,6 +37,7 @@ 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/`).
|
|
41
42
|
- **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
43
|
- **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`.
|
|
@@ -46,7 +47,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
46
47
|
## Public edge (ingress / identity)
|
|
47
48
|
|
|
48
49
|
- **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`.
|
|
49
|
-
- **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 exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those 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 **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`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). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. 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` —
|
|
50
|
+
- **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 exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those 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 **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`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). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. 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` — a hard requirement, since `on_install` throws without it. It sat under `optional.capabilities` until celilo#854 was fixed, because the import-time secret gate refused a consumer over a secret it never reads.
|
|
50
51
|
- **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`.
|
|
51
52
|
- **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
|
|
52
53
|
|
|
@@ -57,6 +58,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
57
58
|
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
|
|
58
59
|
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
|
|
59
60
|
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
|
|
61
|
+
- **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.
|
|
60
62
|
|
|
61
63
|
## Git forge & CI pipeline
|
|
62
64
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -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`).
|
|
@@ -185,6 +189,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
185
189
|
- **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/specs/module-pause/spec.md`.
|
|
186
190
|
- **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).
|
|
187
191
|
- **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/`.
|
|
192
|
+
- **Provider-arrival backfill (every consumer is re-run)** — `apps/celilo/src/services/provider-arrival.ts` — pure `planProviderBackfill` + `loadProviderBackfillPlan` + `runProviderBackfill`, called from `module-deploy.ts` at the end of BOTH deploy paths (config-only and full). The mirror of consumer-removal cleanup, and it exists because celilo handled one side of a capability generically and the other by hand: a provider arriving had three hand-written pieces covering two capabilities, and `firewall` — provided by `axon`, `greenwave` AND `iptables` — had none, so its consumers inherited nothing (celilo#1011). When a module deploys, every already-installed module declaring one of its capabilities under `requires` OR `optional` has its `on_install` re-run. **PULL, never push** (design D7): the consumer re-registers through the path that worked the first time, rather than core replaying history into the provider by calling the provider's own hooks on a consumer's behalf — the push shape is a second implementation with its own bug surface, and `backfillWebRouteDns` had already drifted into one. Never fatal to the provider's own deploy: a failed consumer is named with `celilo module deploy <id>` to retry, and a PAUSED or never-deployed consumer is reported as skipped rather than silently passed over. Shares `PRE_DEPLOY_STATES` with `consumer-cleanup.ts` — one definition, also read by `module-remove.ts`. It replaced `services/public-web-republish.ts` (deleted) and deliberately did NOT replace `services/dns-provider-backfill.ts`, whose docblock records the two reasons: one half replays celilo's HOST inventory rather than a capability set, and the other covers FQDNs published through `public_web` by modules that declare `public_web` and never `dns_internal`. See `openspec/changes/capability-owned-tables/` stage 1.
|
|
188
193
|
- **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.
|
|
189
194
|
- **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.
|
|
190
195
|
|
|
@@ -305,6 +310,7 @@ upgrade controls — as opposed to how the module configures itself.
|
|
|
305
310
|
## Persistence
|
|
306
311
|
|
|
307
312
|
- **DB schema** — `apps/celilo/src/db/schema.ts`. Client: `apps/celilo/src/db/client.ts`. Migration runner: `apps/celilo/src/db/migrate.ts`. Migrations: `apps/celilo/drizzle/`.
|
|
313
|
+
- **Frozen-watermark repair** — `runMigrationsOn` (`db/migrate.ts`) is what `createDbClient` calls on open, not drizzle's migrator directly. drizzle is watermark-only, so a DB from the imperative hand-list era — schema applied, `__drizzle_migrations` never told — makes it re-run migrations and die on `duplicate column name`. That throw is inside the OPEN, so every celilo command on that box fails, `celilo system migrate` included: it reaches its own repair through `getDb()`. The repair engages only after the stock migrator has failed AND only when `findSchemaDrift` reports the declared schema entirely present, and it corrects the LEDGER without running SQL — replaying is not an option, since `drizzle/0021_dns_registration_consumers.sql` rebuilds `dns_registrations` by dropping the original and renaming a copy over it. A PARTIALLY applied schema (celilo-mgr's own pre-remediation state) still fails, naming the hand remediation. Gate: `db/migrate.test.ts`. Both entrypoints run `celilo system migrate` — the `.deb` postinst and the `celilo-mgmt` Ansible role, before the dispatcher starts (celilo#169).
|
|
308
314
|
|
|
309
315
|
## Events
|
|
310
316
|
|
package/MODULE_PRIMITIVES.md
CHANGED
|
@@ -92,7 +92,7 @@ for liveness use `probe`.
|
|
|
92
92
|
serviceCtl(sys, 'caddy', 'reload', run);
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
### `applyRenderedConfig({ target, path, content, validate, apply, runner?, timeoutMs? }) → RunResult`
|
|
95
|
+
### `applyRenderedConfig({ target, path, content, validate, apply, statePath?, runner?, timeoutMs? }) → RunResult`
|
|
96
96
|
**The converge primitive.** For config whose *content* your hook computes at
|
|
97
97
|
deploy-time from celilo's DB (caddy's Caddyfile from `web_routes`, knot's views
|
|
98
98
|
from the DNS ledger, the firewall ruleset from the port-forward registry) —
|
|
@@ -100,6 +100,11 @@ content a static Ansible render can't know. One atomic on-box script, one SSH
|
|
|
100
100
|
round-trip: back up → write (`content` rides **stdin**, newline-safe) → `validate`
|
|
101
101
|
(rolled back + not activated if it fails) → `apply` (rolled back if it fails).
|
|
102
102
|
`{path}` is substituted into `validate`/`apply`.
|
|
103
|
+
|
|
104
|
+
By default, rollback and last-render state are retained beside `path` as
|
|
105
|
+
`.celilo-bak` and `.celilo-prev`. Set `statePath` to a prefix outside daemon
|
|
106
|
+
include directories (such as `/etc/dnsmasq.d`) when the daemon parses every
|
|
107
|
+
neighboring file.
|
|
103
108
|
```ts
|
|
104
109
|
applyRenderedConfig({
|
|
105
110
|
target: sys, path: '/etc/caddy/Caddyfile', content: rendered,
|
|
@@ -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`);
|
|
@@ -190,6 +190,13 @@
|
|
|
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
|
|
193
200
|
}
|
|
194
201
|
]
|
|
195
|
-
}
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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": "^2.
|
|
61
|
+
"@celilo/capabilities": "^2.6.0",
|
|
62
62
|
"@celilo/cli-display": "^0.2.0",
|
|
63
|
-
"@celilo/core": "^0.9.
|
|
63
|
+
"@celilo/core": "^0.9.1",
|
|
64
64
|
"@celilo/event-bus": "^0.6.0",
|
|
65
65
|
"ajv": "^8.18.0",
|
|
66
66
|
"drizzle-orm": "^0.36.4",
|
|
@@ -18,6 +18,43 @@ export interface CapabilityProviderInfo {
|
|
|
18
18
|
zones: string[] | null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type CapabilityProviderRow = {
|
|
22
|
+
id: number;
|
|
23
|
+
moduleId: string;
|
|
24
|
+
capabilityName: string;
|
|
25
|
+
version: string;
|
|
26
|
+
data: Record<string, unknown>;
|
|
27
|
+
zones: string[] | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Select a provider from an already-loaded candidate set.
|
|
32
|
+
*
|
|
33
|
+
* Keeping this policy separate from the database query lets runtime consumers
|
|
34
|
+
* that already loaded every provider (notably the hook capability loader) use
|
|
35
|
+
* exactly the same explicit-zone-first semantics as direct lookups.
|
|
36
|
+
*/
|
|
37
|
+
export function selectCapabilityProvider(
|
|
38
|
+
all: CapabilityProviderRow[],
|
|
39
|
+
zone?: string,
|
|
40
|
+
): CapabilityProviderInfo | null {
|
|
41
|
+
if (all.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
if (zone) {
|
|
44
|
+
const zoneMatch = all.find((candidate) => candidate.zones?.includes(zone));
|
|
45
|
+
if (zoneMatch) return toInfo(zoneMatch);
|
|
46
|
+
|
|
47
|
+
const agnostic = all.find(
|
|
48
|
+
(candidate) => candidate.zones === null || candidate.zones === undefined,
|
|
49
|
+
);
|
|
50
|
+
if (agnostic) return toInfo(agnostic);
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return toInfo(all[0]);
|
|
56
|
+
}
|
|
57
|
+
|
|
21
58
|
/**
|
|
22
59
|
* Find a capability provider, optionally filtered by zone.
|
|
23
60
|
*
|
|
@@ -35,27 +72,7 @@ export function findCapabilityProvider(
|
|
|
35
72
|
zone?: string,
|
|
36
73
|
): CapabilityProviderInfo | null {
|
|
37
74
|
const all = db.select().from(capabilities).where(eq(capabilities.capabilityName, name)).all();
|
|
38
|
-
|
|
39
|
-
if (all.length === 0) return null;
|
|
40
|
-
|
|
41
|
-
if (zone) {
|
|
42
|
-
// First: try to find a provider that explicitly covers this zone
|
|
43
|
-
const zoneMatch = all.find((c) => {
|
|
44
|
-
const zones = c.zones as string[] | null;
|
|
45
|
-
return zones?.includes(zone);
|
|
46
|
-
});
|
|
47
|
-
if (zoneMatch) return toInfo(zoneMatch);
|
|
48
|
-
|
|
49
|
-
// Second: fall back to zone-agnostic provider (zones is null)
|
|
50
|
-
const agnostic = all.find((c) => c.zones === null || c.zones === undefined);
|
|
51
|
-
if (agnostic) return toInfo(agnostic);
|
|
52
|
-
|
|
53
|
-
// No match for this zone
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// No zone specified: return first provider
|
|
58
|
-
return toInfo(all[0]);
|
|
75
|
+
return selectCapabilityProvider(all, zone);
|
|
59
76
|
}
|
|
60
77
|
|
|
61
78
|
/**
|
|
@@ -70,14 +87,7 @@ export function findAllCapabilityProviders(name: string, db: DbClient): Capabili
|
|
|
70
87
|
.map(toInfo);
|
|
71
88
|
}
|
|
72
89
|
|
|
73
|
-
function toInfo(row: {
|
|
74
|
-
id: number;
|
|
75
|
-
moduleId: string;
|
|
76
|
-
capabilityName: string;
|
|
77
|
-
version: string;
|
|
78
|
-
data: Record<string, unknown>;
|
|
79
|
-
zones: string[] | null;
|
|
80
|
-
}): CapabilityProviderInfo {
|
|
90
|
+
function toInfo(row: CapabilityProviderRow): CapabilityProviderInfo {
|
|
81
91
|
return {
|
|
82
92
|
id: row.id,
|
|
83
93
|
moduleId: row.moduleId,
|
|
@@ -10,6 +10,7 @@ import { join } from 'node:path';
|
|
|
10
10
|
import { createDbClient } from '@/db/client';
|
|
11
11
|
import { encryptSecret } from '@/secrets/encryption';
|
|
12
12
|
import { getOrCreateMasterKey } from '@/secrets/master-key';
|
|
13
|
+
import { validateCapabilitySecrets } from './secret-validation';
|
|
13
14
|
import { getCapabilitySecret } from './secrets';
|
|
14
15
|
|
|
15
16
|
let testDirs: string[] = [];
|
|
@@ -108,6 +109,11 @@ describe('Capability secret_ref resolution', () => {
|
|
|
108
109
|
)
|
|
109
110
|
.run(capabilityId.id, 'tsig', 'TSIG secret for DNS updates');
|
|
110
111
|
|
|
112
|
+
// A configured secret_ref satisfies generation validation without
|
|
113
|
+
// duplicating the secret into capability_secrets.
|
|
114
|
+
const validation = await validateCapabilitySecrets('dns-external', db.$client);
|
|
115
|
+
expect(validation).toEqual({ success: true });
|
|
116
|
+
|
|
111
117
|
// Test: Resolve capability secret via secret_ref
|
|
112
118
|
const result = await getCapabilitySecret('dns_external', 'tsig', db.$client);
|
|
113
119
|
|
|
@@ -172,6 +178,17 @@ describe('Capability secret_ref resolution', () => {
|
|
|
172
178
|
)
|
|
173
179
|
.run(capabilityId.id, 'tsig');
|
|
174
180
|
|
|
181
|
+
const validation = await validateCapabilitySecrets('dns-external', db.$client);
|
|
182
|
+
expect(validation.success).toBe(false);
|
|
183
|
+
expect(validation.missingSecrets).toEqual([
|
|
184
|
+
{
|
|
185
|
+
capabilityId: capabilityId.id,
|
|
186
|
+
capabilityName: 'dns_external',
|
|
187
|
+
secretName: 'tsig',
|
|
188
|
+
description: null,
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
|
|
175
192
|
// Test: Should throw error about missing module secret
|
|
176
193
|
await expect(getCapabilitySecret('dns_external', 'tsig', db.$client)).rejects.toThrow(
|
|
177
194
|
"Module secret 'nonexistent_secret' not found",
|
|
@@ -241,6 +258,9 @@ describe('Capability secret_ref resolution', () => {
|
|
|
241
258
|
)
|
|
242
259
|
.run(capabilityId.id, 'api_key', encrypted.encryptedValue, encrypted.iv, encrypted.authTag);
|
|
243
260
|
|
|
261
|
+
const validation = await validateCapabilitySecrets('test-module', db.$client);
|
|
262
|
+
expect(validation).toEqual({ success: true });
|
|
263
|
+
|
|
244
264
|
// Test: Should read from capability_secrets table
|
|
245
265
|
const result = await getCapabilitySecret('test_capability', 'api_key', db.$client);
|
|
246
266
|
|
|
@@ -305,6 +325,10 @@ describe('Capability secret_ref resolution', () => {
|
|
|
305
325
|
)
|
|
306
326
|
.run(capabilityId.id, 'api_key');
|
|
307
327
|
|
|
328
|
+
const validation = await validateCapabilitySecrets('test-module', db.$client);
|
|
329
|
+
expect(validation.success).toBe(false);
|
|
330
|
+
expect(validation.missingSecrets?.map((secret) => secret.secretName)).toEqual(['api_key']);
|
|
331
|
+
|
|
308
332
|
// Test: Should throw helpful error
|
|
309
333
|
await expect(getCapabilitySecret('test_capability', 'api_key', db.$client)).rejects.toThrow(
|
|
310
334
|
"Secret 'api_key' in capability 'test_capability' has not been set",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Database } from 'bun:sqlite';
|
|
7
7
|
import { celiloIntro, promptPassword } from '../cli/prompts';
|
|
8
|
+
import type { ModuleManifest } from '../manifest/schema';
|
|
8
9
|
import { encryptSecret } from '../secrets/encryption';
|
|
9
10
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
10
11
|
|
|
@@ -21,6 +22,46 @@ export interface SecretValidationResult {
|
|
|
21
22
|
missingSecrets?: MissingCapabilitySecret[];
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Check whether an unset capability-secret row is backed by a configured
|
|
27
|
+
* provider module secret through `secret_ref`.
|
|
28
|
+
*
|
|
29
|
+
* Capability registration deliberately stores metadata-only rows even when
|
|
30
|
+
* the manifest delegates storage to `$secret:<name>`. In that case the NULL
|
|
31
|
+
* capability value is expected and must not trigger a second secret prompt.
|
|
32
|
+
*/
|
|
33
|
+
function hasConfiguredSecretRef(
|
|
34
|
+
moduleId: string,
|
|
35
|
+
manifest: ModuleManifest | null,
|
|
36
|
+
capabilityName: string,
|
|
37
|
+
secretName: string,
|
|
38
|
+
db: Database,
|
|
39
|
+
): boolean {
|
|
40
|
+
const capability = manifest?.provides?.capabilities?.find(
|
|
41
|
+
(candidate) => candidate.name === capabilityName,
|
|
42
|
+
);
|
|
43
|
+
const secret = capability?.secrets?.find((candidate) => candidate.name === secretName);
|
|
44
|
+
const match = secret?.secret_ref?.match(/^\$secret:(.+)$/);
|
|
45
|
+
|
|
46
|
+
if (!match) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const referencedSecret = db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT 1
|
|
53
|
+
FROM secrets
|
|
54
|
+
WHERE module_id = ? AND name = ?
|
|
55
|
+
AND encrypted_value IS NOT NULL
|
|
56
|
+
AND iv IS NOT NULL
|
|
57
|
+
AND auth_tag IS NOT NULL
|
|
58
|
+
LIMIT 1`,
|
|
59
|
+
)
|
|
60
|
+
.get(moduleId, match[1]);
|
|
61
|
+
|
|
62
|
+
return referencedSecret !== null && referencedSecret !== undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
24
65
|
/**
|
|
25
66
|
* Check if module has any missing capability secrets
|
|
26
67
|
*
|
|
@@ -43,6 +84,11 @@ export async function validateCapabilitySecrets(
|
|
|
43
84
|
return { success: true }; // No capabilities = no secrets needed
|
|
44
85
|
}
|
|
45
86
|
|
|
87
|
+
const moduleResult = db.prepare('SELECT manifest_data FROM modules WHERE id = ?').get(moduleId) as
|
|
88
|
+
| { manifest_data: string }
|
|
89
|
+
| undefined;
|
|
90
|
+
const manifest = moduleResult ? (JSON.parse(moduleResult.manifest_data) as ModuleManifest) : null;
|
|
91
|
+
|
|
46
92
|
// Check for secrets with NULL encrypted_value
|
|
47
93
|
const missingSecrets: MissingCapabilitySecret[] = [];
|
|
48
94
|
|
|
@@ -56,6 +102,10 @@ export async function validateCapabilitySecrets(
|
|
|
56
102
|
.all(capability.id) as Array<{ name: string; description: string | null }>;
|
|
57
103
|
|
|
58
104
|
for (const secret of secrets) {
|
|
105
|
+
if (hasConfiguredSecretRef(moduleId, manifest, capability.capability_name, secret.name, db)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
59
109
|
missingSecrets.push({
|
|
60
110
|
capabilityId: capability.id,
|
|
61
111
|
capabilityName: capability.capability_name,
|