@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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The ONE consumer-removal rule, driven by the declarations
3
+ * (openspec/changes/capability-owned-tables task 2.4).
4
+ *
5
+ * Before this, "the row dies with its consumer" had five implementations for one
6
+ * rule, and two of them were functions core imported BY NAME from
7
+ * `consumer-cleanup.ts` — the file whose whole purpose is deleting exactly that
8
+ * kind of per-capability special-casing.
9
+ *
10
+ * The property worth protecting is not "port forwards get deleted". It is that
11
+ * core clears EVERY declared table without naming any of them, so declaring a
12
+ * new one is sufficient to have it cleaned up.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { allDeclaredTables } from '@celilo/capabilities';
20
+ import type { DbClient } from '../db/client';
21
+ import { dnsInternalRecords, modules, portForwards, trustedSources, webRoutes } from '../db/schema';
22
+ import { setupTestDatabase } from '../test-utils/setup-test-db';
23
+ import { deleteClaimedRows, planClaimedRowDeletion } from './capability-table-rows';
24
+
25
+ const FW = '192.168.0.254';
26
+
27
+ describe('planClaimedRowDeletion', () => {
28
+ /**
29
+ * The anti-regression that matters. If someone declares a table and this plan
30
+ * does not grow, their rows outlive the consumer silently — which is the whole
31
+ * failure the declaration exists to make impossible.
32
+ */
33
+ it('covers every declared table, so declaring one is enough to have it cleared', () => {
34
+ const planned = new Set(planClaimedRowDeletion().map((t) => t.table));
35
+ const declared = allDeclaredTables().map((d) => d.declaration.table);
36
+
37
+ expect(declared.length).toBeGreaterThan(0);
38
+ for (const table of declared) expect(planned.has(table)).toBe(true);
39
+ });
40
+
41
+ it("carries each table's OWN claim column, which is spelled three ways", () => {
42
+ const byTable = new Map(planClaimedRowDeletion().map((t) => [t.table, t.claimColumn]));
43
+
44
+ expect(byTable.get('web_routes')).toBe('module_id');
45
+ expect(byTable.get('port_forwards')).toBe('registered_by');
46
+ expect(byTable.get('trusted_sources')).toBe('registered_by');
47
+ expect(byTable.get('dns_internal_records')).toBe('consumer_module_id');
48
+ });
49
+ });
50
+
51
+ describe('deleteClaimedRows', () => {
52
+ let dir: string;
53
+ let db: DbClient;
54
+
55
+ beforeEach(async () => {
56
+ dir = mkdtempSync(join(tmpdir(), 'claimed-'));
57
+ const dbPath = join(dir, 'celilo.db');
58
+ process.env.CELILO_DB_PATH = dbPath;
59
+ db = await setupTestDatabase(dbPath);
60
+
61
+ for (const id of ['caddy', 'forgejo', 'technitium']) {
62
+ db.insert(modules)
63
+ .values({
64
+ id,
65
+ name: id,
66
+ version: '1.0.0',
67
+ state: 'VERIFIED',
68
+ sourcePath: `/tmp/${id}`,
69
+ manifestData: {},
70
+ })
71
+ .run();
72
+ }
73
+ });
74
+
75
+ afterEach(() => {
76
+ db.$client.close();
77
+ process.env.CELILO_DB_PATH = undefined;
78
+ rmSync(dir, { recursive: true, force: true });
79
+ });
80
+
81
+ function seed(consumer: string, hostname: string, port: number, subnet: string) {
82
+ db.insert(webRoutes)
83
+ .values({
84
+ slug: `${consumer}-slug`,
85
+ moduleId: consumer,
86
+ type: 'reverse_proxy',
87
+ path: `/${consumer}`,
88
+ hostname,
89
+ })
90
+ .run();
91
+ db.insert(portForwards)
92
+ .values({
93
+ firewallIp: FW,
94
+ internalIp: '10.0.20.5',
95
+ port,
96
+ protocol: 'TCP',
97
+ registeredBy: consumer,
98
+ })
99
+ .run();
100
+ db.insert(trustedSources).values({ firewallIp: FW, subnet, registeredBy: consumer }).run();
101
+ db.insert(dnsInternalRecords)
102
+ .values({
103
+ providerModuleId: 'technitium',
104
+ consumerModuleId: consumer,
105
+ host: hostname,
106
+ ip: '10.0.20.5',
107
+ })
108
+ .run();
109
+ }
110
+
111
+ it('clears the departing consumer from all four declared tables at once', () => {
112
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
113
+
114
+ const cleared = deleteClaimedRows(db, 'caddy');
115
+
116
+ expect(cleared.map((c) => c.table).sort()).toEqual([
117
+ 'dns_internal_records',
118
+ 'port_forwards',
119
+ 'trusted_sources',
120
+ 'web_routes',
121
+ ]);
122
+ expect(db.select().from(webRoutes).all()).toHaveLength(0);
123
+ expect(db.select().from(portForwards).all()).toHaveLength(0);
124
+ expect(db.select().from(trustedSources).all()).toHaveLength(0);
125
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
126
+ });
127
+
128
+ /**
129
+ * The refcount case, the bug most likely to ship silently. Two consumers each
130
+ * hold their own row, and one leaving must not withdraw what the other still
131
+ * needs.
132
+ */
133
+ it("leaves another consumer's rows untouched", () => {
134
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
135
+ seed('forgejo', 'b.example.org', 2222, '10.2.0.0/24');
136
+
137
+ deleteClaimedRows(db, 'caddy');
138
+
139
+ expect(
140
+ db
141
+ .select()
142
+ .from(webRoutes)
143
+ .all()
144
+ .map((r) => r.moduleId),
145
+ ).toEqual(['forgejo']);
146
+ expect(
147
+ db
148
+ .select()
149
+ .from(portForwards)
150
+ .all()
151
+ .map((r) => r.registeredBy),
152
+ ).toEqual(['forgejo']);
153
+ expect(
154
+ db
155
+ .select()
156
+ .from(trustedSources)
157
+ .all()
158
+ .map((r) => r.registeredBy),
159
+ ).toEqual(['forgejo']);
160
+ expect(
161
+ db
162
+ .select()
163
+ .from(dnsInternalRecords)
164
+ .all()
165
+ .map((r) => r.consumerModuleId),
166
+ ).toEqual(['forgejo']);
167
+ });
168
+
169
+ /**
170
+ * `dns_internal_records` is claimed by its CONSUMER, never its provider. The
171
+ * declaration cannot express anything else, which is what makes celilo#1010
172
+ * unrepresentable — but the runtime rule has to agree, or the declaration is
173
+ * decoration.
174
+ */
175
+ it('does not treat the PROVIDER as the claimant of a dns_internal record', () => {
176
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
177
+
178
+ deleteClaimedRows(db, 'technitium');
179
+
180
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(1);
181
+ });
182
+
183
+ it('is a no-op for a module that claimed nothing', () => {
184
+ seed('caddy', 'a.example.org', 443, '10.1.0.0/24');
185
+
186
+ deleteClaimedRows(db, 'forgejo');
187
+
188
+ expect(db.select().from(webRoutes).all()).toHaveLength(1);
189
+ expect(db.select().from(portForwards).all()).toHaveLength(1);
190
+ });
191
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Acting on every capability-declared table without naming any of them
3
+ * (openspec/changes/capability-owned-tables, design D3).
4
+ *
5
+ * "This row dies with its consumer" had five implementations for one rule: an FK
6
+ * cascade for `web_routes`, two functions core imported BY NAME from
7
+ * `consumer-cleanup.ts` for `port_forwards` and `trusted_sources`, a second
8
+ * table pruned on read for `dns_registrations`, a cascade on both provider and
9
+ * consumer for `dns_internal_records`, and a hook rewriting a JSON blob for the
10
+ * rest. `consumer-cleanup.ts` exists specifically to delete that kind of
11
+ * per-capability special-casing. It succeeded for the hook dispatch and then
12
+ * grew two hardcoded imports for the rows, because the rows had no generic rule.
13
+ *
14
+ * This is the generic rule. The declaration names the claim column, so core no
15
+ * longer has to know that three tables spell it `module_id`, `registered_by` and
16
+ * `consumer_module_id`.
17
+ */
18
+
19
+ import { CAPABILITY_DECLARED_TABLES, allDeclaredTables } from '@celilo/capabilities';
20
+ import { type Column, eq, is } from 'drizzle-orm';
21
+ import { SQLiteTable, getTableConfig } from 'drizzle-orm/sqlite-core';
22
+ import type { DbClient } from '../db/client';
23
+ import * as dbSchema from '../db/schema';
24
+
25
+ export interface ClaimedRowTarget {
26
+ capability: string;
27
+ /** Physical table name. */
28
+ table: string;
29
+ /** The column holding the consumer's module id. */
30
+ claimColumn: string;
31
+ }
32
+
33
+ /**
34
+ * Physical table name → the drizzle table object, for the tables celilo can
35
+ * actually act on. Built by walking the schema rather than by a hand-written
36
+ * map, so a table cannot be declared and then silently unreachable.
37
+ */
38
+ function schemaTables(): Map<string, SQLiteTable> {
39
+ const out = new Map<string, SQLiteTable>();
40
+ for (const value of Object.values(dbSchema)) {
41
+ if (!is(value, SQLiteTable)) continue;
42
+ out.set(getTableConfig(value).name, value);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * What a consumer's removal must clear. Pure — the list is a property of the
49
+ * declarations, so it is worth being able to assert without a database.
50
+ *
51
+ * A declaration naming a table that does not exist is skipped here rather than
52
+ * throwing. `policy/capability-shape-drift.test.ts` Scan A is what fails on
53
+ * that, loudly and at build time; a removal is the wrong moment to discover it,
54
+ * and refusing to clear the OTHER tables because one declaration is wrong would
55
+ * strand real rows.
56
+ */
57
+ export function planClaimedRowDeletion(): ClaimedRowTarget[] {
58
+ const tables = schemaTables();
59
+ return allDeclaredTables()
60
+ .filter(({ declaration }) => tables.has(declaration.table))
61
+ .map(({ capability, declaration }) => ({
62
+ capability,
63
+ table: declaration.table,
64
+ claimColumn: declaration.claim.column,
65
+ }))
66
+ .sort((a, b) => a.table.localeCompare(b.table));
67
+ }
68
+
69
+ /**
70
+ * Delete every declared row this consumer claimed.
71
+ *
72
+ * Called AFTER every provider has been told the consumer is going (its D4): a
73
+ * provider's `on_consumer_removed` is a full converge, and converging from a set
74
+ * the rows had already been removed from would withdraw more than the departing
75
+ * consumer's share.
76
+ *
77
+ * `web_routes` and `dns_internal_records` also carry an FK cascade, so their
78
+ * rows would go anyway once the `modules` row is deleted. Doing it here as well
79
+ * is deliberate and not merely harmless: it makes the ORDER explicit rather than
80
+ * a consequence of when some other statement happens to run, and it is the same
81
+ * point in the sequence at which `port_forwards` and `trusted_sources` were
82
+ * already being cleared by hand.
83
+ */
84
+ export function deleteClaimedRows(db: DbClient, consumer: string): ClaimedRowTarget[] {
85
+ const tables = schemaTables();
86
+ const cleared: ClaimedRowTarget[] = [];
87
+ for (const target of planClaimedRowDeletion()) {
88
+ const table = tables.get(target.table);
89
+ if (!table) continue;
90
+ const column = getTableConfig(table).columns.find((c) => c.name === target.claimColumn);
91
+ if (!column) continue;
92
+ db.delete(table)
93
+ .where(eq(column as Column, consumer))
94
+ .run();
95
+ cleared.push(target);
96
+ }
97
+ return cleared;
98
+ }
99
+
100
+ /** Capability names that declare at least one table — for logs and reports. */
101
+ export function capabilitiesWithDeclaredTables(): string[] {
102
+ return Object.keys(CAPABILITY_DECLARED_TABLES).sort();
103
+ }
@@ -23,8 +23,7 @@ import { capabilities, modules } from '../db/schema';
23
23
  import { type RunNamedHookResult, runNamedHook } from '../hooks/run-named-hook';
24
24
  import type { HookLogger } from '../hooks/types';
25
25
  import type { ModuleManifest } from '../manifest/schema';
26
- import { deletePortForwardsForModule } from './port-forwards';
27
- import { deleteTrustedSourcesForModule } from './trusted-sources';
26
+ import { deleteClaimedRows } from './capability-table-rows';
28
27
 
29
28
  /**
30
29
  * States in which a module has never resolved a capability and therefore holds
@@ -213,11 +212,18 @@ export async function runConsumerCleanup(
213
212
  );
214
213
  }
215
214
 
216
- // The rows, once every provider has converged without them (D4). `web_routes`
217
- // dies with the `modules` row via its FK cascade; these two carry a plain
218
- // `registered_by` text column and do not.
219
- deletePortForwardsForModule(db, consumer);
220
- deleteTrustedSourcesForModule(db, consumer);
215
+ // The rows, once every provider has converged without them (D4).
216
+ //
217
+ // Driven by the capability declarations rather than by name. This used to be
218
+ // two hardcoded imports, `deletePortForwardsForModule` and
219
+ // `deleteTrustedSourcesForModule`, which is the shape of the whole problem
220
+ // openspec/changes/capability-owned-tables addresses: this file exists to
221
+ // delete per-capability special-casing, it succeeded for the hook dispatch,
222
+ // and then grew two capability-named calls for the rows because the rows had
223
+ // no generic rule. Now they have one, and core no longer has to know that the
224
+ // claim column is spelled `module_id` on one table, `registered_by` on two and
225
+ // `consumer_module_id` on a fourth.
226
+ deleteClaimedRows(db, consumer);
221
227
 
222
228
  return result;
223
229
  }
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import type { DnsRecordRequest } from '@celilo/capabilities';
6
6
  import type { ViewOverride } from '@celilo/capabilities';
7
+ import { eq } from 'drizzle-orm';
7
8
  import type { DbClient } from '../db/client';
8
- import { modules } from '../db/schema';
9
+ import { dnsInternalRecords, modules } from '../db/schema';
9
10
  import { setupTestDatabase } from '../test-utils/setup-test-db';
10
11
  import {
11
12
  listDnsInternalRecords,
@@ -197,3 +198,73 @@ describe('dns-internal-records ledger', () => {
197
198
  });
198
199
  });
199
200
  });
201
+
202
+ /**
203
+ * celilo#1010 — swapping the internal DNS provider must not delete the ledger.
204
+ *
205
+ * `dns_internal_records` used to cascade on `provider_module_id` as well as on
206
+ * the consumer, so removing `technitium` to install `knot-unbound-internal` took
207
+ * the fleet's entire internal DNS ledger with it, `zone_routable_ip` view
208
+ * overrides included — the durable desired state the resolver's split-horizon
209
+ * config is reconciled from. `web_routes` cascades on its consumer only, and the
210
+ * two tables' docblocks claimed to be siblings, so the divergence read as intent
211
+ * and was not.
212
+ *
213
+ * The claim on a capability-owned table is the CONSUMER
214
+ * (openspec/changes/capability-owned-tables D3/D8). Migration 0027 makes the
215
+ * table agree with the declaration, which cannot express anything else.
216
+ */
217
+ describe('provider cascade (celilo#1010)', () => {
218
+ let dir: string;
219
+ let db: DbClient;
220
+
221
+ beforeEach(async () => {
222
+ dir = mkdtempSync(join(tmpdir(), 'dns-cascade-'));
223
+ const dbPath = join(dir, 'celilo.db');
224
+ process.env.CELILO_DB_PATH = dbPath;
225
+ db = await setupTestDatabase(dbPath);
226
+ for (const id of ['technitium', 'knot-unbound-internal', 'caddy']) {
227
+ db.insert(modules)
228
+ .values({
229
+ id,
230
+ name: id,
231
+ version: '1.0.0',
232
+ state: 'VERIFIED',
233
+ sourcePath: `/tmp/${id}`,
234
+ manifestData: {},
235
+ })
236
+ .run();
237
+ }
238
+ db.insert(dnsInternalRecords)
239
+ .values({
240
+ providerModuleId: 'technitium',
241
+ consumerModuleId: 'caddy',
242
+ host: 'auth.example.org',
243
+ ip: '192.168.0.253',
244
+ zoneRoutableIp: '10.0.10.14',
245
+ })
246
+ .run();
247
+ });
248
+
249
+ afterEach(() => {
250
+ db.$client.close();
251
+ process.env.CELILO_DB_PATH = undefined;
252
+ rmSync(dir, { recursive: true, force: true });
253
+ });
254
+
255
+ it('keeps the ledger when the PROVIDER is removed, view overrides included', () => {
256
+ db.delete(modules).where(eq(modules.id, 'technitium')).run();
257
+
258
+ const left = db.select().from(dnsInternalRecords).all();
259
+ expect(left).toHaveLength(1);
260
+ // The override is the part whose loss is silent: the resolver keeps
261
+ // answering, just with the wrong address for in-zone clients.
262
+ expect(left[0]?.zoneRoutableIp).toBe('10.0.10.14');
263
+ });
264
+
265
+ it('still dies with its CONSUMER, which is the rule that did not change', () => {
266
+ db.delete(modules).where(eq(modules.id, 'caddy')).run();
267
+
268
+ expect(db.select().from(dnsInternalRecords).all()).toHaveLength(0);
269
+ });
270
+ });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { describe, expect, test } from 'bun:test';
8
+ import { CAPABILITY_CONTRACT_VERSIONS } from '@celilo/capabilities';
8
9
  import { validateCapabilityVersions } from './capability-versions';
9
10
 
10
11
  describe('validateCapabilityVersions', () => {
@@ -31,7 +32,11 @@ describe('validateCapabilityVersions', () => {
31
32
  expect(errors).toHaveLength(1);
32
33
  expect(errors[0]).toContain('provides[public_web]');
33
34
  expect(errors[0]).toContain('1.0.0');
34
- expect(errors[0]).toContain('3.1.0');
35
+ // Read from the registry, not pinned to today's number. The assertion is
36
+ // "the message names the RUNTIME version so the author knows what to move
37
+ // to"; a literal here turns a behaviour test into a version tracker that
38
+ // goes red on every legitimate bump, which is what happened at 3.1.0 → 3.2.0.
39
+ expect(errors[0]).toContain(CAPABILITY_CONTRACT_VERSIONS.public_web);
35
40
  });
36
41
 
37
42
  test('error when provides[X].version is newer than runtime', () => {
@@ -7,7 +7,8 @@ import { eq } from 'drizzle-orm';
7
7
  import type { DbClient } from '../db/client';
8
8
  import { portForwards } from '../db/schema';
9
9
  import { setupTestDatabase } from '../test-utils/setup-test-db';
10
- import { buildPortForwardStore, deletePortForwardsForModule } from './port-forwards';
10
+ import { deleteClaimedRows } from './capability-table-rows';
11
+ import { buildPortForwardStore } from './port-forwards';
11
12
 
12
13
  const FW = '192.168.0.254';
13
14
  const CADDY = { internalIp: '10.0.20.5', protocol: 'TCP' as const, description: 'caddy' };
@@ -113,7 +114,10 @@ describe('port-forward store', () => {
113
114
  other.replace(FW, { ...CADDY, description: 'also 443' }, [443]);
114
115
  expect(store.list(FW)).toHaveLength(2);
115
116
 
116
- deletePortForwardsForModule(db, 'caddy');
117
+ // Through the GENERIC declaration-driven path, which replaced
118
+ // `deletePortForwardsForModule`. The property is unchanged; what changed is
119
+ // that core no longer names this capability's table to clear it.
120
+ deleteClaimedRows(db, 'caddy');
117
121
 
118
122
  const left = store.list(FW);
119
123
  expect(left).toHaveLength(1);
@@ -99,14 +99,3 @@ export function buildPortForwardStore(db: DbClient, registeredBy: string): PortF
99
99
  },
100
100
  };
101
101
  }
102
-
103
- /**
104
- * Drop every forward a departing module owns, across every firewall.
105
- *
106
- * `registered_by` is plain text rather than a FK, so unlike `web_routes` these
107
- * rows do NOT die with the `modules` row — core deletes them explicitly, AFTER
108
- * the provider has converged without them (D4).
109
- */
110
- export function deletePortForwardsForModule(db: DbClient, moduleId: string): void {
111
- db.delete(portForwards).where(eq(portForwards.registeredBy, moduleId)).run();
112
- }
@@ -68,11 +68,6 @@ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): Tru
68
68
  };
69
69
  }
70
70
 
71
- /** Drop every trusted source a departing module owns, across every firewall. */
72
- export function deleteTrustedSourcesForModule(db: DbClient, moduleId: string): void {
73
- db.delete(trustedSources).where(eq(trustedSources.registeredBy, moduleId)).run();
74
- }
75
-
76
71
  /** Where a trusted subnet came from — reach into every tier must be attributable. */
77
72
  export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override';
78
73
 
@@ -593,16 +593,81 @@ async function assembleResolutionContext(
593
593
  }
594
594
  }
595
595
  }
596
- const dnsInternal = capabilitiesMap.dns_internal as { server?: { ip?: unknown } } | undefined;
597
- // The dns_internal capability advertises the provider's address, which may
598
- // carry a CIDR suffix (technitium's server.ip resolves from target_ip,
599
- // e.g. "192.168.0.151/24"). A nameserver must be a bare IP — strip it.
600
- const rawInternalIp =
601
- typeof dnsInternal?.server?.ip === 'string' ? dnsInternal.server.ip : undefined;
602
- const internalIp = rawInternalIp ? rawInternalIp.split('/')[0] : undefined;
603
- const nameservers = internalIp ? [internalIp, ...publicDns] : publicDns;
604
- if (nameservers.length > 0) {
605
- selfConfig.lxc_nameserver = nameservers.join(' ');
596
+ type ResolverEndpoint = {
597
+ server?: { ip?: unknown; internal_ip?: unknown };
598
+ /** Zones the provider's base-module aspect owns ongoing DNS for (D5d). */
599
+ aspect?: { covered_zones?: unknown };
600
+ };
601
+ const dnsInternal = capabilitiesMap.dns_internal as ResolverEndpoint | undefined;
602
+ const dnsSecondary = capabilitiesMap.dns_internal_secondary as ResolverEndpoint | undefined;
603
+
604
+ // A nameserver must be a bare IP. The advertised address may carry a CIDR
605
+ // suffix (technitium's server.ip resolves from target_ip, e.g.
606
+ // "192.168.0.151/24"), so strip it.
607
+ const bareIp = (value: unknown): string | undefined =>
608
+ typeof value === 'string' && value.length > 0 ? value.split('/')[0] : undefined;
609
+
610
+ const targetZone = module?.manifestData
611
+ ? getSingularSystemSpec(module.manifestData as ModuleManifest)?.zone
612
+ : undefined;
613
+
614
+ // `internal` cannot route into the resolvers' own (dmz) subnet, so it uses
615
+ // the ingress addresses the firewall DNATs. Every other zone reaches them
616
+ // directly.
617
+ const endpointFor = (resolver: ResolverEndpoint | undefined): string | undefined =>
618
+ targetZone === 'internal'
619
+ ? (bareIp(resolver?.server?.internal_ip) ?? bareIp(resolver?.server?.ip))
620
+ : bareIp(resolver?.server?.ip);
621
+
622
+ const primaryIp = endpointFor(dnsInternal);
623
+ const secondaryIp = endpointFor(dnsSecondary);
624
+
625
+ // Whether the DEPLOYED provider's base-module aspect covers this system's
626
+ // zone, which is what decides if the public entries may go (design D5d).
627
+ //
628
+ // The predicate is aspect COVERAGE, not routing. Routing is why a zone goes
629
+ // uncovered — `external` and the planned `quarantine` hold systems that
630
+ // cannot reach a resolver inside the perimeter. Coverage is why the birth
631
+ // list is PERMANENT: terraform injects
632
+ // `lifecycle { ignore_changes = [nameserver] }`, so it cannot correct the
633
+ // value afterwards even in principle, and a zone no aspect covers has no
634
+ // owner for ongoing DNS at all. Keying on coverage therefore handles a
635
+ // future excluded zone with no change here, and fails safe on an accidental
636
+ // omission — where keeping the public entries is the lesser harm until the
637
+ // array is fixed.
638
+ // The provider DECLARES its coverage in the capability data. Core could
639
+ // instead find the provider's module row and read
640
+ // `base_module_aspect.applicable_zones` off its manifest, which would be
641
+ // one source of truth rather than two — but that means core naming a
642
+ // capability in order to find its provider, which is the pattern the
643
+ // module-business gate exists to stop, and its advice is exactly this: let
644
+ // the provider declare the behaviour. A manifest test holds the declared
645
+ // list and the aspect's own `applicable_zones` together so they cannot
646
+ // drift.
647
+ const aspectZones = dnsInternal?.aspect?.covered_zones;
648
+ const zoneIsAspectCovered =
649
+ targetZone !== undefined && Array.isArray(aspectZones) && aspectZones.includes(targetZone);
650
+
651
+ // Both halves are required. A pair with no aspect covering this zone is the
652
+ // `external` case: two addresses it cannot route to and nothing else, which
653
+ // does not tighten anything, it takes DNS away. A primary with no secondary
654
+ // is D5a: removing the fallback and shipping a secondary are ONE decision,
655
+ // because otherwise every resolver redeploy blanks fleet DNS.
656
+ const dropPublicResolvers =
657
+ primaryIp !== undefined && secondaryIp !== undefined && zoneIsAspectCovered;
658
+
659
+ // The uncovered branch is main's composition unchanged: the primary, then
660
+ // the public resolvers. The secondary is deliberately NOT added to it — in
661
+ // a zone that cannot route to the resolvers, a second unreachable address
662
+ // buys nothing but another timeout before the public entries answer.
663
+ const nameservers = dropPublicResolvers
664
+ ? [primaryIp, secondaryIp]
665
+ : primaryIp
666
+ ? [primaryIp, ...publicDns]
667
+ : publicDns;
668
+ const unique = [...new Set(nameservers)];
669
+ if (unique.length > 0) {
670
+ selfConfig.lxc_nameserver = unique.join(' ');
606
671
  }
607
672
  }
608
673