/** * Tests for infrastructure selector service */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, createDbClient } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import type { Module } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { addContainerService } from './container-service'; import { InfrastructureError, selectInfrastructure } from './infrastructure-selector'; import { addMachine } from './machine-pool'; describe('infrastructure-selector', () => { let testDbPath: string; let testDir: string; beforeEach(async () => { // Create temp directory for test database testDir = mkdtempSync(join(tmpdir(), 'celilo-test-')); testDbPath = join(testDir, 'test.db'); // Set environment variable for database path process.env.CELILO_DB_PATH = testDbPath; // Initialize database and run migrations await runMigrations(testDbPath); // Create a dummy master key for encryption const masterKeyPath = join(testDir, 'master.key'); process.env.CELILO_MASTER_KEY_PATH = masterKeyPath; const fs = await import('node:fs/promises'); await fs.writeFile(masterKeyPath, 'a'.repeat(64), 'utf8'); }); afterEach(() => { // Close database connection closeDb(); // Clean up test directory if (testDir) { rmSync(testDir, { recursive: true, force: true }); } // Clear environment variables resetTestDbPath(); delete process.env.CELILO_MASTER_KEY_PATH; }); describe('selectInfrastructure', () => { it('prefers container service over machine pool', async () => { const db = createDbClient({ path: testDbPath }); // Create test module await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add both container service and machine for same zone const service = await addContainerService({ name: 'Test Proxmox', providerName: 'proxmox', zones: ['dmz'], providerConfig: {}, apiCredentials: { api_url: 'https://test' }, }); // Mark service as verified (required for it to be used) const { updateVerificationStatus } = await import('./container-service'); await updateVerificationStatus(service.id, { success: true, message: 'Test verification', }); await addMachine({ hostname: 'test-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 }, role: 'host', interfaces: [], }); const result = await selectInfrastructure(module); expect(result.type).toBe('container_service'); expect(result.serviceId).toBe(service.id); }); it('uses machine pool when no container services available', async () => { const db = createDbClient({ path: testDbPath }); // Create test module await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'internal', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add only machine (no container services) const machine = await addMachine({ hostname: 'test-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 }, role: 'host', interfaces: [], }); const result = await selectInfrastructure(module); expect(result.type).toBe('machine'); expect(result.machineId).toBe(machine.id); }); it('throws error when no infrastructure available', async () => { const db = createDbClient({ path: testDbPath }); // Create test module await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'external', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // No infrastructure added await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); await expect(selectInfrastructure(module)).rejects.toThrow(/No infrastructure available/); }); it('throws error when module manifest missing resources', async () => { const db = createDbClient({ path: testDbPath }); // Create test module without resources await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: {}, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system/); }); it('throws error when module manifest missing zone', async () => { const db = createDbClient({ path: testDbPath }); // Create test module without zone await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); await expect(selectInfrastructure(module)).rejects.toThrow(/missing requires.system.zone/); }); it('supports requires.system format', async () => { const db = createDbClient({ path: testDbPath }); // Create test module with requires.system format await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add container service const service = await addContainerService({ name: 'Test Proxmox', providerName: 'proxmox', zones: ['dmz'], providerConfig: {}, apiCredentials: { api_url: 'https://test' }, }); // Mark service as verified (required for it to be used) const { updateVerificationStatus } = await import('./container-service'); await updateVerificationStatus(service.id, { success: true, message: 'Test verification', }); const result = await selectInfrastructure(module); expect(result.type).toBe('container_service'); }); }); describe('hardware validation', () => { it('rejects machine with insufficient CPU', async () => { const db = createDbClient({ path: testDbPath }); // Create test module requiring 2 CPU cores await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add machine with only 1 CPU core await addMachine({ hostname: 'underpowered-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 1, memory_mb: 4096, disk_gb: 128 }, role: 'host', interfaces: [], }); // Should throw InfrastructureError with resource details await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); await expect(selectInfrastructure(module)).rejects.toThrow(/insufficient resources/); }); it('rejects machine with insufficient memory', async () => { const db = createDbClient({ path: testDbPath }); // Create test module requiring 2GB memory await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add machine with only 1GB memory await addMachine({ hostname: 'low-memory-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 4, memory_mb: 1024, disk_gb: 128 }, role: 'host', interfaces: [], }); await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); }); it('rejects machine with insufficient disk', async () => { const db = createDbClient({ path: testDbPath }); // Create test module requiring 20GB disk await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add machine with only 10GB disk await addMachine({ hostname: 'small-disk-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 10 }, role: 'host', interfaces: [], }); await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError); }); it('accepts machine with sufficient resources', async () => { const db = createDbClient({ path: testDbPath }); // Create test module with modest requirements await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 2, memory: 2048, disk: 20, zone: 'dmz', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add machine with more than sufficient resources const machine = await addMachine({ hostname: 'powerful-machine', zone: 'dmz', ipAddress: '10.0.10.100', sshUser: 'root', sshKey: 'test-key', hardware: { cpu_cores: 8, memory_mb: 16384, disk_gb: 256 }, role: 'host', interfaces: [], }); const result = await selectInfrastructure(module); expect(result.type).toBe('machine'); expect(result.machineId).toBe(machine.id); }); it('accepts machine with exact resource match', async () => { const db = createDbClient({ path: testDbPath }); // Create test module await db.insert(modules).values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: { requires: { system: { cpu: 4, memory: 4096, disk: 128, zone: 'internal', }, }, }, sourcePath: '/test', state: 'CONFIGURED', }); const module = (await db.select().from(modules).limit(1))[0] as Module; // Add machine with exact resources const machine = await addMachine({ hostname: 'exact-match-machine', zone: 'internal', ipAddress: '192.168.1.100', sshUser: 'ubuntu', sshKey: 'test-key', hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 }, role: 'host', interfaces: [], }); const result = await selectInfrastructure(module); expect(result.type).toBe('machine'); expect(result.machineId).toBe(machine.id); }); }); });