/** * The control-plane bootstrap, which replaced celilo-mgmt's `on_install` * (celilo#1225). * * Every dependency is injected, so these run against a real database and no * host: no resolver file is read, no ssh-keygen runs, no bus is opened. That is * the property the hook could not have. Its equivalents shelled out to the CLI, * so the only way to exercise them was to have a celilo on PATH — which is also * why the jail broke them and no unit test noticed. */ 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 } from '../db/client'; import { capabilities, modules, systemConfig } from '../db/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { type ControlPlaneBootstrapOptions, bootstrapControlPlane, } from './control-plane-bootstrap'; import type { FleetFinding } from './fleet-checks'; const DNS = { primary: '9.9.9.9', fallback: '8.8.4.4' }; const PUBLIC_KEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test celilo-fleet'; function dispatcher(status: FleetFinding['status']): FleetFinding { return { id: 'dispatcher', title: 'Event dispatcher', status, summary: `dispatcher ${status}`, detail: [], remediation: status === 'ok' ? null : 'start the dispatcher', autoFixable: false, }; } function configValue(db: DbClient, key: string): string | undefined { return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined; } describe('bootstrapControlPlane', () => { let dir: string; let db: DbClient; /** Every sleep the poll performed, so waiting is asserted rather than timed. */ let sleeps: number[]; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'cpb-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); sleeps = []; }); afterEach(() => { db.$client.close(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); function run(overrides: Partial = {}) { return bootstrapControlPlane({ db, discoverDnsImpl: () => DNS, ensureFleetKeyImpl: () => ({ publicKey: PUBLIC_KEY, created: true }), discoverNetworkImpl: () => ({ applied: ['network.internal.subnet = 10.0.0.0/24'] }), probeDispatcher: () => dispatcher('ok'), sleep: async (ms) => { sleeps.push(ms); }, ...overrides, }); } test('records the discovered DNS and the fleet public key in system config', async () => { await run(); expect(configValue(db, 'dns.primary')).toBe('9.9.9.9'); expect(configValue(db, 'dns.fallback')).toBe('8.8.4.4'); expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY); }); test('hands discovery every address a deployed resolver advertises (celilo#1239)', async () => { // The recurrence gate's second half: the refusal only works if bootstrap // actually tells discovery what to refuse. Seed capability rows the way a // deployed resolver provider (technitium) and its secondary // (knot-unbound-secondary) write them, including the CIDR suffix IPAM // resolves target_ip to. Selection is by the declared `fleet_resolver` // marker in the data, never the capability name. const [module] = db .insert(modules) .values({ id: 'technitium', name: 'technitium', version: '1.0.0', manifestData: {}, sourcePath: '/test/technitium', }) .returning() .all(); db.insert(capabilities) .values([ { moduleId: module.id, capabilityName: 'dns_internal', version: '1.0.0', data: { fleet_resolver: true, server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' }, }, }, { moduleId: module.id, capabilityName: 'dns_internal_secondary', version: '1.0.0', data: { fleet_resolver: true, server: { ip: '10.0.20.14' } }, }, ]) .run(); const seen: string[][] = []; await run({ discoverDnsImpl: (fleetResolverIps) => { seen.push([...fleetResolverIps]); return DNS; }, }); expect(seen).toHaveLength(1); expect(seen[0].sort()).toEqual(['10.0.10.13', '192.168.0.151/24', '10.0.20.14'].sort()); }); test('a resolver capability without the declared marker is not a fleet resolver', async () => { // Selection is by the DECLARED marker, not the capability name. A row // named like a resolver but not declaring fleet_resolver must be ignored, // otherwise renaming this to a name check would pass every other test // here and silently re-open celilo#1239 to name-shaped drift. const [module] = db .insert(modules) .values({ id: 'technitium', name: 'technitium', version: '1.0.0', manifestData: {}, sourcePath: '/test/technitium', }) .returning() .all(); db.insert(capabilities) .values({ moduleId: module.id, capabilityName: 'dns_internal', version: '1.0.0', data: { server: { ip: '10.0.10.13', internal_ip: '192.168.0.151/24' } }, }) .run(); const seen: string[][] = []; await run({ discoverDnsImpl: (fleetResolverIps) => { seen.push([...fleetResolverIps]); return DNS; }, }); expect(seen).toEqual([[]]); }); test('a capability of any name declaring the marker is a fleet resolver', async () => { // The other half of the same proof: a NEW resolver provider needs no core // change. Its capability name is irrelevant; the declaration is what counts. const [module] = db .insert(modules) .values({ id: 'technitium', name: 'technitium', version: '1.0.0', manifestData: {}, sourcePath: '/test/technitium', }) .returning() .all(); db.insert(capabilities) .values({ moduleId: module.id, capabilityName: 'some_future_resolver', version: '1.0.0', data: { fleet_resolver: true, server: { ip: '10.0.30.15' } }, }) .run(); const seen: string[][] = []; await run({ discoverDnsImpl: (fleetResolverIps) => { seen.push([...fleetResolverIps]); return DNS; }, }); expect(seen).toEqual([['10.0.30.15']]); }); test('returns what each step found, so the caller renders and this does not', async () => { const result = await run(); expect(result.dns).toEqual(DNS); expect(result.fleetKey).toEqual({ publicKey: PUBLIC_KEY, created: true }); expect(result.network.applied).toEqual(['network.internal.subnet = 10.0.0.0/24']); expect(result.dispatcher.status).toBe('ok'); }); test('a dispatcher already up is probed once and never waited for', async () => { let probes = 0; await run({ probeDispatcher: () => { probes++; return dispatcher('ok'); }, }); expect(probes).toBe(1); expect(sleeps).toEqual([]); }); test('a dispatcher still starting is polled until it answers', async () => { // The role enables the supervisor unit moments before this runs, so the // first probe legitimately finds nothing. Two failures then success. const readings: FleetFinding['status'][] = ['fail', 'fail', 'ok']; let probes = 0; const result = await run({ probeDispatcher: () => dispatcher(readings[probes++] ?? 'ok'), }); expect(probes).toBe(3); expect(sleeps).toEqual([1000, 1000]); expect(result.dispatcher.status).toBe('ok'); }); test('a dispatcher that never answers gives up after the attempt budget', async () => { let probes = 0; const result = await run({ probeDispatcher: () => { probes++; return dispatcher('fail'); }, dispatcherAttempts: 5, }); expect(probes).toBe(5); expect(sleeps).toHaveLength(4); // Reported, not thrown. The caller decides how loud to be, which is what // lets `module deploy` fail the install and a later caller merely warn. expect(result.dispatcher.status).toBe('fail'); }); test('a warn reading is accepted rather than polled against', async () => { let probes = 0; await run({ probeDispatcher: () => { probes++; return dispatcher('warn'); }, }); expect(probes).toBe(1); expect(sleeps).toEqual([]); }); test('a box with no default route still gets its DNS and key recorded', async () => { // `discoverAndRecordNetwork` reports a skip rather than throwing, and a // box celilo cannot read a network from must not fail the whole deploy. const result = await run({ discoverNetworkImpl: () => ({ applied: [], skipped: 'no default route' }), }); expect(result.network.skipped).toBe('no default route'); expect(configValue(db, 'dns.primary')).toBe('9.9.9.9'); expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY); }); test('an existing fleet key is recorded without being reminted', async () => { const result = await run({ ensureFleetKeyImpl: () => ({ publicKey: PUBLIC_KEY, created: false }), }); expect(result.fleetKey.created).toBe(false); expect(configValue(db, 'ssh.public_key')).toBe(PUBLIC_KEY); }); });