import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { unlink } from 'node:fs/promises'; import { type DbClient, createDbClient } from '../db/client'; import { ipAllocations, ipReservations, modules, systemConfig, vmidReservations, } from '../db/schema'; import type { NewModule } from '../db/schema'; import { allocateIPFromSubnet, allocateResources, allocateVMID, deallocateResources, getAllocatedIPsInSubnet, getAllocation, isIPAvailable, isVMIDAvailable, listReservations, listVMIDReservations, reserveIP, reserveVMID, unreserveIP, unreserveVMID, updateReservationReason, } from './allocator'; describe('IPAM Allocator', () => { let db: DbClient; let testDbPath: string; beforeEach(async () => { testDbPath = `./test-ipam-${Date.now()}-${Math.random()}.db`; db = createDbClient({ path: testDbPath }); // Tables are automatically created by auto-migration in createDbClient() // Insert test module const testModule: NewModule = { id: 'test-module', name: 'Test Module', version: '1.0.0', sourcePath: '/test', manifestData: {}, }; db.insert(modules).values(testModule).run(); // Insert network config for DMZ zone db.insert(systemConfig) .values({ key: 'network.dmz.subnet', value: '10.0.10.0/24', }) .run(); }); afterEach(async () => { db.$client.close(); if (existsSync(testDbPath)) { await unlink(testDbPath); } const walPath = `${testDbPath}-wal`; const shmPath = `${testDbPath}-shm`; if (existsSync(walPath)) { await unlink(walPath); } if (existsSync(shmPath)) { await unlink(shmPath); } }); describe('allocateVMID', () => { test('should allocate first VMID as 2100', async () => { const vmid = await allocateVMID(db); expect(vmid).toBe(2100); }); test('should increment VMID sequentially', async () => { // Manually insert allocation db.insert(ipAllocations) .values({ moduleId: 'test-module', vmid: 2100, containerIp: '10.0.10.10/24', zone: 'dmz', }) .run(); const vmid = await allocateVMID(db); expect(vmid).toBe(2101); }); test('should find max VMID from multiple allocations', async () => { db.insert(ipAllocations) .values([ { moduleId: 'test-module', vmid: 2100, containerIp: '10.0.10.10/24', zone: 'dmz' }, { moduleId: 'test-module', vmid: 2105, containerIp: '10.0.10.11/24', zone: 'dmz' }, { moduleId: 'test-module', vmid: 2102, containerIp: '10.0.10.12/24', zone: 'dmz' }, ]) .run(); const vmid = await allocateVMID(db); expect(vmid).toBe(2106); }); }); describe('allocateIPFromSubnet', () => { test('should allocate first IP as .10', async () => { const ip = await allocateIPFromSubnet('10.0.10.0/24', 'dmz', db); expect(ip).toBe('10.0.10.10/24'); }); test('should skip already allocated IPs', async () => { // Allocate .10 db.insert(ipAllocations) .values({ moduleId: 'test-module', vmid: 2100, containerIp: '10.0.10.10/24', zone: 'dmz', }) .run(); // Next allocation should be .11 const ip = await allocateIPFromSubnet('10.0.10.0/24', 'dmz', db); expect(ip).toBe('10.0.10.11/24'); }); test('should skip reserved IPs', async () => { // Reserve .10 and .11 db.insert(ipReservations) .values({ ipStart: '10.0.10.10', ipEnd: '10.0.10.11', zone: 'dmz', reason: 'Test reservation', }) .run(); // Should allocate .12 const ip = await allocateIPFromSubnet('10.0.10.0/24', 'dmz', db); expect(ip).toBe('10.0.10.12/24'); }); test('should skip single IP reservation', async () => { // Reserve .10 only db.insert(ipReservations) .values({ ipStart: '10.0.10.10', ipEnd: null, zone: 'dmz', reason: 'Single IP reservation', }) .run(); // Should allocate .11 const ip = await allocateIPFromSubnet('10.0.10.0/24', 'dmz', db); expect(ip).toBe('10.0.10.11/24'); }); test('should throw error when subnet exhausted', async () => { // Fill up subnet (.10 to .254 = 245 IPs) const allocations = []; for (let i = 10; i <= 254; i++) { allocations.push({ moduleId: 'test-module', vmid: 2000 + i, containerIp: `10.0.10.${i}/24`, zone: 'dmz' as const, }); } db.insert(ipAllocations).values(allocations).run(); await expect(allocateIPFromSubnet('10.0.10.0/24', 'dmz', db)).rejects.toThrow( 'No available IPs in subnet', ); }); }); describe('allocateResources', () => { test('should allocate both VMID and IP', async () => { const result = await allocateResources('test-module', 'dmz', db); expect(result.vmid).toBe(2100); expect(result.containerIp).toBe('10.0.10.10/24'); // Verify stored in database const allocations = await db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(1); expect(allocations[0].moduleId).toBe('test-module'); expect(allocations[0].vmid).toBe(2100); expect(allocations[0].containerIp).toBe('10.0.10.10/24'); }); test('should throw error if zone not configured', async () => { await expect(allocateResources('test-module', 'app', db)).rejects.toThrow( "Network zone 'app' not configured", ); }); test('should allocate different resources for multiple modules', async () => { // Insert additional test modules db.insert(modules) .values([ { id: 'module1', name: 'Module 1', version: '1.0.0', sourcePath: '/test1', manifestData: {}, }, { id: 'module2', name: 'Module 2', version: '1.0.0', sourcePath: '/test2', manifestData: {}, }, ]) .run(); const result1 = await allocateResources('module1', 'dmz', db); const result2 = await allocateResources('module2', 'dmz', db); expect(result1.vmid).toBe(2100); expect(result1.containerIp).toBe('10.0.10.10/24'); expect(result2.vmid).toBe(2101); expect(result2.containerIp).toBe('10.0.10.11/24'); }); }); describe('deallocateResources', () => { test('should remove allocation', async () => { await allocateResources('test-module', 'dmz', db); await deallocateResources('test-module', db); const allocations = await db.select().from(ipAllocations).all(); expect(allocations).toHaveLength(0); }); }); describe('getAllocation', () => { test('should return allocation for module', async () => { await allocateResources('test-module', 'dmz', db); const allocation = await getAllocation('test-module', db); expect(allocation).toBeDefined(); expect(allocation?.vmid).toBe(2100); expect(allocation?.containerIp).toBe('10.0.10.10/24'); }); test('should return null if no allocation', async () => { const allocation = await getAllocation('nonexistent', db); expect(allocation).toBeNull(); }); }); describe('IP Reservations', () => { test('should reserve single IP', async () => { await reserveIP('10.0.10.50', 'dmz', 'Load balancer', null, db); const reservations = await db.select().from(ipReservations).all(); expect(reservations).toHaveLength(1); expect(reservations[0].ipStart).toBe('10.0.10.50'); expect(reservations[0].ipEnd).toBeNull(); expect(reservations[0].reason).toBe('Load balancer'); }); test('should reserve IP range', async () => { await reserveIP('10.0.10.100', 'dmz', 'Manual VMs', '10.0.10.110', db); const reservations = await db.select().from(ipReservations).all(); expect(reservations).toHaveLength(1); expect(reservations[0].ipStart).toBe('10.0.10.100'); expect(reservations[0].ipEnd).toBe('10.0.10.110'); }); test('should unreserve IP', async () => { await reserveIP('10.0.10.50', 'dmz', 'Test', null, db); await unreserveIP('10.0.10.50', 'dmz', db); const reservations = await db.select().from(ipReservations).all(); expect(reservations).toHaveLength(0); }); test('should list all reservations', async () => { await reserveIP('10.0.10.50', 'dmz', 'LB', null, db); await reserveIP('10.0.10.100', 'dmz', 'VMs', '10.0.10.110', db); const reservations = await listReservations(db); expect(reservations).toHaveLength(2); }); // Editing in place, rather than include-then-exclude: that dance drops the // row for a moment and can race an allocation into the held address. test('should edit a reservation reason in place', async () => { await reserveIP('10.0.10.50', 'dmz', 'LB', null, db); const updated = await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db); expect(updated).toBe(true); const reservations = await db.select().from(ipReservations).all(); expect(reservations).toHaveLength(1); expect(reservations[0].reason).toBe('Retired LB'); }); test('should leave the address held while editing', async () => { await reserveIP('10.0.10.50', 'dmz', 'LB', null, db); await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db); expect(await isIPAvailable('10.0.10.50', 'dmz', db)).toBe(false); }); test('should edit only the addressed row', async () => { await reserveIP('10.0.10.50', 'dmz', 'LB', null, db); await reserveIP('10.0.10.51', 'dmz', 'LB', null, db); await updateReservationReason('10.0.10.50', 'dmz', 'Retired LB', db); const reasons = (await listReservations(db)).map((r) => r.reason); expect(reasons.sort()).toEqual(['LB', 'Retired LB']); }); test('should report when no reservation exists to edit', async () => { const updated = await updateReservationReason('10.0.10.99', 'dmz', 'Nothing there', db); expect(updated).toBe(false); }); }); describe('getAllocatedIPsInSubnet', () => { test('should return allocated IPs in subnet', async () => { // Insert additional test modules db.insert(modules) .values([ { id: 'module1', name: 'Module 1', version: '1.0.0', sourcePath: '/test1', manifestData: {}, }, { id: 'module2', name: 'Module 2', version: '1.0.0', sourcePath: '/test2', manifestData: {}, }, ]) .run(); await allocateResources('module1', 'dmz', db); await allocateResources('module2', 'dmz', db); const ips = await getAllocatedIPsInSubnet('10.0.10.0/24', db); expect(ips).toHaveLength(2); expect(ips).toContain('10.0.10.10/24'); expect(ips).toContain('10.0.10.11/24'); }); test('should return empty array if no allocations', async () => { const ips = await getAllocatedIPsInSubnet('10.0.10.0/24', db); expect(ips).toHaveLength(0); }); }); describe('isIPAvailable', () => { test('should return true for available IP', async () => { const available = await isIPAvailable('10.0.10.50/24', 'dmz', db); expect(available).toBe(true); }); test('should return false for allocated IP', async () => { await allocateResources('test-module', 'dmz', db); const available = await isIPAvailable('10.0.10.10/24', 'dmz', db); expect(available).toBe(false); }); test('should return false for reserved IP', async () => { await reserveIP('10.0.10.50', 'dmz', 'Test', null, db); const available = await isIPAvailable('10.0.10.50/24', 'dmz', db); expect(available).toBe(false); }); }); describe('VMID Reservations', () => { test('should reserve VMID', async () => { await reserveVMID(2100, 'Test reservation', db); const reservations = await db.select().from(vmidReservations).all(); expect(reservations).toHaveLength(1); expect(reservations[0].vmid).toBe(2100); expect(reservations[0].reason).toBe('Test reservation'); }); test('should throw error if VMID already allocated', async () => { await allocateResources('test-module', 'dmz', db); await expect(reserveVMID(2100, 'Test', db)).rejects.toThrow( 'VMID 2100 is already allocated to module test-module', ); }); test('should throw error if VMID already reserved', async () => { await reserveVMID(2100, 'First reservation', db); await expect(reserveVMID(2100, 'Second reservation', db)).rejects.toThrow( 'VMID 2100 is already reserved: First reservation', ); }); test('should unreserve VMID', async () => { await reserveVMID(2100, 'Test', db); await unreserveVMID(2100, db); const reservations = await db.select().from(vmidReservations).all(); expect(reservations).toHaveLength(0); }); test('should list VMID reservations', async () => { await reserveVMID(2100, 'First', db); await reserveVMID(2101, 'Second', db); const reservations = await listVMIDReservations(db); expect(reservations).toHaveLength(2); expect(reservations[0].vmid).toBe(2100); expect(reservations[1].vmid).toBe(2101); }); test('should skip reserved VMIDs during allocation', async () => { await reserveVMID(2100, 'Reserved for external VM', db); const vmid = await allocateVMID(db); expect(vmid).toBe(2101); }); test('should skip multiple reserved VMIDs', async () => { await reserveVMID(2100, 'First', db); await reserveVMID(2101, 'Second', db); await reserveVMID(2102, 'Third', db); const vmid = await allocateVMID(db); expect(vmid).toBe(2103); }); }); describe('isVMIDAvailable', () => { test('should return true for available VMID', async () => { const available = await isVMIDAvailable(2100, db); expect(available).toBe(true); }); test('should return false for allocated VMID', async () => { await allocateResources('test-module', 'dmz', db); const available = await isVMIDAvailable(2100, db); expect(available).toBe(false); }); test('should return false for reserved VMID', async () => { await reserveVMID(2100, 'Test', db); const available = await isVMIDAvailable(2100, db); expect(available).toBe(false); }); }); });