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, containerServices, ipAllocations, machines, moduleConfigs, moduleInfrastructure, moduleSystems, modules, secrets, systemConfig, } from '../db/schema'; import { buildContextFromData, buildResolutionContext } from './context'; const TEST_DB_PATH = './test-context.db'; /** * Select a proxmox container_service for a module + give it a hostname, so the * deployed-system recording in buildResolutionContext fires (IPAM allocates and * records into module_systems). openspec/specs/module-systems-addressing/spec.md. */ function setupProxmoxInfra(db: DbClient, moduleId: string, zone: string): void { const serviceId = `svc-${moduleId}`; db.insert(containerServices) .values({ id: serviceId, serviceId, name: 'Test Proxmox', providerName: 'proxmox', zones: [zone] as Array<'internal' | 'dmz' | 'app' | 'secure' | 'external'>, apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }), providerConfig: { default_target_node: 'pve', lxc_template: 't', storage: 's' }, verified: true, }) .run(); db.insert(moduleInfrastructure) .values({ id: `infra-${moduleId}`, moduleId, infrastructureType: 'container_service', serviceId, }) .run(); db.insert(moduleConfigs) .values({ moduleId, key: 'hostname', value: moduleId, valueJson: JSON.stringify(moduleId) }) .run(); } describe('Variable Context', () => { let db: DbClient; beforeEach(() => { 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()), UNIQUE(module_id, key), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, capability_name TEXT NOT NULL, version TEXT NOT NULL, data TEXT NOT NULL, registered_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, name TEXT NOT NULL, encrypted_value TEXT NOT NULL, iv TEXT NOT NULL, auth_tag TEXT NOT NULL, 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()) ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS ip_allocations ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, vmid INTEGER NOT NULL UNIQUE, container_ip TEXT NOT NULL UNIQUE, zone TEXT NOT NULL, allocated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS vmid_reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, vmid INTEGER NOT NULL UNIQUE, reason TEXT NOT NULL, reserved_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS ip_reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_start TEXT NOT NULL, ip_end TEXT, zone TEXT NOT NULL, reason TEXT NOT NULL, reserved_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); // Insert network config for DMZ zone (required for IPAM allocation) db.insert(systemConfig) .values([ { key: 'network.dmz.subnet', value: '10.0.10.0/24' }, { key: 'network.dmz.gateway', value: '10.0.10.1' }, { key: 'network.dmz.vlan', value: '10' }, { key: 'network.dmz.bridge', value: 'vmbr0' }, // App zone config { key: 'network.app.subnet', value: '10.0.20.0/24' }, { key: 'network.app.gateway', value: '10.0.20.1' }, { key: 'network.app.vlan', value: '20' }, { key: 'network.app.bridge', value: 'vmbr0' }, // Secure zone config { key: 'network.secure.subnet', value: '10.0.30.0/24' }, { key: 'network.secure.gateway', value: '10.0.30.1' }, { key: 'network.secure.vlan', value: '30' }, { key: 'network.secure.bridge', value: 'vmbr0' }, ]) .run(); }); afterEach(async () => { db.$client.close(); if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } const walPath = `${TEST_DB_PATH}-wal`; const shmPath = `${TEST_DB_PATH}-shm`; if (existsSync(walPath)) { await rm(walPath); } if (existsSync(shmPath)) { await rm(shmPath); } }); describe('buildResolutionContext', () => { test('records a local machine by its interface identity instead of its transport sentinel', async () => { db.insert(modules) .values({ id: 'celilo-mgmt', name: 'Celilo Management', version: '1.0.0', sourcePath: '/test/celilo-mgmt', manifestData: { id: 'celilo-mgmt', name: 'Celilo Management', version: '1.0.0', requires: { system: { zone: 'internal' } }, }, }) .run(); db.insert(moduleConfigs) .values({ moduleId: 'celilo-mgmt', key: 'hostname', value: 'celilo-mgr', valueJson: '"celilo-mgr"', }) .run(); db.insert(machines) .values({ id: 'local-manager', hostname: 'celilo-mgr', ipAddress: '127.0.0.1', sshUser: 'jem', sshKeyEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }), hardware: { cpu_cores: 4, memory_mb: 8192, disk_gb: 64 }, zone: 'internal', earmarkedModule: 'celilo-mgmt', interfaces: [ { name: 'ens18', ipAddress: '10.77.20.32', zone: 'internal' }, { name: 'wlan0', ipAddress: '192.168.0.32', zone: 'upstream' }, ], }) .run(); db.insert(moduleInfrastructure) .values({ id: 'infra-celilo-mgmt', moduleId: 'celilo-mgmt', infrastructureType: 'machine', machineId: 'local-manager', }) .run(); await buildResolutionContext('celilo-mgmt', db); const system = db .select() .from(moduleSystems) .where(eq(moduleSystems.moduleId, 'celilo-mgmt')) .get(); expect(system).toMatchObject({ hostname: 'celilo-mgr', ipv4Address: '10.77.20.32', zone: 'internal', }); }); test('should build context with module configs', async () => { // 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', '{}')`, ); db.insert(moduleConfigs) .values([ { moduleId: 'homebridge', key: 'target_ip', value: '192.168.0.50' }, { moduleId: 'homebridge', key: 'hostname', value: 'homebridge' }, ]) .run(); const context = await buildResolutionContext('homebridge', db); expect(context.moduleId).toBe('homebridge'); expect(context.selfConfig.target_ip).toBe('192.168.0.50'); expect(context.selfConfig.hostname).toBe('homebridge'); // Auto-derived variables expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.0.50'); expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('homebridge'); }); test('should build context with secrets', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`, ); db.insert(secrets) .values([ { moduleId: 'homebridge', name: 'api_key', encryptedValue: 'secret123', iv: 'iv', authTag: 'tag', }, ]) .run(); const context = await buildResolutionContext('homebridge', db); expect(context.secrets).toEqual({ api_key: 'secret123', }); }); test('should build context with capabilities', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS', '1.0.0', '/path', '{}')`, ); db.insert(capabilities) .values([ { moduleId: 'dns-external', capabilityName: 'dns_external', version: '1.0.0', data: { nameserver: 'ns1.example.com', zone: 'example.com', }, }, ]) .run(); const context = await buildResolutionContext('homebridge', db); expect(context.capabilities).toEqual({ dns_external: { nameserver: 'ns1.example.com', zone: 'example.com', }, }); }); test('should load system config from database', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('homebridge', 'Homebridge', '1.0.0', '/path', '{}')`, ); db.$client.run( `INSERT INTO system_config (key, value) VALUES ('dns.primary', '192.168.0.1')`, ); db.$client.run( `INSERT INTO system_config (key, value) VALUES ('network.domain', 'homelab.local')`, ); const context = await buildResolutionContext('homebridge', db); expect(context.systemConfig['dns.primary']).toBe('192.168.0.1'); expect(context.systemConfig['network.domain']).toBe('homelab.local'); }); test('should build context with all data sources', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '/path', '{}')`, ); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-external', 'DNS', '1.0.0', '/path', '{}')`, ); db.insert(moduleConfigs) .values({ moduleId: 'caddy', key: 'target_ip', value: '10.0.20.10' }) .run(); db.insert(secrets) .values({ moduleId: 'caddy', name: 'ssl_cert', encryptedValue: 'cert_data', iv: 'iv', authTag: 'tag', }) .run(); db.insert(capabilities) .values({ moduleId: 'dns-external', capabilityName: 'dns_external', version: '1.0.0', data: { nameserver: 'ns1.example.com' }, }) .run(); db.$client.run( `INSERT INTO system_config (key, value) VALUES ('dns.primary', '192.168.0.1')`, ); const context = await buildResolutionContext('caddy', db); expect(context.selfConfig.target_ip).toBe('10.0.20.10'); expect(context.secrets.ssl_cert).toBe('cert_data'); expect(context.capabilities.dns_external).toBeDefined(); expect(context.systemConfig['dns.primary']).toBe('192.168.0.1'); }); test('should return empty maps for module with no data', async () => { const context = await buildResolutionContext('empty-module', db); expect(context.moduleId).toBe('empty-module'); // Should have auto-derived inventory variables expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('empty-module'); // Should not have ansible_host (no target_ip) expect(context.selfConfig['inventory.ansible_host']).toBeUndefined(); expect(context.secrets).toEqual({}); expect(context.capabilities).toEqual({}); expect(context.systemConfig).toBeDefined(); }); test('should include VM resources from manifest', async () => { // Create module with VM resources db.insert(modules) .values({ id: 'grafana', name: 'Grafana', version: '1.0.0', sourcePath: '/test/grafana', manifestData: { id: 'grafana', name: 'Grafana', version: '1.0.0', requires: { system: { cpu: 2, memory: 2048, disk: 20, storage: 'local-lvm', zone: 'app', }, }, }, }) .run(); const context = await buildResolutionContext('grafana', db); expect(context.selfConfig['requires.system.cpu']).toBe('2'); expect(context.selfConfig['requires.system.memory']).toBe('2048'); expect(context.selfConfig['requires.system.disk']).toBe('20'); expect(context.selfConfig['requires.system.storage']).toBe('local-lvm'); expect(context.selfConfig['requires.system.zone']).toBe('app'); }); test('should auto-derive inventory variables from target_ip', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); db.insert(moduleConfigs) .values({ moduleId: 'test-module', key: 'target_ip', value: '10.0.10.10/24' }) .run(); const context = await buildResolutionContext('test-module', db); // Should strip CIDR from target_ip expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.10.10'); expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('test-module'); }); test('should allow overriding ansible_user', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('custom', 'Custom', '1.0.0', '/path', '{}')`, ); db.insert(moduleConfigs) .values([ { moduleId: 'custom', key: 'target_ip', value: '10.0.20.10' }, { moduleId: 'custom', key: 'inventory.ansible_user', value: 'admin' }, ]) .run(); const context = await buildResolutionContext('custom', db); // Explicit config should override default expect(context.selfConfig['inventory.ansible_user']).toBe('admin'); expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.20.10'); }); test('should handle target_ip without CIDR', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('no-cidr', 'No CIDR', '1.0.0', '/path', '{}')`, ); db.insert(moduleConfigs) .values({ moduleId: 'no-cidr', key: 'target_ip', value: '192.168.1.100' }) .run(); const context = await buildResolutionContext('no-cidr', db); // Should work without CIDR notation expect(context.selfConfig['inventory.ansible_host']).toBe('192.168.1.100'); }); test('should auto-allocate IPAM resources when module declares vmid and target_ip', async () => { // Create module with manifest that declares vmid and target_ip db.insert(modules) .values({ id: 'auto-module', name: 'Auto Module', version: '1.0.0', sourcePath: '/test/auto', manifestData: { id: 'auto-module', name: 'Auto Module', version: '1.0.0', variables: { owns: [ { name: 'vmid', type: 'integer', required: true, source: 'user' }, { name: 'target_ip', type: 'string', required: true, source: 'user' }, ], }, requires: { system: { zone: 'dmz', }, }, }, }) .run(); setupProxmoxInfra(db, 'auto-module', 'dmz'); await buildResolutionContext('auto-module', db); // IPAM allocation persisted const allocations = db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(1); expect(allocations[0].moduleId).toBe('auto-module'); expect(allocations[0].vmid).toBe(2100); expect(allocations[0].containerIp).toBe('10.0.10.10/24'); // The system is recorded in module_systems (target_ip no longer in config). const systems = db .select() .from(moduleSystems) .where(eq(moduleSystems.moduleId, 'auto-module')) .all(); expect(systems).toHaveLength(1); expect(systems[0].name).toBe('main'); expect(systems[0].vmid).toBe(2100); expect(systems[0].ipv4Address).toBe('10.0.10.10'); // CIDR-stripped // target_ip / vmid are NOT written to module_configs anymore. const configs = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'auto-module')) .all(); expect(configs.find((c) => c.key === 'target_ip')).toBeUndefined(); expect(configs.find((c) => c.key === 'vmid')).toBeUndefined(); }); test('should reuse existing allocation if already allocated', async () => { // Create module db.insert(modules) .values({ id: 'existing-alloc', name: 'Existing Allocation', version: '1.0.0', sourcePath: '/test/existing', manifestData: { id: 'existing-alloc', name: 'Existing Allocation', version: '1.0.0', variables: { owns: [ { name: 'vmid', type: 'integer', required: true, source: 'user' }, { name: 'target_ip', type: 'string', required: true, source: 'user' }, ], }, requires: { system: { zone: 'dmz' } }, }, }) .run(); setupProxmoxInfra(db, 'existing-alloc', 'dmz'); // Pre-create allocation db.insert(ipAllocations) .values({ moduleId: 'existing-alloc', vmid: 2150, containerIp: '10.0.10.50/24', zone: 'dmz', }) .run(); await buildResolutionContext('existing-alloc', db); // Should reuse the existing allocation when recording the system. const systems = db .select() .from(moduleSystems) .where(eq(moduleSystems.moduleId, 'existing-alloc')) .all(); expect(systems).toHaveLength(1); expect(systems[0].vmid).toBe(2150); expect(systems[0].ipv4Address).toBe('10.0.10.50'); // Should not create duplicate allocation const allocations = db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(1); }); test('should skip IPAM allocation if vmid and target_ip already configured', async () => { // Create module with existing config db.insert(modules) .values({ id: 'manual-config', name: 'Manual Config', version: '1.0.0', sourcePath: '/test/manual', manifestData: { id: 'manual-config', name: 'Manual Config', version: '1.0.0', variables: { owns: [ { name: 'vmid', type: 'integer', required: true, source: 'user' }, { name: 'target_ip', type: 'string', required: true, source: 'user' }, ], }, }, }) .run(); db.insert(moduleConfigs) .values([ { moduleId: 'manual-config', key: 'vmid', value: '9999' }, { moduleId: 'manual-config', key: 'target_ip', value: '192.168.99.99/24' }, ]) .run(); const context = await buildResolutionContext('manual-config', db); // Should use existing config expect(context.selfConfig.vmid).toBe('9999'); expect(context.selfConfig.target_ip).toBe('192.168.99.99/24'); // Should not create allocation const allocations = await db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(0); }); test('should skip IPAM allocation for VPS modules without target_ip', async () => { // Create VPS module (no target_ip variable) db.insert(modules) .values({ id: 'vps-module', name: 'VPS Module', version: '1.0.0', sourcePath: '/test/vps', manifestData: { id: 'vps-module', name: 'VPS Module', version: '1.0.0', variables: { owns: [{ name: 'vps_ip', type: 'string', required: true, source: 'user' }], }, }, }) .run(); const context = await buildResolutionContext('vps-module', db); // Should not allocate IPAM resources expect(context.selfConfig.vmid).toBeUndefined(); expect(context.selfConfig.target_ip).toBeUndefined(); const allocations = await db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(0); }); test('should allocate sequential VMIDs for multiple modules', async () => { // Create first module db.insert(modules) .values({ id: 'module1', name: 'Module 1', version: '1.0.0', sourcePath: '/test/p1', manifestData: { id: 'module1', name: 'Module 1', version: '1.0.0', variables: { owns: [ { name: 'vmid', type: 'integer', required: true, source: 'user' }, { name: 'target_ip', type: 'string', required: true, source: 'user' }, ], }, requires: { system: { zone: 'dmz' } }, }, }) .run(); setupProxmoxInfra(db, 'module1', 'dmz'); // Create second module db.insert(modules) .values({ id: 'module2', name: 'Module 2', version: '1.0.0', sourcePath: '/test/p2', manifestData: { id: 'module2', name: 'Module 2', version: '1.0.0', variables: { owns: [ { name: 'vmid', type: 'integer', required: true, source: 'user' }, { name: 'target_ip', type: 'string', required: true, source: 'user' }, ], }, requires: { system: { zone: 'dmz' } }, }, }) .run(); setupProxmoxInfra(db, 'module2', 'dmz'); // Allocate for both await buildResolutionContext('module1', db); await buildResolutionContext('module2', db); // Should have recorded systems with sequential VMIDs + IPs. const sys1 = db .select() .from(moduleSystems) .where(eq(moduleSystems.moduleId, 'module1')) .all(); const sys2 = db .select() .from(moduleSystems) .where(eq(moduleSystems.moduleId, 'module2')) .all(); expect(sys1[0].vmid).toBe(2100); expect(sys2[0].vmid).toBe(2101); expect(sys1[0].ipv4Address).toBe('10.0.10.10'); expect(sys2[0].ipv4Address).toBe('10.0.10.11'); const allocations = db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(2); }); test('should auto-assign hostname from well-known capability', async () => { // Create module providing public_web capability db.insert(modules) .values({ id: 'caddy', name: 'Caddy', version: '1.0.0', sourcePath: '/test/caddy', manifestData: { id: 'caddy', name: 'Caddy', version: '1.0.0', provides: { capabilities: [ { name: 'public_web', version: '1.0.0', data: {}, }, ], }, }, }) .run(); const context = await buildResolutionContext('caddy', db); // Should auto-assign canonical hostname expect(context.selfConfig.hostname).toBe('www'); // Verify persisted to database const configs = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'caddy')) .all(); const hostnameConfig = configs.find((c) => c.key === 'hostname'); expect(hostnameConfig?.value).toBe('www'); }); test('should auto-assign zone from well-known capability', async () => { // Create module providing auth capability (requires secure zone) db.insert(modules) .values({ id: 'authentik', name: 'Authentik', version: '1.0.0', sourcePath: '/test/authentik', manifestData: { id: 'authentik', name: 'Authentik', version: '1.0.0', provides: { capabilities: [ { name: 'auth', version: '1.0.0', data: {}, }, ], }, }, }) .run(); const context = await buildResolutionContext('authentik', db); // Should auto-assign required zone expect(context.selfConfig.zone).toBe('secure'); expect(context.selfConfig.hostname).toBe('auth'); // Verify persisted to database const configs = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'authentik')) .all(); const zoneConfig = configs.find((c) => c.key === 'zone'); expect(zoneConfig?.value).toBe('secure'); }); test('should respect explicit hostname override', async () => { // Create module with manual hostname config db.insert(modules) .values({ id: 'custom-web', name: 'Custom Web', version: '1.0.0', sourcePath: '/test/custom', manifestData: { id: 'custom-web', name: 'Custom Web', version: '1.0.0', provides: { capabilities: [ { name: 'public_web', version: '1.0.0', data: {}, }, ], }, }, }) .run(); db.insert(moduleConfigs) .values({ moduleId: 'custom-web', key: 'hostname', value: 'custom' }) .run(); const context = await buildResolutionContext('custom-web', db); // Should use explicit config expect(context.selfConfig.hostname).toBe('custom'); }); // Note: Hostname conflict and zone enforcement tests moved to import-time validation // See test-integration/module/zero-config.test.ts and src/module/import.test.ts // These validations now happen during module import via validateWellKnownCapabilities() test('should skip well-known assignment for non-well-known capabilities', async () => { // Create module with custom capability db.insert(modules) .values({ id: 'custom-module', name: 'Custom Module', version: '1.0.0', sourcePath: '/test/custom', manifestData: { id: 'custom-module', name: 'Custom Module', version: '1.0.0', provides: { capabilities: [{ name: 'custom_capability', version: '1.0.0', data: {} }], }, }, }) .run(); const context = await buildResolutionContext('custom-module', db); // Should not auto-assign anything expect(context.selfConfig.hostname).toBeUndefined(); expect(context.selfConfig.zone).toBeUndefined(); }); test('should prioritize first well-known capability when multiple provided', async () => { // Create module providing multiple well-known capabilities db.insert(modules) .values({ id: 'multi-cap', name: 'Multi Cap', version: '1.0.0', sourcePath: '/test/multi', manifestData: { id: 'multi-cap', name: 'Multi Cap', version: '1.0.0', provides: { capabilities: [ { name: 'public_web', version: '1.0.0', data: {} }, // First { name: 'auth', version: '1.0.0', data: {} }, // Second ], }, }, }) .run(); const context = await buildResolutionContext('multi-cap', db); // Should use first capability (public_web) expect(context.selfConfig.hostname).toBe('www'); expect(context.selfConfig.zone).toBe('dmz'); }); test('should auto-apply VM resource defaults from manifest', async () => { // Create module with VM resource defaults db.insert(modules) .values({ id: 'app-with-resources', name: 'App With Resources', version: '1.0.0', sourcePath: '/test/app', manifestData: { id: 'app-with-resources', name: 'App With Resources', version: '1.0.0', requires: { system: { cpu: 2, memory: 2048, disk: 20, storage: 'local-lvm', }, }, }, }) .run(); const context = await buildResolutionContext('app-with-resources', db); // Should auto-apply all VM resource defaults expect(context.selfConfig.cores).toBe('2'); expect(context.selfConfig.memory).toBe('2048'); expect(context.selfConfig.disk).toBe('20'); expect(context.selfConfig.storage).toBe('local-lvm'); // Verify persisted to database const configs = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'app-with-resources')) .all(); expect(configs.find((c) => c.key === 'cores')?.value).toBe('2'); expect(configs.find((c) => c.key === 'memory')?.value).toBe('2048'); expect(configs.find((c) => c.key === 'disk')?.value).toBe('20'); expect(configs.find((c) => c.key === 'storage')?.value).toBe('local-lvm'); }); test('should respect user overrides for VM resources', async () => { // Create module with VM resource defaults db.insert(modules) .values({ id: 'custom-resources', name: 'Custom Resources', version: '1.0.0', sourcePath: '/test/custom', manifestData: { id: 'custom-resources', name: 'Custom Resources', version: '1.0.0', requires: { system: { cpu: 2, memory: 2048, }, }, }, }) .run(); // User explicitly sets higher memory db.insert(moduleConfigs) .values([ { moduleId: 'custom-resources', key: 'cores', value: '4' }, { moduleId: 'custom-resources', key: 'memory', value: '4096' }, ]) .run(); const context = await buildResolutionContext('custom-resources', db); // Should use user overrides, not manifest defaults expect(context.selfConfig.cores).toBe('4'); expect(context.selfConfig.memory).toBe('4096'); }); test('should handle modules without VM resources', async () => { // Create module with no VM resources section db.insert(modules) .values({ id: 'no-resources', name: 'No Resources', version: '1.0.0', sourcePath: '/test/no-res', manifestData: { id: 'no-resources', name: 'No Resources', version: '1.0.0', }, }) .run(); const context = await buildResolutionContext('no-resources', db); // Should not have any VM resource values expect(context.selfConfig.cores).toBeUndefined(); expect(context.selfConfig.memory).toBeUndefined(); expect(context.selfConfig.disk).toBeUndefined(); expect(context.selfConfig.storage).toBeUndefined(); }); test('should only apply defined VM resource fields', async () => { // Create module with partial VM resources db.insert(modules) .values({ id: 'partial-resources', name: 'Partial Resources', version: '1.0.0', sourcePath: '/test/partial', manifestData: { id: 'partial-resources', name: 'Partial Resources', version: '1.0.0', requires: { system: { cpu: 1, memory: 512, // No disk or storage specified }, }, }, }) .run(); const context = await buildResolutionContext('partial-resources', db); // Should apply specified fields expect(context.selfConfig.cores).toBe('1'); expect(context.selfConfig.memory).toBe('512'); // Should not have unspecified fields expect(context.selfConfig.disk).toBeUndefined(); expect(context.selfConfig.storage).toBeUndefined(); }); test('should auto-derive network config from dmz zone', async () => { // Create module in DMZ zone db.insert(modules) .values({ id: 'dmz-module', name: 'DMZ Module', version: '1.0.0', sourcePath: '/test/dmz', manifestData: { id: 'dmz-module', name: 'DMZ Module', version: '1.0.0', requires: { system: { zone: 'dmz', }, }, }, }) .run(); const context = await buildResolutionContext('dmz-module', db); // Should auto-derive all network config from DMZ zone expect(context.selfConfig.gateway).toBe('10.0.10.1'); expect(context.selfConfig.vlan).toBe('10'); expect(context.selfConfig.subnet).toBe('10.0.10.0/24'); expect(context.selfConfig.bridge).toBe('vmbr0'); // Verify persisted to database const configs = await db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, 'dmz-module')) .all(); expect(configs.find((c) => c.key === 'gateway')?.value).toBe('10.0.10.1'); expect(configs.find((c) => c.key === 'vlan')?.value).toBe('10'); expect(configs.find((c) => c.key === 'subnet')?.value).toBe('10.0.10.0/24'); expect(configs.find((c) => c.key === 'bridge')?.value).toBe('vmbr0'); }); test('should auto-derive network config from app zone', async () => { // Create module in app zone db.insert(modules) .values({ id: 'app-module', name: 'App Module', version: '1.0.0', sourcePath: '/test/app', manifestData: { id: 'app-module', name: 'App Module', version: '1.0.0', requires: { system: { zone: 'app', }, }, }, }) .run(); const context = await buildResolutionContext('app-module', db); // Should auto-derive network config from app zone expect(context.selfConfig.gateway).toBe('10.0.20.1'); expect(context.selfConfig.vlan).toBe('20'); expect(context.selfConfig.subnet).toBe('10.0.20.0/24'); expect(context.selfConfig.bridge).toBe('vmbr0'); }); test('should auto-derive network config from secure zone', async () => { // Create module in secure zone db.insert(modules) .values({ id: 'secure-module', name: 'Secure Module', version: '1.0.0', sourcePath: '/test/secure', manifestData: { id: 'secure-module', name: 'Secure Module', version: '1.0.0', requires: { system: { zone: 'secure', }, }, }, }) .run(); const context = await buildResolutionContext('secure-module', db); // Should auto-derive network config from secure zone expect(context.selfConfig.gateway).toBe('10.0.30.1'); expect(context.selfConfig.vlan).toBe('30'); expect(context.selfConfig.subnet).toBe('10.0.30.0/24'); expect(context.selfConfig.bridge).toBe('vmbr0'); }); test('should use zone from config if specified', async () => { // Create module with zone in config (not manifest) db.insert(modules) .values({ id: 'config-zone', name: 'Config Zone', version: '1.0.0', sourcePath: '/test/config-zone', manifestData: { id: 'config-zone', name: 'Config Zone', version: '1.0.0', }, }) .run(); db.insert(moduleConfigs).values({ moduleId: 'config-zone', key: 'zone', value: 'app' }).run(); const context = await buildResolutionContext('config-zone', db); // Should use zone from config and derive network settings expect(context.selfConfig.gateway).toBe('10.0.20.1'); expect(context.selfConfig.vlan).toBe('20'); }); test('should respect user overrides for network config', async () => { // Create module with zone db.insert(modules) .values({ id: 'custom-network', name: 'Custom Network', version: '1.0.0', sourcePath: '/test/custom-net', manifestData: { id: 'custom-network', name: 'Custom Network', version: '1.0.0', requires: { system: { zone: 'dmz', }, }, }, }) .run(); // User explicitly sets different gateway db.insert(moduleConfigs) .values([ { moduleId: 'custom-network', key: 'gateway', value: '10.0.10.254' }, { moduleId: 'custom-network', key: 'vlan', value: '100' }, ]) .run(); const context = await buildResolutionContext('custom-network', db); // Should use user overrides, not zone defaults expect(context.selfConfig.gateway).toBe('10.0.10.254'); expect(context.selfConfig.vlan).toBe('100'); // Should still auto-derive unset fields expect(context.selfConfig.subnet).toBe('10.0.10.0/24'); expect(context.selfConfig.bridge).toBe('vmbr0'); }); test('should skip zone-based networking for external zone', async () => { // Create VPS module (external zone) db.insert(modules) .values({ id: 'vps-external', name: 'VPS External', version: '1.0.0', sourcePath: '/test/vps', manifestData: { id: 'vps-external', name: 'VPS External', version: '1.0.0', requires: { system: { zone: 'external', }, }, }, }) .run(); const context = await buildResolutionContext('vps-external', db); // Should not auto-derive network config for external zone expect(context.selfConfig.gateway).toBeUndefined(); expect(context.selfConfig.vlan).toBeUndefined(); expect(context.selfConfig.subnet).toBeUndefined(); expect(context.selfConfig.bridge).toBeUndefined(); }); test('should handle missing system network config gracefully', async () => { // Create module in zone without system config db.insert(modules) .values({ id: 'no-net-config', name: 'No Net Config', version: '1.0.0', sourcePath: '/test/no-net', manifestData: { id: 'no-net-config', name: 'No Net Config', version: '1.0.0', requires: { system: { zone: 'dmz', }, }, }, }) .run(); // Remove all network config from system await db.delete(systemConfig).run(); const context = await buildResolutionContext('no-net-config', db); // Should not fail, just not have network config expect(context.selfConfig.gateway).toBeUndefined(); expect(context.selfConfig.vlan).toBeUndefined(); }); test('should handle module without zone', async () => { // Create module without zone specified db.insert(modules) .values({ id: 'no-zone', name: 'No Zone', version: '1.0.0', sourcePath: '/test/no-zone', manifestData: { id: 'no-zone', name: 'No Zone', version: '1.0.0', }, }) .run(); const context = await buildResolutionContext('no-zone', db); // Should not auto-derive network config (no zone) expect(context.selfConfig.gateway).toBeUndefined(); expect(context.selfConfig.vlan).toBeUndefined(); expect(context.selfConfig.subnet).toBeUndefined(); }); }); describe('buildContextFromData', () => { test('should build context from explicit data', () => { const context = buildContextFromData('test-module', { selfConfig: { ip: '192.168.0.50' }, secrets: { key: 'secret' }, capabilities: { dns: { server: 'ns1' } }, }); expect(context.moduleId).toBe('test-module'); expect(context.selfConfig.ip).toBe('192.168.0.50'); // Auto-derived variables expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('test-module'); expect(context.secrets).toEqual({ key: 'secret' }); expect(context.capabilities).toEqual({ dns: { server: 'ns1' } }); }); test('should use provided system config', () => { const context = buildContextFromData('test-module', { systemConfig: { 'dns.primary': '192.168.0.1', 'network.domain': 'homelab.local' }, }); expect(context.systemConfig['dns.primary']).toBe('192.168.0.1'); expect(context.systemConfig['network.domain']).toBe('homelab.local'); }); test('should have empty system config when none provided', () => { const context = buildContextFromData('test-module'); expect(context.systemConfig).toEqual({}); }); test('should return empty maps when no data provided', () => { const context = buildContextFromData('test-module'); // Should still have auto-derived inventory variables expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('test-module'); expect(context.secrets).toEqual({}); expect(context.capabilities).toEqual({}); }); test('should auto-derive inventory variables in buildContextFromData', () => { const context = buildContextFromData('my-module', { selfConfig: { target_ip: '10.0.30.15/24', hostname: 'my-host' }, }); expect(context.selfConfig['inventory.ansible_host']).toBe('10.0.30.15'); expect(context.selfConfig['inventory.ansible_user']).toBe('root'); expect(context.selfConfig['inventory.groups']).toBe('my-module'); }); }); });