/** * `requires.networks` is ensured before the deploy proceeds * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * * The asker is injected, so these assert the RULES — which questions get asked, * what gets written, what is left alone — without a bus or a responder. */ import { beforeEach, describe, expect, test } from 'bun:test'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { machines, systemConfig } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { setupTestDatabase } from '../test-utils/database'; import { type NetworkAsker, ensureRequiredNetworks } from './network-ensure'; function manifestRequiring(...networks: string[]): ModuleManifest { return { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '0.1.0', requires: { capabilities: [], networks: networks.map((name) => ({ name })), }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, } as unknown as ModuleManifest; } /** Records every question and answers each from `answers`, keyed `.`. */ function recordingAsker(answers: Record): { ask: NetworkAsker; asked: Array<{ scope: string; key: string; defaultValue?: string }>; } { const asked: Array<{ scope: string; key: string; defaultValue?: string }> = []; const ask: NetworkAsker = async (question) => { asked.push({ scope: question.scope, key: question.key, defaultValue: question.defaultValue, }); const answer = answers[`${question.scope}.${question.key}`]; if (answer === undefined) { throw new Error(`test asker has no answer for ${question.scope}.${question.key}`); } return answer; }; return { ask, asked }; } function readConfig(db: DbClient, key: string): string | undefined { return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value; } type Iface = { name: string; ipAddress: string; zone: string }; /** * Put a machine in the catalogue, the way `machine add` does. * * `role` is the load-bearing field here: only a router is a gateway. The * recorded `zone` on an interface is deliberately left wrong/unknown in these * fixtures, because that is the real state before a subnet has been declared — * an interface cannot be classified into a zone that does not exist yet. */ function seedMachine(db: DbClient, role: 'host' | 'router', interfaces: Iface[]): void { db.insert(machines) .values({ id: `machine-${role}-${interfaces[0]?.ipAddress ?? 'none'}`, hostname: `${role}-box`, ipAddress: interfaces[0]?.ipAddress ?? '127.0.0.1', sshUser: 'root', sshKeyEncrypted: 'x', zone: 'internal', hardware: { cpu_cores: 1, memory_mb: 512, disk_gb: 10 }, role, interfaces, }) .run(); } function seedRouter(db: DbClient, interfaces: Iface[]): void { seedMachine(db, 'router', interfaces); } function seedHost(db: DbClient, interfaces: Iface[]): void { seedMachine(db, 'host', interfaces); } describe('ensureRequiredNetworks', () => { let db: DbClient; beforeEach(async () => { db = await setupTestDatabase(); }); test('a module requiring no network asks nothing', async () => { const { ask, asked } = recordingAsker({}); const result = await ensureRequiredNetworks('test-module', manifestRequiring(), db, ask); expect(result.success).toBe(true); expect(asked).toEqual([]); }); test('an undefined required network is asked for and written to system config', async () => { const { ask, asked } = recordingAsker({ 'network:control-plane-vpn.subnet': '10.9.9.0/24', }); const result = await ensureRequiredNetworks( 'wireguard', manifestRequiring('control-plane-vpn'), db, ask, ); expect(result.success).toBe(true); expect(asked.map((a) => `${a.scope}.${a.key}`)).toEqual(['network:control-plane-vpn.subnet']); expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBe('10.9.9.0/24'); }); test('a network that is already defined is left alone — no question, no rewrite', async () => { db.insert(systemConfig) .values({ key: 'network.control-plane-vpn.subnet', value: '10.7.7.0/24' }) .run(); const { ask, asked } = recordingAsker({}); const result = await ensureRequiredNetworks( 'wireguard', manifestRequiring('control-plane-vpn'), db, ask, ); expect(result.success).toBe(true); expect(asked).toEqual([]); expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBe('10.7.7.0/24'); }); /** * Which attributes a network HAS is celilo's answer, and WHICH ARE QUESTIONS * is a second answer on top of it. The config schema gives dmz a vlan and * gives the control-plane VPN none, so the VPN is never asked a question that * makes no sense for it — and the gateway is never asked of anything, because * celilo can look it up. */ test('vlan is asked only for the networks celilo says have one; gateway is never asked', async () => { const { ask, asked } = recordingAsker({ 'network:dmz.subnet': '10.0.10.0/24', 'network:dmz.vlan': '10', 'network:control-plane-vpn.subnet': '10.9.9.0/24', }); const result = await ensureRequiredNetworks( 'iptables', manifestRequiring('dmz', 'control-plane-vpn'), db, ask, ); expect(result.success).toBe(true); expect(asked.map((a) => `${a.scope}.${a.key}`)).toEqual([ 'network:dmz.subnet', 'network:dmz.vlan', 'network:control-plane-vpn.subnet', ]); expect(asked.map((a) => a.key)).not.toContain('gateway'); expect(readConfig(db, 'network.dmz.vlan')).toBe('10'); expect(readConfig(db, 'network.control-plane-vpn.vlan')).toBeUndefined(); }); /** * The failure this test exists for, found on the rig. * * `iptables` requires `internal`, whose subnet the management install had * already recorded and whose vlan nothing ever set. The ensure asked for the * vlan, headlessly, with no responder attached, and the deploy died on * `interview.required.network:internal.vlan` — a question about a network that * had existed since the fleet was built. * * celilo is here to DEFINE a network, not to audit one it already holds. An * existing network was already defined without a tag, deliberately or because * it is untagged; re-opening that every time a new module requires it is not a * question, it is a deploy that stops. */ test('an existing network is never re-asked about, vlan included', async () => { db.insert(systemConfig).values({ key: 'network.dmz.subnet', value: '10.0.10.0/24' }).run(); const { ask, asked } = recordingAsker({}); const result = await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); expect(result.success).toBe(true); expect(asked).toEqual([]); expect(readConfig(db, 'network.dmz.vlan')).toBeUndefined(); }); test('a blank vlan answer writes nothing — untagged is a real answer, not an empty tag', async () => { const { ask } = recordingAsker({ 'network:dmz.subnet': '10.0.10.0/24', 'network:dmz.vlan': '', }); await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); expect(readConfig(db, 'network.dmz.subnet')).toBe('10.0.10.0/24'); expect(readConfig(db, 'network.dmz.vlan')).toBeUndefined(); }); /** * The gateway is a fact about the fleet, not a decision: it is the address a * router answers on inside the subnet, and `machine add` already catalogued it. * Matched by CONTAINMENT rather than by the interface's recorded zone, because * an interface has no zone until the subnet it sits in has been declared — * which is the very thing that just happened one line earlier. */ test('the gateway is observed from a catalogued router leg, never asked', async () => { seedRouter(db, [{ name: 'eth1', ipAddress: '10.0.10.254', zone: 'unknown' }]); const { ask, asked } = recordingAsker({ 'network:dmz.subnet': '10.0.10.0/24', 'network:dmz.vlan': '', }); await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); expect(asked.map((a) => a.key)).not.toContain('gateway'); // Not x.x.x.1 — the address the router actually holds. expect(readConfig(db, 'network.dmz.gateway')).toBe('10.0.10.254'); }); test('a host inside the subnet is not mistaken for the gateway', async () => { seedHost(db, [{ name: 'eth0', ipAddress: '10.0.10.50', zone: 'dmz' }]); const { ask } = recordingAsker({ 'network:dmz.subnet': '10.0.10.0/24', 'network:dmz.vlan': '', }); await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); // Only a router is a gateway. Guessing from any host in the subnet would // hand every container in that zone a default route to a random peer. expect(readConfig(db, 'network.dmz.gateway')).toBeUndefined(); }); test('no catalogued router leg leaves the gateway unset rather than invented', async () => { const { ask } = recordingAsker({ 'network:dmz.subnet': '10.0.10.0/24', 'network:dmz.vlan': '', }); await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); expect(readConfig(db, 'network.dmz.gateway')).toBeUndefined(); }); /** * The offer is a starting point in a question, never a row nobody chose — so * it has to reach the asker and it has to be VISIBLE. A defaultValue with no * matching placeholder is a default the operator cannot see and cannot know * they may accept with Enter. */ test('a well-known name is offered a suggested range, visibly', async () => { const asked: Array<{ key: string; defaultValue?: string; placeholder?: string }> = []; const ask: NetworkAsker = async (question) => { asked.push({ key: question.key, defaultValue: question.defaultValue, placeholder: question.placeholder, }); return question.key === 'subnet' ? '10.44.0.0/24' : ''; }; const result = await ensureRequiredNetworks('iptables', manifestRequiring('dmz'), db, ask); expect(result.success).toBe(true); expect(asked[0].key).toBe('subnet'); expect(asked[0].defaultValue).toBe('10.0.10.0/24'); expect(asked[0].placeholder).toBe(asked[0].defaultValue); // The operator's answer wins over the offer, which is the whole point of // offering rather than defaulting. expect(readConfig(db, 'network.dmz.subnet')).toBe('10.44.0.0/24'); }); test('nothing is written when the module requires nothing — a deploy seeds no network', async () => { const { ask } = recordingAsker({}); await ensureRequiredNetworks('test-module', manifestRequiring(), db, ask); const networkRows = db .select() .from(systemConfig) .all() .filter((row) => row.key.startsWith('network.')); expect(networkRows.filter((row) => row.key.endsWith('.subnet'))).toEqual([]); }); test('requiring a network celilo has never heard of fails with an actionable message', async () => { const { ask } = recordingAsker({}); const result = await ensureRequiredNetworks( 'test-module', manifestRequiring('not-a-network'), db, ask, ); expect(result.success).toBe(false); expect(result.error).toContain('not-a-network'); expect(result.error).toContain('system_config.json'); }); test('an empty answer fails the deploy rather than writing an empty network', async () => { const ask: NetworkAsker = async () => ' '; const result = await ensureRequiredNetworks( 'wireguard', manifestRequiring('control-plane-vpn'), db, ask, ); expect(result.success).toBe(false); expect(readConfig(db, 'network.control-plane-vpn.subnet')).toBeUndefined(); }); });