import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { type DbClient, createDbClient } from '../db/client'; import { capabilities } from '../db/schema'; import { upsertModuleConfig } from '../services/module-config'; import { decideStorage, decideTargetNode, discoverTemplateFiles, generateTemplates, getOutputFilename, injectProxmoxDns, isTemplateFile, omitUntaggedProxmoxVlan, readTemplateFiles, storageFromTfState, targetNodeFromTfState, writeGeneratedFiles, } from './generator'; import type { GeneratedFile } from './types'; const TEST_MODULE_DIR = './test-module-templates'; const TEST_OUTPUT_DIR = './test-generated'; const TEST_DB_PATH = './test-templates.db'; describe('Template Generator', () => { let db: DbClient; beforeEach(async () => { // Create test database db = createDbClient({ path: TEST_DB_PATH }); db.$client.run(` CREATE TABLE IF NOT EXISTS modules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'IMPORTED', manifest_data TEXT NOT NULL, source_path TEXT NOT NULL, imported_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), error_message TEXT ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, value_json TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, capability_name TEXT NOT NULL, version TEXT NOT NULL, data TEXT NOT NULL, registered_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, name TEXT NOT NULL, encrypted_value TEXT NOT NULL, iv TEXT NOT NULL, auth_tag TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS system_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT NOT NULL UNIQUE, value TEXT NOT NULL, description TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS machines ( id TEXT PRIMARY KEY, hostname TEXT NOT NULL, zone TEXT NOT NULL, ip_address TEXT NOT NULL, ssh_user TEXT NOT NULL, ssh_key_encrypted TEXT NOT NULL, hardware TEXT NOT NULL, assigned_module_ids TEXT DEFAULT '[]' NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS container_services ( id TEXT PRIMARY KEY, name TEXT NOT NULL, provider_name TEXT NOT NULL, zones TEXT NOT NULL, api_credentials_encrypted TEXT NOT NULL, provider_config TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_infrastructure ( id TEXT PRIMARY KEY, module_id TEXT NOT NULL, infrastructure_type TEXT NOT NULL, machine_id TEXT, service_id TEXT, container_metadata TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE, FOREIGN KEY (machine_id) REFERENCES machines(id), FOREIGN KEY (service_id) REFERENCES container_services(id) ) `); // Clean up test directories if (existsSync(TEST_MODULE_DIR)) { await rm(TEST_MODULE_DIR, { recursive: true }); } if (existsSync(TEST_OUTPUT_DIR)) { await rm(TEST_OUTPUT_DIR, { recursive: true }); } await mkdir(TEST_MODULE_DIR, { recursive: true }); await mkdir(TEST_OUTPUT_DIR, { recursive: true }); }); afterEach(async () => { db.$client.close(); if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } const walPath = `${TEST_DB_PATH}-wal`; const shmPath = `${TEST_DB_PATH}-shm`; if (existsSync(walPath)) { await rm(walPath); } if (existsSync(shmPath)) { await rm(shmPath); } if (existsSync(TEST_MODULE_DIR)) { await rm(TEST_MODULE_DIR, { recursive: true }); } if (existsSync(TEST_OUTPUT_DIR)) { await rm(TEST_OUTPUT_DIR, { recursive: true }); } }); describe('isTemplateFile', () => { test('should identify .tpl files', () => { expect(isTemplateFile('main.tf.tpl')).toBe(true); expect(isTemplateFile('playbook.yml.tpl')).toBe(true); }); test('should identify .j2 files', () => { expect(isTemplateFile('config.json.j2')).toBe(true); expect(isTemplateFile('template.yaml.j2')).toBe(true); }); test('should reject non-template files', () => { expect(isTemplateFile('main.tf')).toBe(false); expect(isTemplateFile('playbook.yml')).toBe(false); expect(isTemplateFile('README.md')).toBe(false); }); }); describe('getOutputFilename', () => { test('should remove .tpl extension', () => { expect(getOutputFilename('main.tf.tpl')).toBe('main.tf'); expect(getOutputFilename('variables.tf.tpl')).toBe('variables.tf'); }); test('should keep .j2 extension (Ansible Jinja2 templates)', () => { expect(getOutputFilename('config.json.j2')).toBe('config.json.j2'); expect(getOutputFilename('playbook.yml.j2')).toBe('playbook.yml.j2'); expect(getOutputFilename('knot.conf.j2')).toBe('knot.conf.j2'); }); test('should return original if no template extension', () => { expect(getOutputFilename('main.tf')).toBe('main.tf'); expect(getOutputFilename('README.md')).toBe('README.md'); }); test('should strip prefix before ansible/ directory', () => { expect(getOutputFilename('celilo/ansible/playbook.yml.tpl')).toBe('ansible/playbook.yml'); expect(getOutputFilename('celilo/ansible/roles/app/tasks/main.yml.tpl')).toBe( 'ansible/roles/app/tasks/main.yml', ); }); test('should strip prefix before terraform/ directory', () => { expect(getOutputFilename('celilo/terraform/main.tf.tpl')).toBe('terraform/main.tf'); expect(getOutputFilename('celilo/terraform/variables.tf.tpl')).toBe('terraform/variables.tf'); }); test('should not strip ansible/terraform at root level', () => { expect(getOutputFilename('ansible/playbook.yml.tpl')).toBe('ansible/playbook.yml'); expect(getOutputFilename('terraform/main.tf.tpl')).toBe('terraform/main.tf'); }); }); describe('discoverTemplateFiles', () => { test('should find template files in directory', async () => { const dir = join(TEST_MODULE_DIR, 'terraform'); await mkdir(dir, { recursive: true }); await writeFile(join(dir, 'main.tf.tpl'), 'content'); await writeFile(join(dir, 'variables.tf.tpl'), 'content'); await writeFile(join(dir, 'README.md'), 'not a template'); const templates = await discoverTemplateFiles(dir); expect(templates).toHaveLength(2); expect(templates).toContain('main.tf.tpl'); expect(templates).toContain('variables.tf.tpl'); expect(templates).not.toContain('README.md'); }); test('should find templates recursively', async () => { const dir = join(TEST_MODULE_DIR, 'ansible'); await mkdir(join(dir, 'roles', 'app', 'tasks'), { recursive: true }); await writeFile(join(dir, 'playbook.yml.tpl'), 'content'); await writeFile(join(dir, 'roles', 'app', 'tasks', 'main.yml.tpl'), 'content'); const templates = await discoverTemplateFiles(dir); expect(templates).toHaveLength(2); expect(templates).toContain('playbook.yml.tpl'); expect(templates).toContain(join('roles', 'app', 'tasks', 'main.yml.tpl')); }); test('should return empty array for non-existent directory', async () => { const templates = await discoverTemplateFiles('./nonexistent'); expect(templates).toHaveLength(0); }); test('should handle empty directory', async () => { const dir = join(TEST_MODULE_DIR, 'empty'); await mkdir(dir, { recursive: true }); const templates = await discoverTemplateFiles(dir); expect(templates).toHaveLength(0); }); }); describe('readTemplateFiles', () => { test('should read template files with content', async () => { const dir = join(TEST_MODULE_DIR, 'terraform'); await mkdir(dir, { recursive: true }); await writeFile(join(dir, 'main.tf.tpl'), 'resource "test" {}'); await writeFile(join(dir, 'variables.tf.tpl'), 'variable "test" {}'); const templates = await readTemplateFiles(TEST_MODULE_DIR, [ 'terraform/main.tf.tpl', 'terraform/variables.tf.tpl', ]); expect(templates).toHaveLength(2); expect(templates[0]?.content).toBe('resource "test" {}'); expect(templates[0]?.targetPath).toBe('terraform/main.tf'); expect(templates[1]?.content).toBe('variable "test" {}'); expect(templates[1]?.targetPath).toBe('terraform/variables.tf'); }); }); describe('writeGeneratedFiles', () => { test('should write files to output directory', async () => { const files: GeneratedFile[] = [ { path: 'terraform/main.tf', content: 'resource "test" {}' }, { path: 'ansible/playbook.yml', content: 'tasks: []' }, ]; await writeGeneratedFiles(TEST_OUTPUT_DIR, files); expect(existsSync(join(TEST_OUTPUT_DIR, 'terraform', 'main.tf'))).toBe(true); expect(existsSync(join(TEST_OUTPUT_DIR, 'ansible', 'playbook.yml'))).toBe(true); }); test('should create nested directories', async () => { const files: GeneratedFile[] = [ { path: 'ansible/roles/app/tasks/main.yml', content: 'tasks: []' }, ]; await writeGeneratedFiles(TEST_OUTPUT_DIR, files); expect( existsSync(join(TEST_OUTPUT_DIR, 'ansible', 'roles', 'app', 'tasks', 'main.yml')), ).toBe(true); }); }); describe('generateTemplates', () => { test('should generate templates with variable resolution', async () => { // Setup module db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'test-module', 'target_ip', '192.168.0.50'); upsertModuleConfig(db, 'test-module', 'hostname', 'test'); // Insert system config db.$client.run( `INSERT INTO system_config (key, value) VALUES ('management.ip', '192.168.0.10')`, ); // Create template files const terraformDir = join(TEST_MODULE_DIR, 'terraform'); await mkdir(terraformDir, { recursive: true }); await writeFile( join(terraformDir, 'main.tf.tpl'), ` resource "proxmox_lxc" "container" { hostname = "$self:hostname" network { ip = "$self:target_ip/24" gateway = "$system:management.ip" } } `, ); // Generate const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(true); if (result.success) { // Should include terraform template + inventory files (hosts.ini, host_vars, group_vars) expect(result.files.length).toBeGreaterThanOrEqual(1); const terraformFile = result.files.find((f) => f.path === 'terraform/main.tf'); expect(terraformFile).toBeDefined(); expect(terraformFile?.content).toContain('hostname = "test"'); expect(terraformFile?.content).toContain('ip = "192.168.0.50/24"'); expect(terraformFile?.content).toContain('gateway = "192.168.0.10"'); // Verify file was written expect(existsSync(join(TEST_OUTPUT_DIR, 'terraform', 'main.tf'))).toBe(true); } }); test('should handle both terraform and ansible templates', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); upsertModuleConfig(db, 'test-module', 'name', 'test'); // Create templates await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await mkdir(join(TEST_MODULE_DIR, 'ansible'), { recursive: true }); await writeFile(join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'name = "$self:name"'); await writeFile(join(TEST_MODULE_DIR, 'ansible', 'playbook.yml.tpl'), 'name: $self:name'); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(true); if (result.success) { expect(result.files).toHaveLength(2); expect(result.files.some((f) => f.path.includes('terraform'))).toBe(true); expect(result.files.some((f) => f.path.includes('ansible'))).toBe(true); } }); test('should fail if module path does not exist', async () => { const result = await generateTemplates({ moduleId: 'test-module', modulePath: './nonexistent', outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('does not exist'); } }); test('should succeed with empty files for config-only modules', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(true); if (result.success) { expect(result.files).toEqual([]); } }); test('should fail if variable resolution fails', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await writeFile( join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'missing = "$self:missing_var"', ); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('Failed to resolve variables'); expect(result.error).toContain('missing_var'); } }); test('should resolve capability variables', async () => { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', '{}')`, ); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('dns-module', 'DNS', '1.0.0', '/path', '{}')`, ); db.insert(capabilities) .values({ moduleId: 'dns-module', capabilityName: 'dns_external', version: '1.0.0', data: { nameserver: 'ns1.example.com' }, }) .run(); await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await writeFile( join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'dns = "$capability:dns_external.nameserver"', ); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(true); if (result.success) { expect(result.files[0]?.content).toContain('dns = "ns1.example.com"'); } }); test('should skip terraform generation for machine infrastructure', async () => { // Setup module with machine infrastructure const manifestWithResources = JSON.stringify({ provides: { capabilities: [] }, requires: { capabilities: [], system: { cpu: 1, memory: 1024, disk: 10, zone: 'internal', }, }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', ?)`, [manifestWithResources], ); upsertModuleConfig(db, 'test-module', 'name', 'test'); // Insert dummy machine (required for foreign key) db.$client.run( `INSERT INTO machines (id, hostname, zone, ip_address, ssh_user, ssh_key_encrypted, hardware) VALUES ('machine-1', 'test-machine', 'internal', '192.168.0.100', 'root', 'encrypted', '{}')`, ); // Insert infrastructure selection for machine db.$client.run( `INSERT INTO module_infrastructure (id, module_id, infrastructure_type, machine_id) VALUES ('test-infra-1', 'test-module', 'machine', 'machine-1')`, ); // Create both terraform and ansible templates await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await mkdir(join(TEST_MODULE_DIR, 'ansible'), { recursive: true }); await writeFile(join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'name = "$self:name"'); await writeFile(join(TEST_MODULE_DIR, 'ansible', 'playbook.yml.tpl'), 'name: $self:name'); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); expect(result.success).toBe(true); if (result.success) { // Debug: print generated files console.log( 'Generated files:', result.files.map((f) => f.path), ); // Should have ansible files, but NO terraform files expect(result.files.length).toBeGreaterThanOrEqual(1); const terraformFiles = result.files.filter((f) => f.path.includes('terraform')); expect(terraformFiles).toEqual([]); expect(result.files.some((f) => f.path.includes('ansible'))).toBe(true); // Verify terraform file was NOT written expect(existsSync(join(TEST_OUTPUT_DIR, 'terraform', 'main.tf'))).toBe(false); // Verify ansible file WAS written expect(existsSync(join(TEST_OUTPUT_DIR, 'ansible', 'playbook.yml'))).toBe(true); expect(existsSync(join(TEST_OUTPUT_DIR, 'ansible', 'inventory', 'hosts.ini'))).toBe(true); const hostsIni = await readFile( join(TEST_OUTPUT_DIR, 'ansible', 'inventory', 'hosts.ini'), 'utf-8', ); expect(hostsIni).toContain('ansible_host=192.168.0.100'); // Machine IP expect(hostsIni).toContain('ansible_user=root'); // Machine SSH user expect(hostsIni).toContain('ansible_ssh_private_key_file='); // SSH key path expect(hostsIni).toContain('celilo-ansible-keys/machine-machine-1.key'); // Key filename } }); test('should generate terraform for container service infrastructure', async () => { // Setup system config for IPAM (internal zone network) db.$client.run( `INSERT INTO system_config (key, value) VALUES ('network.internal.subnet', '192.168.100.0/24')`, ); db.$client.run( `INSERT INTO system_config (key, value) VALUES ('network.internal.gateway', '192.168.100.1')`, ); db.$client.run( `INSERT INTO system_config (key, value) VALUES ('network.internal.vlan', '100')`, ); // Setup module with container service infrastructure const manifestWithResources = JSON.stringify({ provides: { capabilities: [] }, requires: { capabilities: [] }, resources: { machine: { cpu: 1, memory: 1024, disk: 10, zone: 'internal', }, }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('test-module', 'Test', '1.0.0', '/path', ?)`, [manifestWithResources], ); upsertModuleConfig(db, 'test-module', 'name', 'test'); // Insert dummy container service (required for foreign key) db.$client.run( `INSERT INTO container_services (id, service_id, name, provider_name, zones, api_credentials_encrypted, provider_config, verified, created_at, updated_at) VALUES ('service-1', 'test-service', 'test-service', 'proxmox', '["app"]', '{}', '{"default_target_node":"pve","lxc_template":"local:vztmpl/ubuntu.tar.zst","storage":"local-lvm"}', 1, ${Date.now()}, ${Date.now()})`, ); // Insert infrastructure selection for container service db.$client.run( `INSERT INTO module_infrastructure (id, module_id, infrastructure_type, service_id) VALUES ('test-infra-1', 'test-module', 'container_service', 'service-1')`, ); // Create both terraform and ansible templates await mkdir(join(TEST_MODULE_DIR, 'terraform'), { recursive: true }); await mkdir(join(TEST_MODULE_DIR, 'ansible'), { recursive: true }); await writeFile(join(TEST_MODULE_DIR, 'terraform', 'main.tf.tpl'), 'name = "$self:name"'); await writeFile(join(TEST_MODULE_DIR, 'ansible', 'playbook.yml.tpl'), 'name: $self:name'); const result = await generateTemplates({ moduleId: 'test-module', modulePath: TEST_MODULE_DIR, outputPath: TEST_OUTPUT_DIR, db, }); if (!result.success) { console.error('Generation failed:', result.error); if (result.details) console.error('Details:', result.details); } expect(result.success).toBe(true); if (result.success) { // Should have both terraform and ansible files expect(result.files.length).toBe(2); expect(result.files.some((f) => f.path.includes('terraform'))).toBe(true); expect(result.files.some((f) => f.path.includes('ansible'))).toBe(true); // Verify both files were written expect(existsSync(join(TEST_OUTPUT_DIR, 'terraform', 'main.tf'))).toBe(true); expect(existsSync(join(TEST_OUTPUT_DIR, 'ansible', 'playbook.yml'))).toBe(true); } }); }); describe('injectProxmoxDns', () => { const LXC = [ 'resource "proxmox_lxc" "caddy" {', ' target_node = "pve"', ' start = true', ' network {', ' bridge = "vmbr0"', ' }', '}', ].join('\n'); test('injects nameserver + lifecycle when a nameserver is computable', () => { const out = injectProxmoxDns(LXC, true); expect(out).toContain(' nameserver = "$self:lxc_nameserver"'); expect(out).toContain(' lifecycle {'); expect(out).toContain( ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]', ); // Injected immediately after the opening line, before author attributes. const lines = out.split('\n'); expect(lines[0]).toBe('resource "proxmox_lxc" "caddy" {'); expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"'); expect(lines[2]).toBe(' lifecycle {'); // Author's attributes and nested blocks are untouched. expect(out).toContain(' target_node = "pve"'); expect(out).toContain(' network {'); }); test('injects lifecycle only (no nameserver) when none is computable', () => { const out = injectProxmoxDns(LXC, false); expect(out).not.toContain('nameserver = "$self:lxc_nameserver"'); expect(out).toContain(' lifecycle {'); expect(out).toContain( ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]', ); }); test('is idempotent — already-injected content is returned unchanged', () => { const once = injectProxmoxDns(LXC, true); const twice = injectProxmoxDns(once, true); expect(twice).toBe(once); }); test('does not double-inject nameserver when the template already has one', () => { // A stale copied module (or an author-set nameserver) — injecting a second // would be a terraform "Attribute redefined" error. const stale = [ 'resource "proxmox_lxc" "caddy" {', ' target_node = "pve"', ' nameserver = "$self:lxc_nameserver"', ' start = true', '}', ].join('\n'); const out = injectProxmoxDns(stale, true); // Exactly one nameserver attribute survives. expect(out.match(/nameserver\s*=/g)?.length).toBe(1); // The lifecycle guard is still added (ISS-0055 extended the ignore list). expect(out).toContain('ignore_changes = [nameserver,'); }); test('injects cloud-init DNS + the VM ForceNew ignore list into proxmox_vm_qemu', () => { const vm = [ 'resource "proxmox_vm_qemu" "builder" {', ' target_node = "pve"', ' clone = "ubuntu-2204-cloudinit"', ' network {', ' bridge = "vmbr0"', ' }', '}', ].join('\n'); const out = injectProxmoxDns(vm, true); expect(out).toContain(' nameserver = "$self:lxc_nameserver"'); expect(out).toContain(' lifecycle {'); // The VM ForceNew list (telmate 3.x), NOT the LXC one. expect(out).toContain( ' ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]', ); const lines = out.split('\n'); expect(lines[0]).toBe('resource "proxmox_vm_qemu" "builder" {'); expect(lines[1]).toBe(' nameserver = "$self:lxc_nameserver"'); expect(out).toContain(' clone = "ubuntu-2204-cloudinit"'); }); test('branches the ignore list per block in a mixed lxc + vm file', () => { // The forgejo-runner builder pattern: one template with both a count-gated // proxmox_lxc (light) and proxmox_vm_qemu (builder) — each gets its own list. const mixed = [ 'resource "proxmox_lxc" "light" {', ' target_node = "pve"', '}', '', 'resource "proxmox_vm_qemu" "builder" {', ' clone = "ubuntu-2204-cloudinit"', '}', ].join('\n'); const out = injectProxmoxDns(mixed, true); expect(out).toContain( 'ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]', ); expect(out).toContain('ignore_changes = [nameserver, network[0].macaddr, sshkeys, clone]'); }); test('leaves non-Proxmox-compute resources untouched', () => { // Not proxmox_lxc / proxmox_vm_qemu — an external-zone droplet, and the bare // `proxmox_vm` type (which is NOT the cloud-init qemu resource we target). const droplet = [ 'resource "digitalocean_droplet" "edge" {', ' size = "s-1vcpu-1gb"', '}', ].join('\n'); const bareVm = ['resource "proxmox_vm" "x" {', ' cores = 4', '}'].join('\n'); expect(injectProxmoxDns(droplet, true)).toBe(droplet); expect(injectProxmoxDns(bareVm, true)).toBe(bareVm); }); test('injects into every proxmox_lxc block in a multi-resource file', () => { const two = `${LXC}\n\n${LXC.replace('"caddy"', '"forgejo"')}`; const out = injectProxmoxDns(two, true); expect(out.match(/ignore_changes = \[nameserver,/g)?.length).toBe(2); }); test('matches the indentation of the resource opening line', () => { const indented = [' resource "proxmox_lxc" "x" {', ' cores = 1', ' }'].join('\n'); const out = injectProxmoxDns(indented, true); expect(out).toContain(' nameserver = "$self:lxc_nameserver"'); expect(out).toContain(' lifecycle {'); expect(out).toContain( ' ignore_changes = [nameserver, network[0].hwaddr, rootfs[0].volume, ssh_public_keys]', ); }); }); describe('omitUntaggedProxmoxVlan', () => { const LXC = [ 'resource "proxmox_lxc" "dns" {', ' target_node = "$self:target_node"', ' network {', ' bridge = "$self:bridge"', ' tag = $self:vlan', ' ip = "$self:target_ip"', ' }', '}', ].join('\n'); test('omits the optional VLAN tag for an untagged zone', () => { const out = omitUntaggedProxmoxVlan(LXC, false); expect(out).not.toContain('$self:vlan'); expect(out).toContain(' bridge = "$self:bridge"'); expect(out).toContain(' ip = "$self:target_ip"'); }); test('retains the VLAN tag when the zone has a VLAN ID', () => { expect(omitUntaggedProxmoxVlan(LXC, true)).toBe(LXC); }); test('preserves literal and non-self VLAN tag expressions', () => { const authored = ['tag = 30', 'tag = var.vlan', 'tag = $system:network.dmz.vlan'].join('\n'); expect(omitUntaggedProxmoxVlan(authored, false)).toBe(authored); }); test('removes an indented tag line with a trailing comment', () => { const content = ' tag = $self:vlan # optional VLAN\r\n ip = "dhcp"\r\n'; expect(omitUntaggedProxmoxVlan(content, false)).toBe(' ip = "dhcp"\r\n'); }); }); }); describe("targetNodeFromTfState (ISS-0090 — terraform state is celilo's placement record)", () => { test('reads the node from the proxmox_lxc target_node attribute', () => { const state = { resources: [ { type: 'proxmox_lxc', instances: [{ attributes: { target_node: 'node2', id: 'node2/lxc/200' } }], }, ], }; expect(targetNodeFromTfState(state)).toBe('node2'); }); test('falls back to parsing the resource id when target_node is absent', () => { const state = { resources: [{ type: 'proxmox_lxc', instances: [{ attributes: { id: 'node3/lxc/201' } }] }], }; expect(targetNodeFromTfState(state)).toBe('node3'); }); test('reads the node from a proxmox_vm_qemu target_node attribute (type:vm)', () => { const state = { resources: [ { type: 'proxmox_vm_qemu', instances: [{ attributes: { target_node: 'node3', id: 'node3/qemu/208' } }], }, ], }; expect(targetNodeFromTfState(state)).toBe('node3'); }); test('parses the qemu resource id when target_node is absent (type:vm)', () => { const state = { resources: [ { type: 'proxmox_vm_qemu', instances: [{ attributes: { id: 'node3/qemu/208' } }] }, ], }; expect(targetNodeFromTfState(state)).toBe('node3'); }); test('returns null when there is no proxmox_lxc/proxmox_vm_qemu resource (fresh/empty state)', () => { expect(targetNodeFromTfState({ resources: [] })).toBeNull(); expect(targetNodeFromTfState({})).toBeNull(); }); }); describe('decideTargetNode (ISS-0090 — deploy follows reality: Proxmox > state > default)', () => { test('Proxmox reality wins — adopts a hand-migration tf-state would miss', () => { expect( decideTargetNode({ proxmoxNode: 'node2', stateNode: 'node3', defaultNode: 'node3' }), ).toEqual({ node: 'node2', source: 'proxmox' }); }); test('falls back to terraform state when Proxmox is unknown/unreachable', () => { expect( decideTargetNode({ proxmoxNode: null, stateNode: 'node2', defaultNode: 'node3' }), ).toEqual({ node: 'node2', source: 'state' }); }); test('first deploy (no Proxmox, no state) → service default', () => { expect(decideTargetNode({ proxmoxNode: null, stateNode: null, defaultNode: 'node3' })).toEqual({ node: 'node3', source: 'default', }); }); test('a changed default never relocates a running container (reality overrides default)', () => { // default flipped node2→node3, but the container actually runs on node2: // resolution stays node2, so the redeploy is an in-place update, not a move. expect( decideTargetNode({ proxmoxNode: 'node2', stateNode: null, defaultNode: 'node3' }), ).toEqual({ node: 'node2', source: 'proxmox' }); }); }); describe("storageFromTfState (terraform state is celilo's storage record)", () => { test('reads storage from the proxmox_lxc rootfs block', () => { const state = { resources: [ { type: 'proxmox_lxc', instances: [ { attributes: { rootfs: [{ storage: 'local-lvm', volume: 'local-lvm:vm-204-disk-0' }], }, }, ], }, ], }; expect(storageFromTfState(state)).toBe('local-lvm'); }); test('falls back to the volume prefix when storage is absent', () => { const state = { resources: [ { type: 'proxmox_lxc', instances: [{ attributes: { rootfs: [{ volume: 'local-lvm:vm-204-disk-0' }] } }], }, ], }; expect(storageFromTfState(state)).toBe('local-lvm'); }); test('accepts a bare rootfs object as well as a block list', () => { const state = { resources: [ { type: 'proxmox_lxc', instances: [{ attributes: { rootfs: { storage: 'datacenter' } } }] }, ], }; expect(storageFromTfState(state)).toBe('datacenter'); }); test('reads a VM data disk, ignoring the cloudinit disk (type:vm)', () => { const state = { resources: [ { type: 'proxmox_vm_qemu', instances: [ { attributes: { disk: [ { type: 'cloudinit', storage: 'datacenter' }, { type: 'disk', storage: 'local-lvm' }, ], }, }, ], }, ], }; expect(storageFromTfState(state)).toBe('local-lvm'); }); test('returns null on a fresh/empty state', () => { expect(storageFromTfState({ resources: [] })).toBeNull(); expect(storageFromTfState({})).toBeNull(); expect(storageFromTfState({ resources: [{ type: 'proxmox_lxc', instances: [] }] })).toBeNull(); }); }); describe('decideStorage (storage is sticky — a changed default must not force replacement)', () => { test('an existing container keeps the storage it was created on', () => { // The celilo-registry case: lxc 204 lives on local-lvm, the service default // later became datacenter. Emitting the default would plan 1-to-destroy. expect(decideStorage({ stateStorage: 'local-lvm', defaultStorage: 'datacenter' })).toEqual({ storage: 'local-lvm', source: 'state', }); }); test('first create (no state) uses the service default', () => { expect(decideStorage({ stateStorage: null, defaultStorage: 'datacenter' })).toEqual({ storage: 'datacenter', source: 'default', }); }); });