/** * SC5 unit tests — Proxmox reconciliation planning. * * Covers: * - planProxmoxReconcile: empty-block, no-systems-in-zone, * non-Proxmox provider skip, template resolution from * module config, $capability: resolution. * * executeProxmoxReconcile is currently observation-only (logs * warnings); its log surface is tested by a single smoke test * that just verifies the function doesn't throw on an empty plan * and on a populated one. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { capabilities, containerServices, ipAllocations, moduleInfrastructure, modules, } from '../db/schema'; import type { BaseModuleAspect } from '../manifest/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { upsertModuleConfig } from './module-config'; import { executeProxmoxReconcile, planProxmoxReconcile } from './proxmox-reconcile'; const PROVIDER_MODULE_ID = 'knot-unbound-internal'; function seedProviderModule(opts: { configs?: Record } = {}) { const db = getDb(); db.insert(modules) .values({ id: PROVIDER_MODULE_ID, name: PROVIDER_MODULE_ID, version: '1.0.0', manifestData: { id: PROVIDER_MODULE_ID, name: PROVIDER_MODULE_ID, version: '1.0.0', celilo_contract: '1.0', }, sourcePath: `/tmp/${PROVIDER_MODULE_ID}`, }) .run(); for (const [key, value] of Object.entries(opts.configs ?? {})) { upsertModuleConfig(db, PROVIDER_MODULE_ID, key, value); } } function seedProxmoxService(serviceId = 'proxmox-home-lab') { const id = randomUUID(); getDb() .insert(containerServices) .values({ id, serviceId, name: serviceId, providerName: 'proxmox', zones: ['dmz', 'app', 'secure', 'internal'], apiCredentialsEncrypted: '{}', providerConfig: {}, verified: true, }) .run(); return id; } function seedDigitalOceanService(serviceId = 'do-vps') { const id = randomUUID(); getDb() .insert(containerServices) .values({ id, serviceId, name: serviceId, providerName: 'digitalocean', zones: ['external'], apiCredentialsEncrypted: '{}', providerConfig: {}, verified: true, }) .run(); return id; } function seedContainerLxc(opts: { moduleId: string; serviceUuid: string; vmid: number; containerIp: string; zone: 'dmz' | 'app' | 'secure' | 'internal'; }) { const db = getDb(); db.insert(modules) .values({ id: opts.moduleId, name: opts.moduleId, version: '1.0.0', manifestData: { id: opts.moduleId, name: opts.moduleId, version: '1.0.0', celilo_contract: '1.0', }, sourcePath: `/tmp/${opts.moduleId}`, }) .run(); db.insert(moduleInfrastructure) .values({ id: randomUUID(), moduleId: opts.moduleId, infrastructureType: 'container_service', machineId: null, serviceId: opts.serviceUuid, containerMetadata: { vmid: opts.vmid }, }) .run(); db.insert(ipAllocations) .values({ moduleId: opts.moduleId, vmid: opts.vmid, containerIp: opts.containerIp, zone: opts.zone, }) .run(); } const aspectWithReconcile: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app', 'secure'], triggers: ['on_install'], proxmox_reconcile: { tfvars: { nameserver: '$self:target_ip', }, }, }; describe('proxmox-reconcile', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-proxmox-reconcile-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); describe('planProxmoxReconcile', () => { it('returns an empty plan when the aspect has no proxmox_reconcile block', async () => { seedProviderModule(); const aspect: BaseModuleAspect = { ansible_role: 'noop', applicable_zones: ['app'], triggers: ['on_install'], }; const plan = await planProxmoxReconcile({ aspect, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }); expect(plan.actions).toEqual([]); expect(plan.skipped).toEqual([]); }); it('returns an empty plan when no container_service systems match the zones', async () => { seedProviderModule({ configs: { target_ip: '192.168.0.10' } }); const plan = await planProxmoxReconcile({ aspect: aspectWithReconcile, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }); expect(plan.actions).toEqual([]); }); it('emits one action per Proxmox LXC in scope, with tfvars resolved', async () => { seedProviderModule({ configs: { target_ip: '192.168.0.10' } }); const proxmoxId = seedProxmoxService(); seedContainerLxc({ moduleId: 'forgejo', serviceUuid: proxmoxId, vmid: 142, containerIp: '10.0.20.42/24', zone: 'app', }); seedContainerLxc({ moduleId: 'authentik', serviceUuid: proxmoxId, vmid: 130, containerIp: '10.0.20.30/24', zone: 'app', }); const plan = await planProxmoxReconcile({ aspect: aspectWithReconcile, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }); expect(plan.actions).toHaveLength(2); const byModule = new Map(plan.actions.map((a) => [a.moduleId, a])); expect(byModule.get('forgejo')?.tfvarUpdates).toEqual({ nameserver: '192.168.0.10' }); expect(byModule.get('authentik')?.tfvarUpdates).toEqual({ nameserver: '192.168.0.10' }); expect(plan.skipped).toEqual([]); }); it('skips non-Proxmox provider systems with a clear reason', async () => { seedProviderModule({ configs: { target_ip: '192.168.0.10' } }); const doId = seedDigitalOceanService(); seedContainerLxc({ moduleId: 'external-app', serviceUuid: doId, vmid: 0, // n/a for DO; the field is just required containerIp: '10.0.20.50/24', zone: 'app', }); const plan = await planProxmoxReconcile({ aspect: aspectWithReconcile, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }); expect(plan.actions).toEqual([]); expect(plan.skipped).toHaveLength(1); expect(plan.skipped[0].system.moduleId).toBe('external-app'); expect(plan.skipped[0].reason).toContain('non-proxmox'); }); it('resolves $capability: templates against the providing module', async () => { // Provider registers a capability whose data is consumed by the // tfvar template. Real-world example: a future host-firewall // aspect could read $capability:public_web.internal_ip. seedProviderModule(); getDb() .insert(capabilities) .values({ moduleId: PROVIDER_MODULE_ID, capabilityName: 'dns_internal', version: '1.0.0', data: { server: { ip: '10.99.0.53' } }, }) .run(); const proxmoxId = seedProxmoxService(); seedContainerLxc({ moduleId: 'forgejo', serviceUuid: proxmoxId, vmid: 142, containerIp: '10.0.20.42/24', zone: 'app', }); const aspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app'], triggers: ['on_install'], proxmox_reconcile: { tfvars: { nameserver: '$capability:dns_internal.server.ip' }, }, }; const plan = await planProxmoxReconcile({ aspect, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }); expect(plan.actions[0].tfvarUpdates).toEqual({ nameserver: '10.99.0.53' }); }); it('throws with a clear message when a template references a missing field', async () => { seedProviderModule(); // no target_ip configured const proxmoxId = seedProxmoxService(); seedContainerLxc({ moduleId: 'forgejo', serviceUuid: proxmoxId, vmid: 142, containerIp: '10.0.20.42/24', zone: 'app', }); await expect( planProxmoxReconcile({ aspect: aspectWithReconcile, providerModuleId: PROVIDER_MODULE_ID, db: getDb(), }), ).rejects.toThrow(/Cannot resolve \$self:target_ip/); }); }); describe('executeProxmoxReconcile', () => { it('is a safe no-op on an empty plan', () => { expect(() => executeProxmoxReconcile({ actions: [], skipped: [] })).not.toThrow(); }); it('does not throw on a populated plan (currently observation-only)', () => { const plan = { actions: [ { moduleId: 'forgejo', serviceId: 'proxmox-home-lab', tfvarUpdates: { nameserver: '192.168.0.10' }, containerSystem: { infrastructureId: 'infra-1', moduleId: 'forgejo', serviceId: 'proxmox-home-lab', providerName: 'proxmox' as const, zone: 'app' as const, containerIp: '10.0.20.42/24', containerMetadata: { vmid: 142 }, apiOnly: false, }, }, ], skipped: [], }; expect(() => executeProxmoxReconcile(plan)).not.toThrow(); }); }); });