import { describe, expect, test } from 'bun:test'; import { type ProxmoxClusterResource, buildCloudImageVolid, filterVmTemplates, findNodeForVmid, pollTaskUntilDone, selectFreeVmid, selectImportStorage, summarizeNodeCapacities, } from './proxmox'; describe('findNodeForVmid (ISS-0090 — Proxmox is source of truth for current location)', () => { // A trimmed /cluster/resources payload: guest rows carry a vmid; node/storage // rows do not (only a node name). const resources = [ { node: 'node2' }, // node row — no vmid { node: 'node3' }, // storage row — no vmid { vmid: 200, node: 'node2' }, // caddy { vmid: 201, node: 'node3' }, // authentik ]; test('returns the node a vmid currently lives on', () => { expect(findNodeForVmid(resources, 200)).toBe('node2'); expect(findNodeForVmid(resources, 201)).toBe('node3'); }); test('returns null when the vmid is absent — container not created yet (first deploy)', () => { expect(findNodeForVmid(resources, 999)).toBeNull(); }); test('never matches a node/storage row that has no vmid', () => { expect(findNodeForVmid([{ node: 'node2' }, { node: 'node3' }], 200)).toBeNull(); }); test('returns null for an empty inventory', () => { expect(findNodeForVmid([], 200)).toBeNull(); }); }); describe('summarizeNodeCapacities (ISS-0060 — per-node capacity reconciled from Proxmox)', () => { const GB = 1024 * 1024 * 1024; const resources: ProxmoxClusterResource[] = [ { type: 'node', node: 'node3', status: 'online', maxmem: 16 * GB, mem: 4 * GB, maxcpu: 8, cpu: 0.25, maxdisk: 200 * GB, disk: 50 * GB, uptime: 3600, }, { type: 'node', node: 'node2', status: 'offline', maxmem: 8 * GB, mem: 7 * GB, maxcpu: 4, cpu: 0.9, maxdisk: 100 * GB, disk: 90 * GB, uptime: 0, }, // Guest + storage rows must be ignored. { type: 'lxc', vmid: 200, node: 'node2', status: 'running' }, { type: 'storage', node: 'node3', status: 'available' }, ]; test('derives free RAM/CPU/disk and online state from node rows only', () => { const caps = summarizeNodeCapacities(resources); expect(caps).toHaveLength(2); // guest + storage rows dropped // Sorted by node name → node2 first. expect(caps[0]).toEqual({ node: 'node2', online: false, memTotalMb: 8192, memFreeMb: 1024, cpuCores: 4, cpuUsedPct: 90, diskTotalGb: 100, diskFreeGb: 10, uptimeSec: 0, }); expect(caps[1]).toMatchObject({ node: 'node3', online: true, memFreeMb: 12288, cpuUsedPct: 25, diskFreeGb: 150, }); }); test('tolerates missing fields (treats absent capacity as 0, offline unless status==online)', () => { const caps = summarizeNodeCapacities([{ type: 'node', node: 'bare' }]); expect(caps[0]).toEqual({ node: 'bare', online: false, memTotalMb: 0, memFreeMb: 0, cpuCores: 0, cpuUsedPct: 0, diskTotalGb: 0, diskFreeGb: 0, uptimeSec: 0, }); }); test('empty inventory → no nodes', () => { expect(summarizeNodeCapacities([])).toEqual([]); }); }); describe('filterVmTemplates — extract VM templates from a /nodes/{node}/qemu listing', () => { test('keeps only guests flagged template===1 and projects to {vmid, name}', () => { const guests = [ { vmid: 100, name: 'running-vm' }, // no template flag → a live VM { vmid: 9000, name: 'ubuntu-2404-cloudinit', template: 1 }, { vmid: 201, name: 'another-vm', template: 0 }, // explicitly not a template { vmid: 9001, name: 'ubuntu-2204-cloudinit', template: 1 }, ]; expect(filterVmTemplates(guests)).toEqual([ { vmid: 9000, name: 'ubuntu-2404-cloudinit' }, { vmid: 9001, name: 'ubuntu-2204-cloudinit' }, ]); }); test('returns an empty array when no guests are templates', () => { expect(filterVmTemplates([{ vmid: 100, name: 'vm', template: 0 }])).toEqual([]); }); test('returns an empty array for an empty listing', () => { expect(filterVmTemplates([])).toEqual([]); }); }); describe('selectImportStorage — pick a storage that accepts import content', () => { test('returns the first active, enabled storage whose content includes import', () => { const storages = [ { storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }, { storage: 'local', content: 'import,vztmpl,backup', active: 1, enabled: 1 }, ]; expect(selectImportStorage(storages)).toBe('local'); }); test('does NOT pick an iso-only storage (import-from rejects iso content)', () => { // Regression guard: the cloud image must land in the import namespace, not // iso/, or the VM-create `import-from=` fails with `has wrong type 'iso'`. const storages = [ { storage: 'iso-only', content: 'iso,vztmpl,backup', active: 1, enabled: 1 }, { storage: 'data', content: 'import,images', active: 1, enabled: 1 }, ]; expect(selectImportStorage(storages)).toBe('data'); }); test('skips an import-capable storage that is inactive', () => { const storages = [ { storage: 'cold', content: 'import', active: 0, enabled: 1 }, { storage: 'local', content: 'import', active: 1, enabled: 1 }, ]; expect(selectImportStorage(storages)).toBe('local'); }); test('skips an import-capable storage that is disabled', () => { const storages = [ { storage: 'off', content: 'import', active: 1, enabled: 0 }, { storage: 'local', content: 'import', active: 1, enabled: 1 }, ]; expect(selectImportStorage(storages)).toBe('local'); }); test('returns null when no storage accepts import content', () => { const storages = [{ storage: 'local-lvm', content: 'images,rootdir', active: 1, enabled: 1 }]; expect(selectImportStorage(storages)).toBeNull(); }); test('returns null for an empty storage list', () => { expect(selectImportStorage([])).toBeNull(); }); }); describe('selectFreeVmid — first free VMID >= minVmid (template hygiene)', () => { test('returns the default minimum (9000) when nothing is in use', () => { expect(selectFreeVmid([])).toBe(9000); }); test('returns 9000 when only low (IPAM-range) VMIDs are used', () => { expect(selectFreeVmid([200, 201, 202])).toBe(9000); }); test('skips a contiguous block of used template VMIDs', () => { expect(selectFreeVmid([9000, 9001, 9002])).toBe(9003); }); test('finds a gap inside the used set', () => { // 9000 used, 9001 free → returns 9001 expect(selectFreeVmid([9000, 9002, 9003])).toBe(9001); }); test('honors a custom minVmid floor', () => { expect(selectFreeVmid([9000], 9500)).toBe(9500); }); test('never returns a VMID below the floor even if low ones are free', () => { // 100/101 are free but below the 9000 floor — must stay out of the IPAM range expect(selectFreeVmid([9000])).toBe(9001); }); }); describe('buildCloudImageVolid — volid for a cloud image on import storage', () => { test('places the filename under the storage import/ namespace', () => { expect(buildCloudImageVolid('local', 'noble-server-cloudimg-amd64.img')).toBe( 'local:import/noble-server-cloudimg-amd64.img', ); }); test('works with an arbitrary storage name', () => { expect(buildCloudImageVolid('nas', 'jammy.img')).toBe('nas:import/jammy.img'); }); }); describe('pollTaskUntilDone — synchronous-task guard (null/empty UPID)', () => { const creds = { api_url: 'https://proxmox.invalid:8006/api2/json', api_token_id: 'root@pam!test', api_token_secret: 'unused', }; test('resolves to OK immediately for an empty UPID without hitting the network', async () => { // An empty UPID means the API call completed synchronously (e.g. converting // a diskless VM to a template returns null data, not a worker UPID). The // guard must short-circuit BEFORE any task-status lookup — proxmox.invalid // would otherwise throw a DNS/connection error, so a clean 'OK' proves it // never tried to poll. expect(await pollTaskUntilDone(creds, 'pve', '')).toBe('OK'); }); });