@celilo/cli 1.7.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.
@@ -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`.
@@ -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`).
@@ -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.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^2.4.0",
61
+ "@celilo/capabilities": "^2.6.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
63
  "@celilo/core": "^0.9.1",
64
64
  "@celilo/event-bus": "^0.6.0",
@@ -824,5 +824,56 @@ describe('Capability Access Validation', () => {
824
824
  expect(refused.success).toBe(false);
825
825
  expect(refused.error).toContain('tsig_key');
826
826
  });
827
+
828
+ /**
829
+ * A reference in a TEMPLATE counts too. `resolver.ts` does refuse one at
830
+ * the point of use, so nothing is unsafe without this, but the refusal
831
+ * lands at generation rather than at import.
832
+ *
833
+ * That gap sits exactly where the repo sends people. CLAUDE.md's module
834
+ * Definition of Done says "Capability variable usage — Templates use
835
+ * `$capability:` syntax" and gives a `.tf.tpl` example, so the first author
836
+ * who follows that instruction with a RESTRICTED secret is the one who
837
+ * finds out late. No module in the tree references a capability from a
838
+ * template today, which is why the gap is currently invisible rather than
839
+ * absent.
840
+ */
841
+ test('a secret named only in a template is refused at import', async () => {
842
+ const manifest: ModuleManifest = {
843
+ celilo_contract: '1.0',
844
+ id: 'nosy-app',
845
+ name: 'Nosy App',
846
+ version: '1.0.0',
847
+ description: 'Test',
848
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
849
+ provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
850
+ variables: { owns: [], imports: [] },
851
+ };
852
+
853
+ // The manifest names nothing. Only the .tf.tpl does.
854
+ const result = await validateCapabilityAccess(manifest, knotDb, ['dns_internal.tsig_key']);
855
+
856
+ expect(result.success).toBe(false);
857
+ expect(result.error).toContain("cannot access secret 'tsig_key'");
858
+ });
859
+
860
+ test('a non-secret template reference is still not a secret reference', async () => {
861
+ const manifest: ModuleManifest = {
862
+ celilo_contract: '1.0',
863
+ id: 'caddy-internal',
864
+ name: 'Caddy (fleet-only ingress)',
865
+ version: '1.0.0',
866
+ description: 'Test',
867
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
868
+ provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
869
+ variables: { owns: [], imports: [] },
870
+ };
871
+
872
+ const result = await validateCapabilityAccess(manifest, knotDb, [
873
+ 'dns_internal.server.ip.primary',
874
+ ]);
875
+
876
+ expect(result.success).toBe(true);
877
+ });
827
878
  });
828
879
  });
@@ -21,11 +21,16 @@ export interface ValidationResult {
21
21
  *
22
22
  * @param manifest - Consumer module manifest
23
23
  * @param db - Database connection
24
+ * @param templateReferences - `<capability>.<path>` references found outside
25
+ * the manifest, i.e. in the module's templates. Empty is the honest default
26
+ * for a caller holding no template context; `module import` passes what the
27
+ * template validator already parsed.
24
28
  * @returns Validation result
25
29
  */
26
30
  export async function validateCapabilityAccess(
27
31
  manifest: ModuleManifest,
28
32
  db: Database,
33
+ templateReferences: readonly string[] = [],
29
34
  ): Promise<ValidationResult> {
30
35
  // If module doesn't require capabilities, validation passes
31
36
  if (!manifest.requires?.capabilities || manifest.requires.capabilities.length === 0) {
@@ -35,7 +40,7 @@ export async function validateCapabilityAccess(
35
40
  // Get list of capabilities this module provides
36
41
  const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name);
37
42
 
38
- const references = collectCapabilityReferences(manifest);
43
+ const references = collectCapabilityReferences(manifest, templateReferences);
39
44
 
40
45
  // Check each required capability
41
46
  for (const requiredCapability of manifest.requires.capabilities) {
@@ -105,16 +110,25 @@ export async function validateCapabilityAccess(
105
110
  * lives — a `variables.owns[].derive_from`, a default, a capability data block —
106
111
  * without this having to track which fields may hold one.
107
112
  *
108
- * References made from a module's TEMPLATES are deliberately not collected here.
109
- * `src/variables/resolver.ts` checks access at the point of use, which is the
110
- * only place a template reference can be seen, and it refuses there.
113
+ * Template references are included too, supplied by the caller. `resolver.ts`
114
+ * does refuse a template reference at the point of use, so nothing is unsafe
115
+ * without them, but the refusal lands at generation rather than at import. That
116
+ * matters because a module's TEMPLATES are where this repo tells authors to put
117
+ * these: the Definition of Done in CLAUDE.md says "Capability variable usage —
118
+ * Templates use `$capability:` syntax" and gives a `.tf.tpl` example. A gate
119
+ * that does not fire where the documentation sends people is a gate with a hole
120
+ * in the shape of the instructions.
111
121
  */
112
- function collectCapabilityReferences(manifest: ModuleManifest): Set<string> {
113
- return new Set(
114
- parseVariables(JSON.stringify(manifest))
122
+ function collectCapabilityReferences(
123
+ manifest: ModuleManifest,
124
+ templateReferences: readonly string[] = [],
125
+ ): Set<string> {
126
+ return new Set([
127
+ ...parseVariables(JSON.stringify(manifest))
115
128
  .filter((variable) => variable.type === 'capability')
116
129
  .map((variable) => variable.path),
117
- );
130
+ ...templateReferences,
131
+ ]);
118
132
  }
119
133
 
120
134
  /**
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Migration gate for celilo#1010, over a database that already holds records.
3
+ *
4
+ * `0027` rebuilds `dns_internal_records` to drop the foreign key on
5
+ * `provider_module_id`. A rebuild is copy, drop, rename — the most destructive
6
+ * shape a migration takes — and it will run on celilo-mgr, which holds the live
7
+ * internal DNS ledger including the `zone_routable_ip` view overrides the
8
+ * resolver's split-horizon config is reconciled from.
9
+ *
10
+ * Every other test in the suite starts from an empty database and can only prove
11
+ * the forward invariant. This one builds a database at the schema BEFORE the
12
+ * change, puts real rows in it, and runs the real migrator over it — the same
13
+ * shape as `dns-registrations-migration.test.ts`, and for the same reason: the
14
+ * installed base is where the risk is, and a clean-start suite cannot see it.
15
+ *
16
+ * Task 2.6 asks for backup and restore proven before this runs on celilo-mgr.
17
+ * This is the stronger half of that: it proves the migration PRESERVES the rows,
18
+ * so a restore is the fallback rather than the plan.
19
+ */
20
+
21
+ import { Database } from 'bun:sqlite';
22
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
23
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { drizzle } from 'drizzle-orm/bun-sqlite';
27
+ import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
28
+ import { findMigrationsFolder } from './client';
29
+
30
+ /** The last migration before this change — the schema celilo-mgr is on today. */
31
+ const LAST_LEGACY_TAG = '0026_module_integrity_version';
32
+
33
+ interface JournalEntry {
34
+ idx: number;
35
+ version: string;
36
+ when: number;
37
+ tag: string;
38
+ breakpoints: boolean;
39
+ }
40
+
41
+ /** A migrations folder truncated at `LAST_LEGACY_TAG`, built from the real files. */
42
+ function legacyMigrationsFolder(into: string): string {
43
+ const source = findMigrationsFolder();
44
+ const journal = JSON.parse(readFileSync(join(source, 'meta', '_journal.json'), 'utf8')) as {
45
+ version: string;
46
+ dialect: string;
47
+ entries: JournalEntry[];
48
+ };
49
+ const cutoff = journal.entries.findIndex((e) => e.tag === LAST_LEGACY_TAG);
50
+ if (cutoff === -1) throw new Error(`Journal has no entry for ${LAST_LEGACY_TAG}`);
51
+ const kept = journal.entries.slice(0, cutoff + 1);
52
+
53
+ mkdirSync(join(into, 'meta'), { recursive: true });
54
+ for (const entry of kept) {
55
+ cpSync(join(source, `${entry.tag}.sql`), join(into, `${entry.tag}.sql`));
56
+ }
57
+ writeFileSync(
58
+ join(into, 'meta', '_journal.json'),
59
+ JSON.stringify({ ...journal, entries: kept }, null, 2),
60
+ );
61
+ return into;
62
+ }
63
+
64
+ describe('0027 over a database that already holds an internal DNS ledger', () => {
65
+ let tempDir: string;
66
+ let dbPath: string;
67
+
68
+ beforeEach(() => {
69
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsint-'));
70
+ dbPath = join(tempDir, 'legacy.db');
71
+ });
72
+
73
+ afterEach(() => {
74
+ rmSync(tempDir, { recursive: true, force: true });
75
+ });
76
+
77
+ function openLegacyDatabase(): Database {
78
+ const sqlite = new Database(dbPath, { create: true });
79
+ sqlite.run('PRAGMA foreign_keys = ON');
80
+ migrate(drizzle(sqlite), {
81
+ migrationsFolder: legacyMigrationsFolder(join(tempDir, 'legacy-migrations')),
82
+ });
83
+ return sqlite;
84
+ }
85
+
86
+ function seedModule(sqlite: Database, id: string): void {
87
+ sqlite.run(
88
+ 'INSERT INTO modules (id, name, source_path, version, manifest_data) VALUES (?, ?, ?, ?, ?)',
89
+ [id, id, `/srv/${id}`, '1.0.0', JSON.stringify({ id })],
90
+ );
91
+ }
92
+
93
+ function seedLedger(sqlite: Database): void {
94
+ seedModule(sqlite, 'technitium');
95
+ seedModule(sqlite, 'knot-unbound-internal');
96
+ seedModule(sqlite, 'caddy');
97
+ seedModule(sqlite, 'forgejo');
98
+ sqlite.run(
99
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, ?)',
100
+ ['technitium', 'caddy', 'auth.example.org', '192.168.0.253', '10.0.10.14'],
101
+ );
102
+ sqlite.run(
103
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip, zone_routable_ip) VALUES (?, ?, ?, ?, NULL)',
104
+ ['technitium', 'forgejo', 'git.example.org', '192.168.0.253'],
105
+ );
106
+ }
107
+
108
+ function runCurrentMigrations(sqlite: Database): void {
109
+ migrate(drizzle(sqlite), { migrationsFolder: findMigrationsFolder() });
110
+ }
111
+
112
+ test('the rebuild carries every row across, values intact', () => {
113
+ const sqlite = openLegacyDatabase();
114
+ seedLedger(sqlite);
115
+
116
+ runCurrentMigrations(sqlite);
117
+
118
+ const rows = sqlite
119
+ .query<
120
+ { host: string; ip: string; zone_routable_ip: string | null; provider_module_id: string },
121
+ []
122
+ >(
123
+ 'SELECT host, ip, zone_routable_ip, provider_module_id FROM dns_internal_records ORDER BY host',
124
+ )
125
+ .all();
126
+ expect(rows).toHaveLength(2);
127
+ expect(rows[0]?.host).toBe('auth.example.org');
128
+ // The override is the value whose loss is silent: the resolver keeps
129
+ // answering, just with an address in-zone clients cannot route to.
130
+ expect(rows[0]?.zone_routable_ip).toBe('10.0.10.14');
131
+ expect(rows[1]?.zone_routable_ip).toBeNull();
132
+ // Attribution survives; only its ON DELETE action changed.
133
+ expect(rows[0]?.provider_module_id).toBe('technitium');
134
+ sqlite.close();
135
+ });
136
+
137
+ test('after migrating, removing the PROVIDER no longer empties the ledger', () => {
138
+ const sqlite = openLegacyDatabase();
139
+ seedLedger(sqlite);
140
+ runCurrentMigrations(sqlite);
141
+
142
+ sqlite.run('DELETE FROM modules WHERE id = ?', ['technitium']);
143
+
144
+ expect(
145
+ sqlite.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM dns_internal_records').get()?.c,
146
+ ).toBe(2);
147
+ sqlite.close();
148
+ });
149
+
150
+ /**
151
+ * The behaviour that did NOT change. Dropping a foreign key is an easy way to
152
+ * lose the one you meant to keep, and nothing else would notice.
153
+ */
154
+ test('the consumer cascade still fires after the rebuild', () => {
155
+ const sqlite = openLegacyDatabase();
156
+ seedLedger(sqlite);
157
+ runCurrentMigrations(sqlite);
158
+
159
+ sqlite.run('DELETE FROM modules WHERE id = ?', ['caddy']);
160
+
161
+ const left = sqlite
162
+ .query<{ consumer_module_id: string }, []>(
163
+ 'SELECT consumer_module_id FROM dns_internal_records',
164
+ )
165
+ .all();
166
+ expect(left.map((r) => r.consumer_module_id)).toEqual(['forgejo']);
167
+ sqlite.close();
168
+ });
169
+
170
+ /** The unique index is recreated by the rebuild, not left behind with the old table. */
171
+ test('the provider+host uniqueness survives the rebuild', () => {
172
+ const sqlite = openLegacyDatabase();
173
+ seedLedger(sqlite);
174
+ runCurrentMigrations(sqlite);
175
+
176
+ expect(() =>
177
+ sqlite.run(
178
+ 'INSERT INTO dns_internal_records (provider_module_id, consumer_module_id, host, ip) VALUES (?, ?, ?, ?)',
179
+ ['technitium', 'forgejo', 'auth.example.org', '192.168.0.253'],
180
+ ),
181
+ ).toThrow();
182
+ sqlite.close();
183
+ });
184
+ });
package/src/db/schema.ts CHANGED
@@ -801,16 +801,33 @@ export const publicDnsEvidence = sqliteTable('public_dns_evidence', {
801
801
  *
802
802
  * `celilo system doctor` reads this to assert service hostnames resolve to
803
803
  * the firewall natIp (LAN-reachable) and not a zone-side container IP that
804
- * a LAN device can't route to. Rows die with either module via FK cascade.
804
+ * a LAN device can't route to. Rows die with their CONSUMER via FK cascade, and
805
+ * NOT with their provider (celilo#1010 — see `providerModuleId`).
805
806
  * @owner capability:dns_internal — resolver configuration; migrates to the provider (T6)
806
807
  */
807
808
  export const dnsInternalRecords = sqliteTable(
808
809
  'dns_internal_records',
809
810
  {
810
811
  id: integer('id').primaryKey({ autoIncrement: true }),
811
- providerModuleId: text('provider_module_id')
812
- .notNull()
813
- .references(() => modules.id, { onDelete: 'cascade' }),
812
+ /**
813
+ * The resolver serving this record. A PLAIN column with no foreign key, and
814
+ * that is the celilo#1010 correction rather than an oversight.
815
+ *
816
+ * It used to cascade, so swapping `technitium` for `knot-unbound-internal`
817
+ * deleted the fleet's entire internal DNS ledger, `zone_routable_ip` view
818
+ * overrides included. `web_routes` cascades on its consumer only and the two
819
+ * docblocks claimed to be siblings, so the divergence read as intent and was
820
+ * not. The claim on a capability-owned table is the CONSUMER
821
+ * (openspec/changes/capability-owned-tables D3/D8), and `dns_internal`'s
822
+ * declaration cannot express anything else.
823
+ *
824
+ * Attribution is still real and still enforced — it is half
825
+ * `dns_internal_records_provider_host_idx` — it just no longer decides when a
826
+ * LIVE record is forgotten. A provider leaving now leaves the ledger for the
827
+ * next one to reconcile from, which is what stage 1's provider-arrival
828
+ * backfill assumes. Migration `0027`.
829
+ */
830
+ providerModuleId: text('provider_module_id').notNull(),
814
831
  consumerModuleId: text('consumer_module_id')
815
832
  .notNull()
816
833
  .references(() => modules.id, { onDelete: 'cascade' }),
@@ -258,4 +258,51 @@ resource "local_file" "test" {
258
258
  expect(errors[0]?.variable).toBe('$self:invalid_var');
259
259
  });
260
260
  });
261
+
262
+ /**
263
+ * The capability-secret gate at import consumes these, so what happens when
264
+ * templates cannot be read is a security property rather than a detail. It
265
+ * has to FAIL CLOSED: an unreadable tree must abort the import, never hand
266
+ * the gate an empty reference set that reads as "this module references no
267
+ * secrets". It was already true here — but true by inspection, which is the
268
+ * weakest way for a security property to be true.
269
+ */
270
+ describe('capability references, and failing closed', () => {
271
+ test('collects $capability: references from templates', async () => {
272
+ const dir = await mkdtemp(join(tmpdir(), 'celilo-tplrefs-'));
273
+ try {
274
+ await mkdir(join(dir, 'terraform'), { recursive: true });
275
+ await writeFile(
276
+ join(dir, 'terraform', 'main.tf.tpl'),
277
+ 'acme_dns = "$capability:dns_internal.tsig_key"\nzone = "$capability:dns_internal.dns.domain"\n',
278
+ );
279
+ const manifest = createTestManifest({
280
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
281
+ });
282
+
283
+ const result = await validateModuleTemplates(dir, manifest);
284
+
285
+ expect(result.capabilityReferences.sort()).toEqual([
286
+ 'dns_internal.dns.domain',
287
+ 'dns_internal.tsig_key',
288
+ ]);
289
+ } finally {
290
+ await rm(dir, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test('an unreadable module tree fails, and yields no references', async () => {
295
+ // A path that does not exist stands in for any read failure. The PAIRING
296
+ // is the property: success:false travels WITH the empty array, and
297
+ // `import.ts` returns on !success before the access gate runs, so the
298
+ // empty set can never be mistaken for "nothing referenced".
299
+ const result = await validateModuleTemplates(
300
+ join(tmpdir(), 'celilo-no-such-module-tree-9f3a2b'),
301
+ createTestManifest(),
302
+ );
303
+
304
+ expect(result.success).toBe(false);
305
+ expect(result.capabilityReferences).toEqual([]);
306
+ });
307
+ });
261
308
  });
@@ -19,6 +19,12 @@ export interface TemplateValidationError {
19
19
  export interface TemplateValidationResult {
20
20
  success: boolean;
21
21
  errors: TemplateValidationError[];
22
+ /**
23
+ * `<capability>.<path>` paths the templates reference. The capability-access
24
+ * check at import consumes these, so a secret referenced only from a template
25
+ * is refused at import rather than later at generation.
26
+ */
27
+ capabilityReferences: string[];
22
28
  }
23
29
 
24
30
  /**
@@ -277,10 +283,16 @@ export async function validateModuleTemplates(
277
283
  // Find all .tpl files
278
284
  const templateFiles = await findTemplateFiles(modulePath, modulePath);
279
285
 
280
- // Validate each template file
286
+ // Validate each template file. Every one is parsed here anyway, so the
287
+ // capability references fall out of work already being done — no second
288
+ // walk of the module tree.
289
+ const capabilityReferences = new Set<string>();
281
290
  for (const relativePath of templateFiles) {
282
291
  const fullPath = join(modulePath, relativePath);
283
292
  const content = await readFile(fullPath, 'utf-8');
293
+ for (const variable of parseVariables(content)) {
294
+ if (variable.type === 'capability') capabilityReferences.add(variable.path);
295
+ }
284
296
  const errors = validateTemplateContent(content, manifest, relativePath);
285
297
  allErrors.push(...errors);
286
298
  }
@@ -288,6 +300,7 @@ export async function validateModuleTemplates(
288
300
  return {
289
301
  success: allErrors.length === 0,
290
302
  errors: allErrors,
303
+ capabilityReferences: [...capabilityReferences],
291
304
  };
292
305
  } catch (error) {
293
306
  return {
@@ -299,6 +312,10 @@ export async function validateModuleTemplates(
299
312
  error: `Failed to validate templates: ${error instanceof Error ? error.message : 'Unknown error'}`,
300
313
  },
301
314
  ],
315
+ // Unreadable templates cannot yield references, and import returns on
316
+ // !success before the access check runs, so this can never reach it as a
317
+ // silent pass.
318
+ capabilityReferences: [],
302
319
  };
303
320
  }
304
321
  }
@@ -607,7 +607,25 @@ export async function importModule(options: ModuleImportOptions): Promise<Module
607
607
  // Execution: Validate capability access if module requires capabilities
608
608
  if (manifest.requires?.capabilities && manifest.requires.capabilities.length > 0) {
609
609
  const { validateCapabilityAccess } = await import('../capabilities/validation');
610
- const accessResult = await validateCapabilityAccess(manifest, db.$client);
610
+ // Templates are where CLAUDE.md's Definition of Done tells module authors
611
+ // to put `$capability:` references, so the import-time gate has to see
612
+ // them (celilo#854, celilo#1027).
613
+ //
614
+ // This adds no parser. `validateModuleTemplates` above already reads and
615
+ // parses every `.tpl` on every import, roughly 25 lines before the gate
616
+ // runs — it was discarding the references it saw. So there is no new file
617
+ // walk, no new failure mode and no new ordering question: the data is
618
+ // already in scope and the gate simply was not looking at it.
619
+ //
620
+ // Fail-closed by that same ordering. An unreadable template makes
621
+ // `validateModuleTemplates` return success:false with no references, and
622
+ // the early return above fires BEFORE this check, so an empty reference
623
+ // set can never reach the gate as a silent pass.
624
+ const accessResult = await validateCapabilityAccess(
625
+ manifest,
626
+ db.$client,
627
+ templateValidation.capabilityReferences,
628
+ );
611
629
 
612
630
  if (!accessResult.success) {
613
631
  if (tempDir) await cleanupTempDir(tempDir);