import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { type DbClient, createDbClient } from '../db/client'; import { systemConfig } from '../db/schema'; import { upsertModuleConfig } from '../services/module-config'; import { resetTestDbPath } from '../test-utils/db-path'; import { buildHostVars, buildSystemVars, extractInventoryHost, generateGroupVarsYaml, generateHostVarsYaml, generateHostsIni, parseConfigValue, } from './inventory'; import type { InventoryHost } from './inventory'; const TEST_DB_PATH = './test-inventory.db'; describe('generateHostsIni', () => { test('generates INI format for single host', () => { const hosts: InventoryHost[] = [ { hostname: 'iot', ansibleHost: '192.168.0.110', ansibleUser: 'root', groups: ['homebridge'], }, ]; const result = generateHostsIni(hosts); expect(result).toContain('[homebridge]'); expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root'); }); test('generates INI format for host in multiple groups', () => { const hosts: InventoryHost[] = [ { hostname: 'web', ansibleHost: '10.0.10.10', ansibleUser: 'root', groups: ['web_server', 'production'], }, ]; const result = generateHostsIni(hosts); expect(result).toContain('[web_server]'); expect(result).toContain('[production]'); expect(result).toContain('web ansible_host=10.0.10.10 ansible_user=root'); }); test('generates INI format for multiple hosts', () => { const hosts: InventoryHost[] = [ { hostname: 'iot', ansibleHost: '192.168.0.110', ansibleUser: 'root', groups: ['homebridge'], }, { hostname: 'dns-ext', ansibleHost: '192.0.2.20', ansibleUser: 'root', groups: ['dns_external'], }, ]; const result = generateHostsIni(hosts); expect(result).toContain('[homebridge]'); expect(result).toContain('[dns_external]'); expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root'); expect(result).toContain('dns-ext ansible_host=192.0.2.20 ansible_user=root'); }); test('includes SSH private key file path when provided', () => { const keyPath = '/tmp/celilo-ansible-keys/machine-machine-1.key'; const hosts: InventoryHost[] = [ { hostname: 'machine-host', ansibleHost: '192.168.1.100', ansibleUser: 'ubuntu', groups: ['machines'], ansibleSshPrivateKeyFile: keyPath, }, ]; const result = generateHostsIni(hosts); expect(result).toContain('[machines]'); expect(result).toContain( `machine-host ansible_host=192.168.1.100 ansible_user=ubuntu ansible_ssh_private_key_file=${keyPath}`, ); }); test('emits ansible_connection=local for a local machine (no SSH host/key)', () => { // The management box deploying to itself uses Ansible's local // connection (openspec/specs/bootstrap-meta-package/spec.md). const hosts: InventoryHost[] = [ { hostname: 'celilo-mgr', ansibleHost: '127.0.0.1', ansibleUser: 'root', groups: ['celilo-mgmt'], local: true, // even if a key path were present, local hosts must ignore it ansibleSshPrivateKeyFile: '/tmp/should-not-appear.key', }, ]; const result = generateHostsIni(hosts); expect(result).toContain('celilo-mgr ansible_connection=local'); expect(result).not.toContain('ansible_host=127.0.0.1'); expect(result).not.toContain('should-not-appear'); }); }); describe('generateHostVarsYaml', () => { test('generates YAML for simple variables', () => { const vars = { vmid: 2110, hostname: 'iot', target_ip: '192.168.0.110/24', }; const result = generateHostVarsYaml(vars); expect(result).toContain('vmid: 2110'); expect(result).toContain('hostname: iot'); expect(result).toContain('target_ip: 192.168.0.110/24'); }); test('generates YAML for arrays', () => { const vars = { zone_records: [ { name: 'ns1', type: 'A', value: '192.0.2.20' }, { name: 'www', type: 'A', value: '203.0.113.10' }, ], }; const result = generateHostVarsYaml(vars); expect(result).toContain('zone_records:'); expect(result).toContain('- name: ns1'); expect(result).toContain('type: A'); expect(result).toContain('value: 192.0.2.20'); expect(result).toContain('- name: www'); expect(result).toContain('value: 203.0.113.10'); }); test('generates YAML for nested objects', () => { const vars = { network: { interface: 'eth0', ip: '192.168.0.110', gateway: '192.168.0.254', }, }; const result = generateHostVarsYaml(vars); expect(result).toContain('network:'); expect(result).toContain('interface: eth0'); expect(result).toContain('ip: 192.168.0.110'); expect(result).toContain('gateway: 192.168.0.254'); }); test('sorts keys for deterministic output', () => { const vars = { zebra: 'last', apple: 'first', middle: 'second', }; const result = generateHostVarsYaml(vars); const lines = result.split('\n'); const appleIndex = lines.findIndex((l) => l.includes('apple')); const middleIndex = lines.findIndex((l) => l.includes('middle')); const zebraIndex = lines.findIndex((l) => l.includes('zebra')); expect(appleIndex).toBeLessThan(middleIndex); expect(middleIndex).toBeLessThan(zebraIndex); }); }); describe('generateGroupVarsYaml', () => { test('generates YAML for system config', () => { const vars = { dns_primary: '192.168.0.1', dns_fallback: '8.8.8.8 1.1.1.1', routing_internal_gateway: '192.168.0.254', }; const result = generateGroupVarsYaml(vars); expect(result).toContain('dns_primary: 192.168.0.1'); expect(result).toContain('dns_fallback: 8.8.8.8 1.1.1.1'); expect(result).toContain('routing_internal_gateway: 192.168.0.254'); }); }); describe('parseConfigValue', () => { test('parses JSON numbers', () => { expect(parseConfigValue('2110')).toBe(2110); expect(parseConfigValue('42.5')).toBe(42.5); }); test('parses JSON booleans', () => { expect(parseConfigValue('true')).toBe(true); expect(parseConfigValue('false')).toBe(false); }); test('parses JSON arrays', () => { const result = parseConfigValue('["homebridge", "production"]'); expect(Array.isArray(result)).toBe(true); expect(result).toEqual(['homebridge', 'production']); }); test('parses JSON objects', () => { const result = parseConfigValue('{"name":"test","value":123}'); expect(typeof result).toBe('object'); expect(result).toEqual({ name: 'test', value: 123 }); }); test('returns string for non-JSON values', () => { expect(parseConfigValue('iot')).toBe('iot'); expect(parseConfigValue('192.168.0.110/24')).toBe('192.168.0.110/24'); expect(parseConfigValue('Home Bridge')).toBe('Home Bridge'); }); }); describe('Database integration', () => { let db: DbClient; beforeEach(() => { // Set environment variable for test database process.env.CELILO_DB_PATH = TEST_DB_PATH; db = createDbClient({ path: TEST_DB_PATH }); // Create tables db.$client.run(` CREATE TABLE IF NOT EXISTS modules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'IMPORTED', manifest_data TEXT NOT NULL, source_path TEXT NOT NULL, imported_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), error_message TEXT ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, value_json TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS system_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, description TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); }); afterEach(async () => { db.$client.close(); // Clean up environment variable resetTestDbPath(); if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } // Clean up WAL files if (existsSync(`${TEST_DB_PATH}-wal`)) { await rm(`${TEST_DB_PATH}-wal`); } if (existsSync(`${TEST_DB_PATH}-shm`)) { await rm(`${TEST_DB_PATH}-shm`); } }); describe('buildHostVars', () => { test('builds host vars from module config', () => { // Insert module first (for foreign key) db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`, ); // Insert module config via the typed-storage helper so // parseStoredConfigValue can roundtrip them. Numbers stay numbers, // strings stay strings, etc. (vmid=2110, cores=1 are integers in // the manifest — test that the type round-trips correctly.) upsertModuleConfig(db, 'homebridge', 'vmid', 2110); upsertModuleConfig(db, 'homebridge', 'hostname', 'iot'); upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.110/24'); upsertModuleConfig(db, 'homebridge', 'cores', 1); const vars = buildHostVars('homebridge', db); expect(vars.vmid).toBe(2110); expect(vars.hostname).toBe('iot'); expect(vars.target_ip).toBe('192.168.0.110/24'); expect(vars.cores).toBe(1); }); test('converts dot notation to underscores', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'test', 'network.interface', 'eth0'); upsertModuleConfig(db, 'test', 'network.ip', '192.168.0.1'); const vars = buildHostVars('test', db); expect(vars.network_interface).toBe('eth0'); expect(vars.network_ip).toBe('192.168.0.1'); }); test('skips inventory.* keys', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'test', 'hostname', 'iot'); upsertModuleConfig(db, 'test', 'inventory.hostname', 'iot'); upsertModuleConfig(db, 'test', 'inventory.ansible_host', '192.168.0.110'); const vars = buildHostVars('test', db); expect(vars.hostname).toBe('iot'); expect(vars.inventory_hostname).toBeUndefined(); expect(vars.inventory_ansible_host).toBeUndefined(); }); test('parses array values', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns', 'DNS', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'dns', 'zone_records', [ { name: 'ns1', type: 'A', value: '192.0.2.20' }, ]); const vars = buildHostVars('dns', db); expect(Array.isArray(vars.zone_records)).toBe(true); expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '192.0.2.20' }]); }); }); describe('buildSystemVars', () => { test('builds system vars from system config', () => { db.insert(systemConfig) .values([ { key: 'dns.primary', value: '192.168.0.1' }, { key: 'dns.fallback', value: '8.8.8.8 1.1.1.1' }, { key: 'network.bridge', value: 'vmbr0' }, ]) .run(); const vars = buildSystemVars(db); expect(vars.dns_primary).toBe('192.168.0.1'); expect(vars.dns_fallback).toBe('8.8.8.8 1.1.1.1'); expect(vars.network_bridge).toBe('vmbr0'); }); test('returns empty object when no system config', () => { const vars = buildSystemVars(db); expect(vars).toEqual({}); }); }); describe('extractInventoryHost', () => { test('extracts inventory host from config with auto-derivation', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'homebridge', 'hostname', 'iot'); upsertModuleConfig(db, 'homebridge', 'target_ip', '192.168.0.110/24'); const host = extractInventoryHost('homebridge', db); expect(host).not.toBeNull(); expect(host?.hostname).toBe('iot'); expect(host?.ansibleHost).toBe('192.168.0.110'); // CIDR stripped expect(host?.ansibleUser).toBe('root'); // Default expect(host?.groups).toEqual(['homebridge']); // Module ID }); test('handles VPS-based modules', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS External', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext'); upsertModuleConfig(db, 'dns-external', 'vps_ip', '192.0.2.20'); const host = extractInventoryHost('dns-external', db); expect(host).not.toBeNull(); expect(host?.hostname).toBe('dns-ext'); expect(host?.ansibleHost).toBe('192.0.2.20'); // VPS IP used directly expect(host?.ansibleUser).toBe('root'); expect(host?.groups).toEqual(['dns-external']); }); test('returns null when required fields missing', () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test', 'Test', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'test', 'hostname', 'iot'); // Missing target_ip or vps_ip for ansible_host const host = extractInventoryHost('test', db); expect(host).toBeNull(); }); test('returns null when no inventory config', () => { const host = extractInventoryHost('nonexistent', db); expect(host).toBeNull(); }); }); });