@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.
Files changed (64) hide show
  1. package/CELILO_CORE_MODULES.md +3 -1
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +238 -2
  11. package/src/capabilities/validation.ts +67 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-remove.ts +34 -2
  14. package/src/cli/commands/module-update.test.ts +149 -2
  15. package/src/cli/commands/module-update.ts +113 -25
  16. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  17. package/src/cli/commands/service-set-credentials.ts +115 -0
  18. package/src/cli/commands/system-migrate.ts +6 -4
  19. package/src/cli/completion.ts +16 -1
  20. package/src/cli/index.ts +9 -0
  21. package/src/db/client.ts +10 -8
  22. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  23. package/src/db/migrate.test.ts +147 -0
  24. package/src/db/migrate.ts +69 -1
  25. package/src/db/schema.ts +21 -4
  26. package/src/hooks/capability-loader.test.ts +55 -0
  27. package/src/hooks/capability-loader.ts +16 -1
  28. package/src/manifest/template-validator.test.ts +47 -0
  29. package/src/manifest/template-validator.ts +18 -1
  30. package/src/module/import.ts +39 -6
  31. package/src/policy/capability-shape-baseline.ts +88 -0
  32. package/src/policy/capability-shape-drift.test.ts +162 -0
  33. package/src/policy/capability-shape.ts +117 -0
  34. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  35. package/src/policy/module-business-baseline.ts +32 -18
  36. package/src/services/alerting/monitors.ts +54 -2
  37. package/src/services/alerting/sweep-runner.ts +38 -1
  38. package/src/services/capability-table-rows.test.ts +191 -0
  39. package/src/services/capability-table-rows.ts +103 -0
  40. package/src/services/consumer-cleanup.ts +18 -10
  41. package/src/services/container-service.test.ts +34 -0
  42. package/src/services/container-service.ts +44 -0
  43. package/src/services/deployed-systems.test.ts +101 -0
  44. package/src/services/deployed-systems.ts +43 -11
  45. package/src/services/dns-internal-records.test.ts +72 -1
  46. package/src/services/dns-provider-backfill.ts +30 -0
  47. package/src/services/fleet-checks.test.ts +26 -0
  48. package/src/services/fleet-checks.ts +11 -1
  49. package/src/services/module-deploy.ts +88 -41
  50. package/src/services/module-validator/capability-versions.test.ts +6 -1
  51. package/src/services/port-forwards.test.ts +6 -2
  52. package/src/services/port-forwards.ts +0 -11
  53. package/src/services/provider-arrival.test.ts +241 -0
  54. package/src/services/provider-arrival.ts +213 -0
  55. package/src/services/trusted-sources.ts +0 -5
  56. package/src/templates/generator.test.ts +35 -0
  57. package/src/templates/generator.ts +29 -1
  58. package/src/variables/context.test.ts +63 -0
  59. package/src/variables/context.ts +85 -12
  60. package/src/variables/declarative-derivation.test.ts +47 -8
  61. package/src/variables/declarative-derivation.ts +6 -4
  62. package/src/variables/lxc-nameserver.test.ts +144 -0
  63. package/src/services/public-web-republish.test.ts +0 -189
  64. package/src/services/public-web-republish.ts +0 -84
@@ -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
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The frozen-watermark recurrence gate (celilo#169).
3
+ *
4
+ * The state under test is one no correct deploy can produce, so it is seeded
5
+ * by hand rather than reached: a database written by a celilo from the
6
+ * imperative hand-list era, where schema changes were applied directly and
7
+ * `__drizzle_migrations` never recorded them. Its ledger therefore remembers
8
+ * an old migration while the tables and columns of every later one are already
9
+ * present.
10
+ *
11
+ * That is the carve-out CLAUDE.md draws around seeding state: deploying
12
+ * anything cannot reproduce this row, because current celilo has recorded
13
+ * every migration it applied since ISS-0100 made drizzle authoritative. A
14
+ * suite that starts from an empty database can only ever prove the forward
15
+ * invariant, and says nothing about the installed base.
16
+ *
17
+ * What made it a hazard: drizzle's migrator is watermark-only. It re-runs
18
+ * every migration newer than the newest ledger row, so the first `ALTER TABLE
19
+ * ... ADD` dies on `duplicate column name`, the transaction rolls back, and
20
+ * the throw happens inside `createDbClient` — so EVERY celilo command on that
21
+ * box fails at database open, not just a migrate. celilo-mgr was remediated by
22
+ * hand once. This gate is what stops the next one needing a runbook.
23
+ */
24
+
25
+ import { afterEach, describe, expect, test } from 'bun:test';
26
+ import { rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { type DbClient, createDbClient } from './client';
30
+ import { runMigrationsOn } from './migrate';
31
+ import { findSchemaDrift } from './schema-introspection';
32
+
33
+ describe('runMigrationsOn — a database from the hand-list era', () => {
34
+ const paths: string[] = [];
35
+
36
+ const freshDb = (): { db: DbClient; path: string } => {
37
+ const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
38
+ paths.push(path);
39
+ return { db: createDbClient({ path }), path };
40
+ };
41
+
42
+ afterEach(() => {
43
+ for (const path of paths.splice(0)) {
44
+ for (const suffix of ['', '-wal', '-shm']) {
45
+ rmSync(`${path}${suffix}`, { force: true });
46
+ }
47
+ }
48
+ });
49
+
50
+ /**
51
+ * Freeze the ledger to its oldest entry, leaving the schema fully applied.
52
+ * This is the hand-list-era shape: the objects exist, the ledger has
53
+ * forgotten who made them.
54
+ */
55
+ const freezeWatermark = (db: DbClient): void => {
56
+ db.$client.run(
57
+ 'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
58
+ );
59
+ };
60
+
61
+ const appliedCount = (db: DbClient): number =>
62
+ db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
63
+ ?.c ?? 0;
64
+
65
+ test('converges instead of dying on the first already-applied statement', () => {
66
+ const { db } = freshDb();
67
+ const migrationCount = appliedCount(db);
68
+ freezeWatermark(db);
69
+ expect(appliedCount(db)).toBe(1);
70
+
71
+ // Red before the ledger repair: drizzle re-runs 0001 and throws
72
+ // "duplicate column name: role", leaving the ledger frozen.
73
+ expect(() => runMigrationsOn(db)).not.toThrow();
74
+
75
+ expect(appliedCount(db)).toBe(migrationCount);
76
+ });
77
+
78
+ test('leaves the schema whole, so the drift detector goes green', () => {
79
+ const { db } = freshDb();
80
+ freezeWatermark(db);
81
+
82
+ runMigrationsOn(db);
83
+
84
+ const drift = findSchemaDrift(db.$client);
85
+ expect(drift.missingTables).toEqual([]);
86
+ expect(drift.missingColumns).toEqual([]);
87
+ });
88
+
89
+ test('is idempotent — a second pass applies nothing and still converges', () => {
90
+ const { db } = freshDb();
91
+ freezeWatermark(db);
92
+ runMigrationsOn(db);
93
+ const afterBaseline = appliedCount(db);
94
+
95
+ runMigrationsOn(db);
96
+
97
+ expect(appliedCount(db)).toBe(afterBaseline);
98
+ });
99
+
100
+ /**
101
+ * The repair must not become a blanket "assume it already ran". It fires only
102
+ * where the answer is unambiguous — the declared schema is entirely present,
103
+ * so every migration plainly did run and the ledger is what is wrong. A
104
+ * PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
105
+ * was actually in: it carried 0011's column and not 0010's table. Stamping
106
+ * there would record migrations that never ran and bury the missing schema
107
+ * for good, so it keeps failing and a human decides.
108
+ */
109
+ test('refuses to stamp when the schema is only partly there', () => {
110
+ const { db } = freshDb();
111
+ freezeWatermark(db);
112
+ db.$client.run('DROP TABLE `dns_registrations`');
113
+
114
+ expect(() => runMigrationsOn(db)).toThrow();
115
+ // The ledger is left exactly as found, so the drift is still diagnosable.
116
+ expect(appliedCount(db)).toBe(1);
117
+ });
118
+
119
+ /**
120
+ * The path that actually matters. Every celilo command opens the database
121
+ * through createDbClient, which migrates on open, so a frozen watermark
122
+ * failed there rather than anywhere an operator could aim a fix at — and
123
+ * `celilo system migrate`, the command whose whole job is repairing this,
124
+ * reached its own repair through the same open and died first.
125
+ */
126
+ test('repairs on database open, not just when migrate is called by hand', () => {
127
+ const { db, path } = freshDb();
128
+ const migrationCount = appliedCount(db);
129
+ freezeWatermark(db);
130
+ db.$client.close();
131
+
132
+ const reopened = createDbClient({ path });
133
+
134
+ expect(appliedCount(reopened)).toBe(migrationCount);
135
+ expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
136
+ });
137
+
138
+ test('a healthy database is untouched by the fallback', () => {
139
+ const { db } = freshDb();
140
+ const before = appliedCount(db);
141
+
142
+ runMigrationsOn(db);
143
+
144
+ expect(appliedCount(db)).toBe(before);
145
+ expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
146
+ });
147
+ });
package/src/db/migrate.ts CHANGED
@@ -1,14 +1,82 @@
1
+ import type { Database } from 'bun:sqlite';
1
2
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
3
+ import { readMigrationFiles } from 'drizzle-orm/migrator';
2
4
  import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
5
+ import { findSchemaDrift } from './schema-introspection';
6
+
7
+ /**
8
+ * Record every migration past the ledger's watermark as applied, running none
9
+ * of them. Only safe when the caller has already established that the schema
10
+ * the code declares is entirely present.
11
+ *
12
+ * Replaying the statements instead would be wrong, and quietly so. Migrations
13
+ * are not idempotent: `0021_dns_registration_consumers` rebuilds a table by
14
+ * copying it aside, `DROP TABLE`-ing the original and renaming the copy over
15
+ * it. Run that against a schema that is already current and it drops a live
16
+ * table. Skipping the statements that fail with "already exists" does not save
17
+ * you either, because `DROP` and `INSERT ... SELECT` do not fail that way.
18
+ *
19
+ * So the ledger is corrected and the schema is left alone.
20
+ */
21
+ function stampLedgerAsApplied(sqlite: Database, migrationsFolder: string): void {
22
+ const newest = sqlite
23
+ .query<{ created_at: number }, []>(
24
+ 'SELECT created_at FROM `__drizzle_migrations` ORDER BY created_at DESC LIMIT 1',
25
+ )
26
+ .get();
27
+ const watermark = Number(newest?.created_at ?? 0);
28
+
29
+ sqlite.run('BEGIN');
30
+ try {
31
+ for (const migration of readMigrationFiles({ migrationsFolder })) {
32
+ if (migration.folderMillis <= watermark) continue;
33
+ sqlite.run('INSERT INTO `__drizzle_migrations` ("hash", "created_at") VALUES (?, ?)', [
34
+ migration.hash,
35
+ migration.folderMillis,
36
+ ]);
37
+ }
38
+ sqlite.run('COMMIT');
39
+ } catch (error) {
40
+ sqlite.run('ROLLBACK');
41
+ throw error;
42
+ }
43
+ }
3
44
 
4
45
  /**
5
46
  * Apply pending drizzle migrations to an open DB. Idempotent — drizzle applies
6
47
  * only migrations newer than the latest recorded in `__drizzle_migrations`.
7
48
  * The single migration mechanism (ISS-0100); createDbClient also calls this
8
49
  * shape on open (auto-migrate).
50
+ *
51
+ * The stock migrator runs first and handles every database celilo has written
52
+ * since ISS-0100, so a healthy box takes exactly the path it took before.
53
+ *
54
+ * It fails on one database celilo did not write: one from the imperative
55
+ * hand-list era, where schema changes were applied directly and
56
+ * `__drizzle_migrations` never recorded them. Its ledger remembers an old
57
+ * migration while the objects of every later one are already there, and
58
+ * drizzle — being watermark-only — re-runs them and dies on the first
59
+ * `ALTER TABLE ... ADD`. The throw happens inside `createDbClient`, so every
60
+ * celilo command on that box fails at database open. celilo-mgr, the one box
61
+ * in that state, was remediated by hand (celilo#169); this is so the next one
62
+ * is not.
63
+ *
64
+ * The repair is only attempted when the schema the code declares is ALREADY
65
+ * COMPLETE, because that is the one case with an unambiguous answer: every
66
+ * migration has plainly run, so the ledger is what is wrong. A database missing
67
+ * some of it is the genuinely hard case — celilo-mgr had 0011's column and not
68
+ * 0010's table — and there is no safe automatic answer, so it keeps failing
69
+ * with drizzle's own error and a human decides.
9
70
  */
10
71
  export function runMigrationsOn(db: DbClient): void {
11
- migrate(db, { migrationsFolder: findMigrationsFolder() });
72
+ const migrationsFolder = findMigrationsFolder();
73
+ try {
74
+ migrate(db, { migrationsFolder });
75
+ } catch (error) {
76
+ const drift = findSchemaDrift(db.$client);
77
+ if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) throw error;
78
+ stampLedgerAsApplied(db.$client, migrationsFolder);
79
+ }
12
80
  }
13
81
 
14
82
  /**
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' }),
@@ -29,6 +29,17 @@ export default function registerHost(context) {
29
29
  }
30
30
  `;
31
31
 
32
+ const TEST_DHCP_SERVER_MODULE = `
33
+ export default function createDhcpServer(context) {
34
+ return {
35
+ async setDnsServers() {},
36
+ async getDnsServers() { return [context.config.marker]; },
37
+ async setDomainName() {},
38
+ async getDomainName() { return context.config.marker; },
39
+ };
40
+ }
41
+ `;
42
+
32
43
  describe('Capability Loader', () => {
33
44
  let db: DbClient;
34
45
  let tempDir: string;
@@ -97,6 +108,50 @@ describe('Capability Loader', () => {
97
108
  expect(result.dns_registrar).toBeTruthy();
98
109
  });
99
110
 
111
+ test('prefers a provider explicitly scoped to the well-known capability zone', async () => {
112
+ for (const moduleId of ['upstream-dhcp', 'internal-dhcp']) {
113
+ const modulePath = join(tempDir, moduleId);
114
+ const scriptsDir = join(modulePath, 'scripts');
115
+ mkdirSync(scriptsDir, { recursive: true });
116
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
117
+ db.$client.run(
118
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`,
119
+ );
120
+ upsertModuleConfig(db, moduleId, 'marker', moduleId);
121
+ }
122
+
123
+ // Insert the zone-agnostic upstream provider first to prove selection is
124
+ // policy-driven rather than database-order-driven.
125
+ db.$client.run(
126
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
127
+ );
128
+ db.$client.run(
129
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('internal-dhcp', 'dhcp_server', '1.0.0', '{}', '["internal"]', unixepoch())`,
130
+ );
131
+
132
+ const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
133
+ const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
134
+ expect(await dhcp.getDomainName()).toBe('internal-dhcp');
135
+ });
136
+
137
+ test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
138
+ const modulePath = join(tempDir, 'upstream-dhcp');
139
+ const scriptsDir = join(modulePath, 'scripts');
140
+ mkdirSync(scriptsDir, { recursive: true });
141
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE);
142
+ db.$client.run(
143
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('upstream-dhcp', 'upstream-dhcp', '1.0.0', '${modulePath}', '{}')`,
144
+ );
145
+ db.$client.run(
146
+ `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`,
147
+ );
148
+ upsertModuleConfig(db, 'upstream-dhcp', 'marker', 'upstream-dhcp');
149
+
150
+ const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger);
151
+ const dhcp = result.dhcp_server as { getDomainName(): Promise<string> };
152
+ expect(await dhcp.getDomainName()).toBe('upstream-dhcp');
153
+ });
154
+
100
155
  test('injects a read-only web_routes view into the public_web provider own hooks (ISS-0035)', async () => {
101
156
  const modulePath = join(tempDir, 'caddy');
102
157
  mkdirSync(modulePath, { recursive: true });
@@ -27,6 +27,8 @@ import type {
27
27
  TrustedSourceStore,
28
28
  } from '@celilo/capabilities';
29
29
  import { and, eq } from 'drizzle-orm';
30
+ import { selectCapabilityProvider } from '../capabilities/lookup';
31
+ import { WELL_KNOWN_CAPABILITIES } from '../capabilities/well-known';
30
32
  import type { DbClient } from '../db/client';
31
33
  import {
32
34
  NETWORK_ZONES,
@@ -279,7 +281,20 @@ export async function loadCapabilityFunctions(
279
281
  continue;
280
282
  }
281
283
 
282
- const capability = allProviders[0];
284
+ // A well-known capability's required zone is also its runtime selection
285
+ // context. Prefer a provider that explicitly serves that zone, then fall
286
+ // back to a zone-agnostic provider. Without this, multiple DHCP providers
287
+ // (for example an upstream router plus an internal LAN server) are selected
288
+ // by database insertion order and a consumer can reconfigure the wrong
289
+ // network boundary.
290
+ const selectionZone = WELL_KNOWN_CAPABILITIES[capName]?.required_zone;
291
+ const capability = selectCapabilityProvider(allProviders, selectionZone);
292
+ if (!capability) {
293
+ debugLog(
294
+ `${capName}: no provider serves required zone ${selectionZone ?? '(unspecified)'}, skipping`,
295
+ );
296
+ continue;
297
+ }
283
298
  debugLog(`${capName}: found provider module ${capability.moduleId}`);
284
299
 
285
300
  const providerModule = db
@@ -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
  }