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 { containerServices, machines, moduleConfigs, moduleInfrastructure, modules, } from '@/db/schema'; import type { ModuleManifest } from '@/manifest/schema'; import { and, eq } from 'drizzle-orm'; import { resetTestDbPath } from '../test-utils/db-path'; import { resolveInfrastructureVariables } from './infrastructure-variable-resolver'; import { upsertModuleConfig } from './module-config'; const TEST_DB_PATH = './test-infra-resolver.db'; let db: DbClient; // Helper function to create a module record (required for foreign key) async function createTestModule(moduleId: string, manifest: ModuleManifest, database: DbClient) { await database.insert(modules).values({ id: moduleId, name: manifest.name, version: manifest.version, description: manifest.description || null, state: 'CONFIGURED', manifestData: manifest as Record, sourcePath: `/tmp/modules/${moduleId}`, }); } beforeEach(() => { process.env.CELILO_DB_PATH = TEST_DB_PATH; db = createDbClient({ path: TEST_DB_PATH }); }); afterEach(async () => { db.$client.close(); resetTestDbPath(); // Clean up database files if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } 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('resolveInfrastructureVariables - Machine Infrastructure', () => { test('resolves variables from machine', async () => { const moduleId = 'dns-external'; // Create manifest with infrastructure variables const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'DNS External', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, { name: 'hostname', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; // Create module record (required for foreign key) await createTestModule(moduleId, manifest, db); // Create machine const machineId = 'machine-123'; await db.insert(machines).values({ id: machineId, hostname: 'dns-ext', ipAddress: '203.0.113.42', sshUser: 'root', sshKeyEncrypted: 'encrypted-key', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, zone: 'external', }); // Create module infrastructure selection await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); // Resolve variables const result = await resolveInfrastructureVariables(moduleId, manifest, null, db); // Verify resolved values expect(result.resolved['ip.primary']).toBe('203.0.113.42'); expect(result.resolved.hostname).toBe('dns-ext'); expect(result.skipped).toEqual([]); // Verify values stored in moduleConfigs const ipConfig = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'ip.primary'))) .get(); expect(ipConfig?.value).toBe('203.0.113.42'); const hostnameConfig = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'hostname'))) .get(); expect(hostnameConfig?.value).toBe('dns-ext'); }); test('throws when machine not found', async () => { const moduleId = 'test-module'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); // Create machine temporarily to satisfy the foreign key when we // insert moduleInfrastructure pointing at it. const machineId = 'temp-machine'; await db.insert(machines).values({ id: machineId, hostname: 'temp', ipAddress: '192.168.1.1', sshUser: 'root', sshKeyEncrypted: 'encrypted', hardware: { cpu_cores: 1, memory_mb: 1024, disk_gb: 10 }, zone: 'internal', }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); // Disable FK constraints just long enough to orphan the // moduleInfrastructure row by deleting its referenced machine. The // resolver's "Machine not found" branch is a defensive check for // this exact corrupted-DB scenario; we test it by manufacturing // the corruption FK constraints normally prevent. db.$client.run('PRAGMA foreign_keys = OFF'); try { await db.delete(machines).where(eq(machines.id, machineId)); } finally { db.$client.run('PRAGMA foreign_keys = ON'); } await expect(resolveInfrastructureVariables(moduleId, manifest, null, db)).rejects.toThrow( 'Machine not found', ); }); }); describe('resolveInfrastructureVariables - Proxmox Container Service', () => { test('resolves variables from IPAM allocation', async () => { const moduleId = 'homebridge'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Homebridge', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, { name: 'hostname', source: 'infrastructure', type: 'string', required: true, }, { name: 'id', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); // Create a valid container service first const serviceId = 'test-service-123'; await db.insert(containerServices).values({ id: serviceId, serviceId: 'test-service', name: 'Test Service', providerName: 'proxmox', zones: ['app'], apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }), providerConfig: { default_target_node: 'pve', lxc_template: 'local:vztmpl/ubuntu-22.04.tar.zst', storage: 'local-lvm', }, verified: true, verifiedAt: new Date(), verificationError: null, createdAt: new Date(), updatedAt: new Date(), }); // Create module infrastructure selection (container service, no Terraform outputs) await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'container_service', machineId: null, serviceId, // Must have valid serviceId for container_service type containerMetadata: null, }); // Create IPAM-allocated config (mimics what IPAM does) upsertModuleConfig(db, moduleId, 'vmid', '100'); upsertModuleConfig(db, moduleId, 'target_ip', '10.0.10.5'); upsertModuleConfig(db, moduleId, 'hostname', 'homebridge'); // Resolve variables (no Terraform outputs = Proxmox) const result = await resolveInfrastructureVariables(moduleId, manifest, null, db); // Verify resolved values expect(result.resolved['ip.primary']).toBe('10.0.10.5'); expect(result.resolved.hostname).toBe('homebridge'); expect(result.resolved.id).toBe('100'); // hostname is skipped because it was already in moduleConfigs (user override / previous run) expect(result.skipped).toEqual(['hostname']); }); test('throws when IPAM allocation not found', async () => { const moduleId = 'test-module'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); // Create a valid container service first const serviceId = 'test-service-123'; await db.insert(containerServices).values({ id: serviceId, serviceId: 'test-service', name: 'Test Service', providerName: 'proxmox', zones: ['app'], apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }), providerConfig: { default_target_node: 'pve', lxc_template: 'local:vztmpl/ubuntu-22.04.tar.zst', storage: 'local-lvm', }, verified: true, verifiedAt: new Date(), verificationError: null, createdAt: new Date(), updatedAt: new Date(), }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'container_service', machineId: null, serviceId, // Must have valid serviceId for container_service type containerMetadata: null, }); // No IPAM allocation created - this should throw await expect(resolveInfrastructureVariables(moduleId, manifest, null, db)).rejects.toThrow( 'IPAM allocation not found', ); }); }); describe('resolveInfrastructureVariables - Digital Ocean Container Service', () => { test('resolves variables from Terraform outputs', async () => { const moduleId = 'dns-external'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'DNS External', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, { name: 'id', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); // Create a valid container service first (Digital Ocean) const serviceId = 'test-do-service-123'; await db.insert(containerServices).values({ id: serviceId, serviceId: 'test-do-service', name: 'Test Digital Ocean', providerName: 'digitalocean', zones: ['external'], apiCredentialsEncrypted: JSON.stringify({ encryptedValue: '', iv: '', authTag: '' }), providerConfig: {}, verified: true, verifiedAt: new Date(), verificationError: null, createdAt: new Date(), updatedAt: new Date(), }); // Create module infrastructure selection (container service) await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'container_service', machineId: null, serviceId, // Must have valid serviceId for container_service type containerMetadata: null, }); // Create hostname config (user-configured) upsertModuleConfig(db, moduleId, 'hostname', 'dns-ext'); // Terraform outputs (wrapped format) const terraformOutputs = { droplet_ip: { value: '203.0.113.42', type: 'string', sensitive: false, }, droplet_id: { value: '123456789', type: 'string', sensitive: false, }, }; // Resolve variables const result = await resolveInfrastructureVariables(moduleId, manifest, terraformOutputs, db); // Verify resolved values expect(result.resolved['ip.primary']).toBe('203.0.113.42'); expect(result.resolved.id).toBe('123456789'); expect(result.skipped).toEqual([]); }); }); describe('resolveInfrastructureVariables - User Override', () => { test('user-configured value wins over infrastructure', async () => { const moduleId = 'dns-external'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'DNS External', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); // Create machine const machineId = 'machine-123'; await db.insert(machines).values({ id: machineId, hostname: 'dns-ext', ipAddress: '203.0.113.42', sshUser: 'root', sshKeyEncrypted: 'encrypted', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, zone: 'external', }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); // User manually set ip.primary upsertModuleConfig(db, moduleId, 'ip.primary', '198.51.100.50'); const result = await resolveInfrastructureVariables(moduleId, manifest, null, db); // User value should win (not machine IP) expect(result.resolved['ip.primary']).toBe('198.51.100.50'); expect(result.skipped).toContain('ip.primary'); // Tracked as skipped (user override) }); }); describe('resolveInfrastructureVariables - Required vs Optional', () => { test('throws when required variable not available', async () => { const moduleId = 'test-module'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'nonexistent.property', // Property doesn't exist source: 'infrastructure', type: 'string', required: true, // Required! }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); const machineId = 'machine-123'; await db.insert(machines).values({ id: machineId, hostname: 'test', ipAddress: '192.168.1.100', sshUser: 'root', sshKeyEncrypted: 'encrypted', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, zone: 'internal', }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); await expect(resolveInfrastructureVariables(moduleId, manifest, null, db)).rejects.toThrow( "Required infrastructure property 'nonexistent.property' not available", ); }); test('skips optional variable when not available', async () => { const moduleId = 'test-module'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, { name: 'optional.property', // Property doesn't exist source: 'infrastructure', type: 'string', required: false, // Optional }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); const machineId = 'machine-123'; await db.insert(machines).values({ id: machineId, hostname: 'test', ipAddress: '192.168.1.100', sshUser: 'root', sshKeyEncrypted: 'encrypted', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, zone: 'internal', }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); const result = await resolveInfrastructureVariables(moduleId, manifest, null, db); // ip.primary should be resolved expect(result.resolved['ip.primary']).toBe('192.168.1.100'); // optional.property should be skipped (not throw) expect(result.skipped).toContain('optional.property'); expect(result.resolved['optional.property']).toBeUndefined(); }); }); describe('resolveInfrastructureVariables - Edge Cases', () => { test('returns empty when no infrastructure variables declared', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'user_var', source: 'user', // Not infrastructure type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; const result = await resolveInfrastructureVariables('test-module', manifest, null, db); expect(result.resolved).toEqual({}); expect(result.skipped).toEqual([]); }); test('throws when no infrastructure selected', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await expect(resolveInfrastructureVariables('test-module', manifest, null, db)).rejects.toThrow( 'No infrastructure selected', ); }); test('idempotency - second resolution reuses existing values', async () => { const moduleId = 'test-module'; const manifest: ModuleManifest = { celilo_contract: '1.0', id: moduleId, name: 'Test', version: '1.0.0', variables: { owns: [ { name: 'ip.primary', source: 'infrastructure', type: 'string', required: true, }, ], imports: [], }, requires: { capabilities: [] }, provides: { capabilities: [] }, }; await createTestModule(moduleId, manifest, db); const machineId = 'machine-123'; await db.insert(machines).values({ id: machineId, hostname: 'test', ipAddress: '192.168.1.100', sshUser: 'root', sshKeyEncrypted: 'encrypted', hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 }, zone: 'internal', }); await db.insert(moduleInfrastructure).values({ id: 'infra-123', moduleId, infrastructureType: 'machine', machineId, serviceId: null, containerMetadata: null, }); // First resolution const result1 = await resolveInfrastructureVariables(moduleId, manifest, null, db); expect(result1.resolved['ip.primary']).toBe('192.168.1.100'); // Second resolution - should reuse existing value const result2 = await resolveInfrastructureVariables(moduleId, manifest, null, db); expect(result2.resolved['ip.primary']).toBe('192.168.1.100'); // Verify only one config entry exists (upsert, not duplicate) const configs = await db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, 'ip.primary'))) .all(); expect(configs).toHaveLength(1); }); });