@celilo/cli 1.5.0 → 1.7.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 +2 -1
- package/CELILO_SUBSYSTEMS.md +18 -2
- package/MODULE_PRIMITIVES.md +25 -7
- package/drizzle/0026_module_integrity_version.sql +20 -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 +187 -2
- package/src/capabilities/validation.ts +53 -1
- package/src/cli/commands/alerts-sweep.ts +18 -0
- package/src/cli/commands/module-audit.ts +5 -2
- package/src/cli/commands/module-remove.ts +34 -2
- package/src/cli/commands/module-update.test.ts +238 -3
- package/src/cli/commands/module-update.ts +206 -12
- package/src/cli/commands/module-verify.ts +77 -13
- 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-audit.ts +17 -0
- package/src/cli/commands/system-doctor.ts +78 -2
- package/src/cli/commands/system-migrate.ts +6 -4
- package/src/cli/commands/system-update.ts +33 -3
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +11 -2
- package/src/cli/tui/audit-state.ts +11 -3
- package/src/cli/tui/audit-tui.tsx +10 -4
- package/src/cli/tui/icons.ts +9 -2
- package/src/cli/tui/modals/analyzing.tsx +3 -0
- package/src/db/client.ts +10 -8
- package/src/db/migrate.test.ts +147 -0
- package/src/db/migrate.ts +69 -1
- package/src/db/schema.ts +5 -0
- package/src/hooks/capability-loader.test.ts +55 -0
- package/src/hooks/capability-loader.ts +16 -1
- package/src/manifest/json-schema-roundtrip.test.ts +12 -4
- package/src/manifest/schema.ts +23 -0
- package/src/module/import.ts +56 -40
- package/src/module/packaging/audit.ts +103 -28
- package/src/module/packaging/build.ts +12 -53
- package/src/module/packaging/classify-module-path.test.ts +104 -0
- package/src/module/packaging/extract.ts +31 -3
- package/src/module/packaging/generated-plane.test.ts +79 -0
- package/src/module/packaging/generated-plane.ts +134 -0
- package/src/module/packaging/host-plane.test.ts +132 -0
- package/src/module/packaging/host-plane.ts +135 -0
- package/src/module/packaging/package-rules.ts +62 -0
- package/src/policy/module-business-baseline.ts +0 -11
- package/src/services/alerting/monitors.ts +54 -2
- package/src/services/alerting/sweep-runner.ts +38 -1
- package/src/services/audit/cli-version.test.ts +6 -2
- package/src/services/audit/cli-version.ts +20 -6
- package/src/services/audit/detect-without-converge.test.ts +91 -0
- package/src/services/audit/detect-without-converge.ts +81 -0
- package/src/services/audit/disk-space.test.ts +5 -2
- package/src/services/audit/disk-space.ts +5 -3
- package/src/services/audit/health.test.ts +39 -0
- package/src/services/audit/index.test.ts +7 -1
- package/src/services/audit/index.ts +12 -0
- package/src/services/audit/module-integrity.test.ts +146 -0
- package/src/services/audit/module-integrity.ts +113 -0
- package/src/services/audit/module-versions.ts +4 -1
- package/src/services/audit/schema.test.ts +7 -2
- package/src/services/audit/schema.ts +19 -1
- package/src/services/audit/terraform-plan.ts +17 -2
- package/src/services/audit/types.test.ts +29 -0
- package/src/services/audit/types.ts +30 -4
- package/src/services/consumer-cleanup.ts +5 -3
- 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-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 +109 -41
- package/src/services/provider-arrival.test.ts +241 -0
- package/src/services/provider-arrival.ts +213 -0
- package/src/services/restore-from-file.ts +4 -0
- package/src/services/update/orchestrator.test.ts +2 -0
- 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 +10 -2
- package/src/variables/declarative-derivation.test.ts +47 -8
- package/src/variables/declarative-derivation.ts +6 -4
- 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
|
@@ -46,7 +46,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
46
46
|
## Public edge (ingress / identity)
|
|
47
47
|
|
|
48
48
|
- **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` —
|
|
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` — 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
50
|
- **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
51
|
- **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
|
|
52
52
|
|
|
@@ -57,6 +57,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
|
|
|
57
57
|
- **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
58
|
- **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
59
|
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
|
|
60
|
+
- **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
61
|
|
|
61
62
|
## Git forge & CI pipeline
|
|
62
63
|
|
package/CELILO_SUBSYSTEMS.md
CHANGED
|
@@ -96,7 +96,8 @@ Module hooks reach a remote box ONLY through these typed primitives
|
|
|
96
96
|
(exported from `packages/capabilities/src/index.ts`).
|
|
97
97
|
|
|
98
98
|
- **`remoteExec`** — the ONE ssh seam (`ssh <user>@<target> <cmd>`); everything else builds on it. `user`/`port`/`identityFile` on the target default to `root`/22/the agent key, so a fleet call is unchanged; an OFF-FLEET account (`external_web`) sets them.
|
|
99
|
-
- **`probe`** — read-only health checks: `systemd` / `http`
|
|
99
|
+
- **`probe`** — read-only health checks over SSH: `systemd` / `command`. It carried a third `http` kind that SSHed in and ran `curl`; the production `ubuntu-22.04-standard` LXC ships none, and a missing binary was indistinguishable from a dead service. Deleted — `probeHttp` replaces it (`openspec/changes/probe-http-from-management/`).
|
|
100
|
+
- **`probeHttp`** — HTTP health check run FROM the management server over `fetch`; nothing on the target. Takes `port` + `path`, never a URL, and builds the address from the target's `ipv4_address` — so a check cannot name `localhost` and silently probe celilo-mgr's own port. Redirects observed, not followed (caddy's 308 is a healthy answer). Typed failure: `'unreachable'` vs `'status'`.
|
|
100
101
|
- **`serviceCtl`** — systemctl start/stop/restart/reload/enable/disable.
|
|
101
102
|
- **`runAppCommand` / `runAppCommandWithSecret`** — escape-hatch on-box command; the secret variant feeds the secret on **stdin** (`$SECRET`), never argv.
|
|
102
103
|
- **`streamBackup` / `streamRestore` / `fetchFile` / `pushFile`** — binary-safe streaming via local shell redirect/pipe.
|
|
@@ -184,6 +185,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
|
|
|
184
185
|
- **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`.
|
|
185
186
|
- **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).
|
|
186
187
|
- **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/`.
|
|
188
|
+
- **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.
|
|
187
189
|
- **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.
|
|
188
190
|
- **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.
|
|
189
191
|
|
|
@@ -226,13 +228,26 @@ to look, which is how a forgotten pause actually gets found.
|
|
|
226
228
|
|
|
227
229
|
## Packaging, registry & publish
|
|
228
230
|
|
|
229
|
-
- **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts
|
|
231
|
+
- **Module packaging** — `apps/celilo/src/module/packaging/` — `build.ts` (`buildModule`), `extract.ts`, `checksum.ts`, `signature.ts` (`signChecksums`/`verifySignature`), `release-metadata.ts`, `audit.ts`, `package-rules.ts`, `generated-plane.ts`, `host-plane.ts` (see **Module integrity** below).
|
|
230
232
|
- **Publish driver** — `scripts/publish.ts` shims to `apps/celilo/src/cli/commands/publish/` (workspace npm packages via `bun publish` + module registry; preflight stale-version/stale-manifest gates).
|
|
231
233
|
- **Registry publish-token admin (append-safe)** — `apps/celilo/src/cli/commands/registry-token.ts` — `celilo registry token add/rm <token>` read-modify-write the celilo-registry `publish_tokens` secret (newline-separated bootstrap/admin list). Avoids the `module secret set` full-overwrite that clobbered other holders. Runs on-mgr where the master key lives; runtime-minted scoped tokens are handled separately by the registry-server.
|
|
232
234
|
- **Contributor identity tokens (idp-issued, per-user)** — `apps/celilo/src/cli/commands/token.ts` — `celilo token obtain|list|revoke` mints/lists/revokes per-user API tokens via the `idp` capability (`create_token`/`list_tokens`/`revoke_token` on authentik). A module contributor authenticates publishes AS THEMSELVES (SECURE_MODULE_PUBLISH.md §6) — no admin/shared token on their machine; the token feeds `celilo author init`. Shown once at mint; the idp stores it hashed, celilo persists nothing. Runs on-mgr where the idp provider lives. Distinct from `registry token add/rm` (raw bootstrap list, different trust model).
|
|
233
235
|
- **Registry token verification (opaque + idp introspection)** — `packages/registry-server/src/auth.ts` (`TokenAuth` — opaque SHA-256 publish tokens, admin/per-package scope) + `packages/registry-server/src/introspection.ts` (`IntrospectionVerifier` — RFC 7662 verify-bridge, SECURE_MODULE_PUBLISH.md §5[D-A]). `authorizePackage()` in `server.ts` tries the opaque set first (unchanged), then, for a token unknown to it, `identify()`s it via the idp introspection endpoint using the registry's confidential OIDC client creds (`OIDC_INTROSPECTION_ENDPOINT`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`, provisioned on install — ce-7aa), reading `{active, sub, groups, exp}`. Fails CLOSED on any introspection error; never logs tokens/secrets. Instant revocation: revoke at the idp → next publish sees `active:false` → 401.
|
|
234
236
|
- **Registry module-owner table (hybrid group + owner authz — ce-1ch, D-C)** — `packages/registry-server/src/module-owner-store.ts` (`ModuleOwnerStore` — JSON-persisted `{moduleName, ownerSub, claimedAt, sourceGroup}`, `REGISTRY_OWNERS_FILE`/`dataDir/module-owners.json`). The verified `groups` claim gates *whether* an identity may publish (`REGISTRY_ADMIN_GROUP`→publish/reassign anything; `REGISTRY_PUBLISHER_GROUP`, default `celilo-authors`, configurable→claim+publish owned); the owner table gates *which names*. First-publish-claims: the first verified publisher of an unclaimed name owns it; a *different* publisher is then DENIED (confused-deputy defense — Author-A cannot publish Author-B's module). Admin HTTP endpoints `GET /api/v1/modules/owners`, `GET|POST /api/v1/modules/owners/{name}` (reassign). Operator front door: `celilo registry owner list|show|set` (`apps/celilo/src/cli/commands/registry-owner.ts`), admin token resolved from the local `publish_tokens` bootstrap list.
|
|
235
237
|
|
|
238
|
+
## Module integrity (the four places a module exists)
|
|
239
|
+
|
|
240
|
+
A module exists in four places at once, and celilo verifies the correspondence
|
|
241
|
+
between them. Design: `openspec/changes/module-integrity-rigor/design.md`.
|
|
242
|
+
|
|
243
|
+
- **The one classifier** — `apps/celilo/src/module/packaging/package-rules.ts` — `classifyModulePath(relPath)` returns `package` / `derived` / `unknown` and is the SINGLE answer to what belongs to a module. `build.ts#shouldExclude`, `extract.ts#scanDirectory`, `import.ts#copyModuleFiles`, `module-update.ts` and `audit.ts` all route through it. Composes with, and does not restate, `includeNodeModulesPath` (ISS-0046).
|
|
244
|
+
- **The versioned baseline** — `moduleIntegrity` in `apps/celilo/src/db/schema.ts` — checksums PLUS the `version` they describe. Upserted by `module import` and written by `module-update.ts#updateOne`, so it tracks the installed version instead of freezing at first import. `version` is nullable: NULL means "written before celilo stamped versions", which verify reports rather than papers over.
|
|
245
|
+
- **Plane 1, installed tree vs baseline** — `apps/celilo/src/module/packaging/audit.ts` — `auditModule(moduleId, db, { deep })`. The entry point for `module verify`.
|
|
246
|
+
- **Plane 2, generated project vs installed tree** — `apps/celilo/src/module/packaging/generated-plane.ts` — `compareVerbatimRoleAssets` (pure), `readVerbatimRoleAssets`, `refuseIfGeneratedIsStale`. Only `ansible/roles/<role>/files/**` has a meaningful expected digest; Ansible templates the rest. Wired into `module verify` and into `module-deploy.ts` as a pre-flight that refuses BEFORE contacting any system (celilo#925).
|
|
247
|
+
- **Plane 3, host vs generated project** — `apps/celilo/src/module/packaging/host-plane.ts` — `verifyModuleOnHosts` runs the generated playbook through `executeAnsible(..., { check: true })` and classifies each `PLAY RECAP` line as `converged` / `drift` / `unmeasured`. One SSH per system, so `--deep` only. A module opts out with `verify: { deep: false, reason: … }`; the reason is required and is printed.
|
|
248
|
+
- **Surfaces** — `celilo module verify <id> [--deep] [--json]` (`apps/celilo/src/cli/commands/module-verify.ts`), the `module_integrity` audit category (`apps/celilo/src/services/audit/module-integrity.ts`), and `system doctor`'s fleet section. Same implementation behind all three.
|
|
249
|
+
- **Dead convergence machinery** — `apps/celilo/src/services/audit/detect-without-converge.ts` — the `detect_without_converge` category reports a module declaring a `reconcile_*` / `refresh_registrations` / `reassert_dhcp_dns` hook that no subscription ever fires (celilo#934).
|
|
250
|
+
|
|
236
251
|
## Alerting & notifications
|
|
237
252
|
|
|
238
253
|
Observation and delivery: monitors run checks on a schedule, alerts hold what
|
|
@@ -291,6 +306,7 @@ upgrade controls — as opposed to how the module configures itself.
|
|
|
291
306
|
## Persistence
|
|
292
307
|
|
|
293
308
|
- **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/`.
|
|
309
|
+
- **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).
|
|
294
310
|
|
|
295
311
|
## Events
|
|
296
312
|
|
package/MODULE_PRIMITIVES.md
CHANGED
|
@@ -64,12 +64,25 @@ that, not a primitive. The primitives are for the module's *own* box.
|
|
|
64
64
|
### `probe(target, check, runner?, opts?) → { healthy, detail }`
|
|
65
65
|
Read-only health check. Never mutates. `check` is one of:
|
|
66
66
|
- `{ kind: 'systemd', unit }` — unit is `active`.
|
|
67
|
-
- `{ kind: 'http', url, expectStatus?, headers? }` — curl runs **on the box**, so
|
|
68
|
-
it doesn't depend on Caddy/public DNS. `expectStatus` defaults to 200; pass a
|
|
69
|
-
list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
|
|
70
67
|
- `{ kind: 'command', command, expectStdoutIncludes? }`.
|
|
68
|
+
|
|
69
|
+
For HTTP use `probeHttp`, not `probe` — see below.
|
|
71
70
|
```ts
|
|
72
|
-
probe(sys, { kind: '
|
|
71
|
+
probe(sys, { kind: 'systemd', unit: 'caddy' }, run);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `probeHttp(target, { port, path?, expectStatus?, headers?, timeoutMs? }) → Promise<{ healthy, detail, failure? }>`
|
|
75
|
+
HTTP health check, run **from the management server** over `fetch`. Nothing runs
|
|
76
|
+
on the target — no SSH, no curl (the production LXC image ships none, and a
|
|
77
|
+
missing binary used to be indistinguishable from a dead service). There is
|
|
78
|
+
deliberately **no `url`**: you give a port and a path, and the primitive builds
|
|
79
|
+
the address from the target's `ipv4_address`, so a check cannot name `localhost`
|
|
80
|
+
and end up probing celilo-mgr itself. `expectStatus` defaults to 200; pass a
|
|
81
|
+
list (e.g. `[200, 308]`) or `headers: { Host: 'app.example.com' }` for a vhost.
|
|
82
|
+
Redirects are **observed, not followed**. On failure, `failure` is
|
|
83
|
+
`'unreachable'` (could not connect) or `'status'` (answered with the wrong code).
|
|
84
|
+
```ts
|
|
85
|
+
await probeHttp(sys, { port: 80, headers: { Host: fqdn }, expectStatus: [200, 308] });
|
|
73
86
|
```
|
|
74
87
|
|
|
75
88
|
### `serviceCtl(target, unit, action, runner?, opts?) → RunResult`
|
|
@@ -79,7 +92,7 @@ for liveness use `probe`.
|
|
|
79
92
|
serviceCtl(sys, 'caddy', 'reload', run);
|
|
80
93
|
```
|
|
81
94
|
|
|
82
|
-
### `applyRenderedConfig({ target, path, content, validate, apply, runner?, timeoutMs? }) → RunResult`
|
|
95
|
+
### `applyRenderedConfig({ target, path, content, validate, apply, statePath?, runner?, timeoutMs? }) → RunResult`
|
|
83
96
|
**The converge primitive.** For config whose *content* your hook computes at
|
|
84
97
|
deploy-time from celilo's DB (caddy's Caddyfile from `web_routes`, knot's views
|
|
85
98
|
from the DNS ledger, the firewall ruleset from the port-forward registry) —
|
|
@@ -87,6 +100,11 @@ content a static Ansible render can't know. One atomic on-box script, one SSH
|
|
|
87
100
|
round-trip: back up → write (`content` rides **stdin**, newline-safe) → `validate`
|
|
88
101
|
(rolled back + not activated if it fails) → `apply` (rolled back if it fails).
|
|
89
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.
|
|
90
108
|
```ts
|
|
91
109
|
applyRenderedConfig({
|
|
92
110
|
target: sys, path: '/etc/caddy/Caddyfile', content: rendered,
|
|
@@ -97,11 +115,11 @@ applyRenderedConfig({
|
|
|
97
115
|
|
|
98
116
|
### `waitFor(predicate, { attempts?, intervalMs?, onAttempt? }) → Promise<boolean>`
|
|
99
117
|
Poll an async predicate until true or exhausted (default 30 × 5s). A combinator —
|
|
100
|
-
it never touches the remote; wrap a `probe`/`runAppCommand` thunk. Use
|
|
118
|
+
it never touches the remote; wrap a `probe`/`probeHttp`/`runAppCommand` thunk. Use
|
|
101
119
|
`onAttempt` to emit a heartbeat so a long wait doesn't trip the hook idle-timeout.
|
|
102
120
|
Never `sleep`.
|
|
103
121
|
```ts
|
|
104
|
-
await waitFor(() =>
|
|
122
|
+
await waitFor(async () => (await probeHttp(sys, { port: 3000 })).healthy,
|
|
105
123
|
{ onAttempt: (n) => logger.info(`waiting… ${n * 5}s`) });
|
|
106
124
|
```
|
|
107
125
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- The integrity baseline records WHICH VERSION its checksums describe
|
|
2
|
+
-- (openspec/changes/module-integrity-rigor, D1).
|
|
3
|
+
--
|
|
4
|
+
-- Without it `module verify` cannot distinguish "the files are old" from "the
|
|
5
|
+
-- checksums are old", and it reported the same violations either way. The row
|
|
6
|
+
-- was written once, by a plain INSERT on a UNIQUE column inside a try/catch
|
|
7
|
+
-- that only warned, and `module update` never touched the table at all — so a
|
|
8
|
+
-- module imported once and updated ten times still carried its first import's
|
|
9
|
+
-- checksums, and every file that had legitimately changed since read as
|
|
10
|
+
-- [MODIFIED]. That was the whole of the manifest.yml / README.md / scripts/*
|
|
11
|
+
-- block in the fleet's 47- and 25-violation outputs.
|
|
12
|
+
--
|
|
13
|
+
-- NULLABLE on purpose, and this is the Rule 3.4 exception rather than a missing
|
|
14
|
+
-- default: NULL means "written before celilo stamped versions", which is a
|
|
15
|
+
-- fact verify must be able to state. A default of '' or of the module's current
|
|
16
|
+
-- version would fabricate a correspondence nobody measured — exactly the
|
|
17
|
+
-- failure this change exists to remove. Existing rows keep NULL until the next
|
|
18
|
+
-- `module import` or `module update` rewrites them.
|
|
19
|
+
|
|
20
|
+
ALTER TABLE `module_integrity` ADD `version` text;
|
|
@@ -183,6 +183,13 @@
|
|
|
183
183
|
"when": 1784100000000,
|
|
184
184
|
"tag": "0025_port_forward_owner",
|
|
185
185
|
"breakpoints": true
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"idx": 26,
|
|
189
|
+
"version": "6",
|
|
190
|
+
"when": 1784200000000,
|
|
191
|
+
"tag": "0026_module_integrity_version",
|
|
192
|
+
"breakpoints": true
|
|
186
193
|
}
|
|
187
194
|
]
|
|
188
|
-
}
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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.4.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,
|