import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { eq } from 'drizzle-orm'; import { type DbClient, createDbClient } from '../db/client'; import { capabilities, moduleConfigs, modules, systemConfig } from '../db/schema'; import { buildResolutionContext } from './context'; const TEST_DB_PATH = './test-lxc-nameserver.db'; /** * Coverage for the generate-time `lxc_nameserver` composition * (openspec/specs/lxc-dns-at-birth/spec.md): proxmox_lxc templates read `$self:lxc_nameserver`, * a space-separated primary/secondary list = internal dns_internal resolver * first (CIDR stripped), then the public dns.primary/dns.fallback resolvers; * public-only when no dns_internal provider is registered (bootstrap). */ describe('lxc_nameserver composition', () => { let db: DbClient; beforeEach(() => { db = createDbClient({ path: TEST_DB_PATH }); db.insert(modules) .values({ id: 'consumer', name: 'consumer', version: '1.0.0', manifestData: { requires: { system: { zone: 'app' } } }, sourcePath: '/tmp/consumer', }) .run(); db.insert(moduleConfigs) .values({ moduleId: 'consumer', key: 'hostname', value: 'consumer', valueJson: '"consumer"' }) .run(); for (const [key, value] of [ ['dns.primary', '1.1.1.1'], ['dns.fallback', '1.0.0.1,8.8.8.8'], ]) { db.insert(systemConfig).values({ key, value }).run(); } }); afterEach(async () => { db.$client.close(); for (const suffix of ['', '-shm', '-wal']) { const p = `${TEST_DB_PATH}${suffix}`; if (existsSync(p)) await rm(p); } }); function registerInternalDns(ip: string): void { db.insert(modules) .values({ id: 'dns-provider', name: 'dns-provider', version: '1.0.0', manifestData: {}, sourcePath: '/tmp/dns', }) .run(); db.insert(capabilities) .values({ moduleId: 'dns-provider', capabilityName: 'dns_internal', version: '1.0.0', data: { server: { ip } }, }) .run(); } /** Put the consumer in `zone` instead of the default `app`. */ function placeConsumerIn(zone: string): void { db.update(modules) .set({ manifestData: { requires: { system: { zone } } } }) .where(eq(modules.id, 'consumer')) .run(); } /** * A dns_internal provider that advertises a zone-routable endpoint AND * declares a base-module aspect, the way `knot-unbound-internal` does. The * aspect's `applicable_zones` is what decides whether a system's ongoing DNS * has an owner at all (design D5d). */ function registerManagedPrimary(opts: { ip: string; internalIp?: string; aspectZones: string[]; }): void { db.insert(modules) .values({ id: 'dns-provider', name: 'dns-provider', version: '1.0.0', manifestData: {}, sourcePath: '/tmp/dns', }) .run(); db.insert(capabilities) .values({ moduleId: 'dns-provider', capabilityName: 'dns_internal', version: '1.0.0', data: { server: { ip: opts.ip, internal_ip: opts.internalIp }, // The provider DECLARES what its aspect covers, rather than core // finding the module row and reading its manifest. A manifest test // holds this list and `base_module_aspect.applicable_zones` together. aspect: { covered_zones: opts.aspectZones }, }, }) .run(); } /** The secondary publishes its OWN capability, never a field on the primary's. */ function registerSecondary(opts: { ip: string; internalIp?: string }): void { db.insert(modules) .values({ id: 'dns-secondary', name: 'dns-secondary', version: '1.0.0', manifestData: {}, sourcePath: '/tmp/dns2', }) .run(); db.insert(capabilities) .values({ moduleId: 'dns-secondary', capabilityName: 'dns_internal_secondary', version: '1.0.0', data: { server: { ip: opts.ip, internal_ip: opts.internalIp } }, }) .run(); } test('internal resolver first (CIDR stripped) + public fallback', async () => { registerInternalDns('192.168.0.151/24'); const ctx = await buildResolutionContext('consumer', db); expect(ctx.selfConfig.lxc_nameserver).toBe('192.168.0.151 1.1.1.1 1.0.0.1 8.8.8.8'); }); test('bootstrap: no dns_internal provider → public resolvers only', async () => { const ctx = await buildResolutionContext('consumer', db); expect(ctx.selfConfig.lxc_nameserver).toBe('1.1.1.1 1.0.0.1 8.8.8.8'); }); test('already-bare internal IP is passed through unchanged', async () => { registerInternalDns('10.0.20.30'); const ctx = await buildResolutionContext('consumer', db); expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.20.30 1.1.1.1 1.0.0.1 8.8.8.8'); }); /** * Terraform injects `lifecycle { ignore_changes = [nameserver] }` into every * `proxmox_lxc`, so it cannot correct the birth value afterwards even in * principle. `lxc-dns-at-birth` splits ownership accordingly: terraform owns * birth DNS, the base-module aspect owns ongoing DNS. A zone the aspect does * not cover therefore has NO owner for ongoing DNS — not a late one, none — * and the birth list is that system's permanent configuration. * * That is why dropping the public resolvers is a TIGHTENING in an * aspect-covered zone and an OUTRIGHT BREAK outside one, from the same edit * (design D5d). These assert on the composed CONFIGURATION rather than on a * lookup result, deliberately: a resolution assertion passes whenever the * internal resolver happens to be up, which is nearly always, so it would * flake in the direction that reads as harness noise. The production symptom * is a correct-looking answer from the wrong view, and this string is the * only place that is ever visible. */ describe('a managed primary/secondary pair (design D5, D5d, D5e)', () => { /** * Both resolvers are reached the SAME way, and that is a consequence of * placement rather than a convenience. Design D5e puts the secondary in * `dmz` alongside the primary, so from dmz/app/secure both answer at their * own zone addresses, and from `internal` — which cannot route into the dmz * subnet — both answer at an internal-subnet address the firewall DNATs. * * This is worth stating because the symmetry is exactly what a reader * should check rather than assume. Jeremy's version placed the secondary in * `internal` and therefore selected its endpoints the other way round * (native address for internal clients, a dmz ingress for everyone else). * Under that placement this symmetric selection would be WRONG, and these * tests would still pass, because the same premise would be in the test and * in the code. If the secondary ever moves out of `dmz`, this block is what * has to change first. */ test('an aspect-covered zone drops the public resolvers', async () => { registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] }); registerSecondary({ ip: '10.0.10.6' }); const ctx = await buildResolutionContext('consumer', db); expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 10.0.10.6'); }); test('a zone NO aspect covers keeps them, because nothing will ever rewrite it', async () => { placeConsumerIn('external'); registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] }); registerSecondary({ ip: '10.0.10.6' }); const ctx = await buildResolutionContext('consumer', db); // An `external` VPS sits outside the perimeter with no route to a // dmz-resident resolver. Handing it two unreachable addresses and nothing // else does not tighten anything; it takes DNS away. So the list stays // what it has always been here — the primary, then the public resolvers // that are the only ones it can actually reach. The secondary is not // added either: a second unreachable address costs another timeout. expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 1.1.1.1 1.0.0.1 8.8.8.8'); }); test('an internal-zone system takes the zone-routable endpoints', async () => { placeConsumerIn('internal'); registerManagedPrimary({ ip: '10.0.10.5', internalIp: '192.168.0.5', aspectZones: ['dmz', 'app', 'secure', 'internal'], }); registerSecondary({ ip: '10.0.10.6', internalIp: '192.168.0.6' }); const ctx = await buildResolutionContext('consumer', db); // `internal` cannot route into the dmz subnet, so it uses the ingress // addresses the firewall DNATs — not the resolvers' own zone addresses. expect(ctx.selfConfig.lxc_nameserver).toBe('192.168.0.5 192.168.0.6'); }); test('a primary with no secondary keeps the public fallback', async () => { registerManagedPrimary({ ip: '10.0.10.5', aspectZones: ['dmz', 'app', 'secure'] }); const ctx = await buildResolutionContext('consumer', db); // Removing the fallback and shipping a secondary are ONE decision (D5a). // Without a secondary, a resolver redeploy would blank fleet DNS. expect(ctx.selfConfig.lxc_nameserver).toBe('10.0.10.5 1.1.1.1 1.0.0.1 8.8.8.8'); }); }); });