// Slice 3 of openspec/changes/module-orchestrator-primitives: celilo performs // the dns.primary repoint when a dns_internal provider deploys (D5), gated on // an askConfirm interview. knot's on_install no longer touches these keys. import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { type DbClient, closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { capabilities, moduleSystems, modules, systemConfig } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { InterviewUnansweredError } from './interview-errors'; import { planDnsRepoint, repointDnsPrimaryForProvider } from './module-deploy'; const MODULE_ID = 'knot-unbound-internal'; const SERVER_IP = '10.226.10.53'; type AskOpts = { scope: string; key: string; message: string }; function makeAsk(answer: boolean): ((opts: AskOpts) => Promise) & { calls: AskOpts[] } { const calls: AskOpts[] = []; const ask = async (opts: AskOpts) => { calls.push(opts); return answer; }; return Object.assign(ask, { calls }); } function makeAskThrowing(err: Error): ((opts: AskOpts) => Promise) & { calls: AskOpts[] } { const calls: AskOpts[] = []; const ask = async (opts: AskOpts) => { calls.push(opts); throw err; }; return Object.assign(ask, { calls }); } describe('repointDnsPrimaryForProvider (module-orchestrator-primitives slice 3)', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-dns-repoint-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); db = getDb(); seedProvider(); }); afterEach(() => { closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir cleanup is best-effort */ } }); function seedProvider() { db.insert(modules) .values({ id: MODULE_ID, name: MODULE_ID, version: '1.3.4', manifestData: { id: MODULE_ID, name: MODULE_ID, version: '1.3.4', celilo_contract: '1.0' }, sourcePath: `/tmp/${MODULE_ID}`, }) .run(); db.insert(capabilities) .values({ moduleId: MODULE_ID, capabilityName: 'dns_internal', version: '1.0.0', data: {} }) .run(); db.insert(moduleSystems) .values({ moduleId: MODULE_ID, name: 'main', hostname: 'dns', ipv4Address: SERVER_IP, zone: 'dmz', infraType: 'container_service', }) .run(); } function setConfig(key: string, value: string) { db.insert(systemConfig) .values({ key, value }) .onConflictDoUpdate({ target: systemConfig.key, set: { value } }) .run(); } function configValue(key: string): string | undefined { return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value; } test('3.1: repoints dns.primary at the resolver and demotes the prior primary WITHOUT losing the existing fallback', async () => { setConfig('dns.primary', '1.1.1.1'); setConfig('dns.fallback', '8.8.8.8'); const ask = makeAsk(true); await repointDnsPrimaryForProvider(MODULE_ID, db, ask); expect(configValue('dns.primary')).toBe(SERVER_IP); // The WHOLE resulting value: the existing fallback must survive beside the // demoted prior primary (D5's secondary observation — knot's hook lost it). expect(configValue('dns.fallback')).toBe('1.1.1.1,8.8.8.8'); expect(ask.calls).toHaveLength(1); expect(ask.calls[0].message).toContain(SERVER_IP); expect(ask.calls[0].message).toContain('1.1.1.1'); }); test('3.1: demotes the prior primary to dns.fallback when no fallback existed', async () => { setConfig('dns.primary', '1.1.1.1'); const ask = makeAsk(true); await repointDnsPrimaryForProvider(MODULE_ID, db, ask); expect(configValue('dns.primary')).toBe(SERVER_IP); expect(configValue('dns.fallback')).toBe('1.1.1.1'); }); test('3.1: sets dns.primary without touching dns.fallback when no primary was set', async () => { const ask = makeAsk(true); await repointDnsPrimaryForProvider(MODULE_ID, db, ask); expect(configValue('dns.primary')).toBe(SERVER_IP); expect(configValue('dns.fallback')).toBeUndefined(); }); test('does not ask when dns.primary already names this resolver (re-deploy)', async () => { setConfig('dns.primary', SERVER_IP); setConfig('dns.fallback', '8.8.8.8'); const ask = makeAsk(true); await repointDnsPrimaryForProvider(MODULE_ID, db, ask); expect(ask.calls).toHaveLength(0); expect(configValue('dns.primary')).toBe(SERVER_IP); expect(configValue('dns.fallback')).toBe('8.8.8.8'); }); test('3.2: a negative answer leaves both keys unchanged and does not fail the deploy', async () => { setConfig('dns.primary', '1.1.1.1'); setConfig('dns.fallback', '8.8.8.8'); const ask = makeAsk(false); await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined(); expect(configValue('dns.primary')).toBe('1.1.1.1'); expect(configValue('dns.fallback')).toBe('8.8.8.8'); expect(ask.calls).toHaveLength(1); }); test('no responder able to answer (headless) is a defined skip, never a hang', async () => { setConfig('dns.primary', '1.1.1.1'); const ask = makeAskThrowing( new InterviewUnansweredError('interview.required.deploy:knot.repoint_dns_primary', 'nobody'), ); await expect(repointDnsPrimaryForProvider(MODULE_ID, db, ask)).resolves.toBeUndefined(); expect(configValue('dns.primary')).toBe('1.1.1.1'); }); }); describe('planDnsRepoint', () => { test('merges the existing fallback without duplicating the demoted primary', () => { const plan = planDnsRepoint({ serverIp: SERVER_IP, priorPrimary: '1.1.1.1', existingFallback: '1.1.1.1,9.9.9.9', }); expect(plan.needsRepoint).toBe(true); expect(plan.newFallback).toBe('1.1.1.1,9.9.9.9'); }); test('already-primary is a no-op plan', () => { const plan = planDnsRepoint({ serverIp: SERVER_IP, priorPrimary: SERVER_IP, existingFallback: '8.8.8.8', }); expect(plan.needsRepoint).toBe(false); expect(plan.newFallback).toBeUndefined(); }); });