From 12874d168dc1d543bb087a63bb9966466fc43549 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 11:51:49 -0500 Subject: [PATCH 01/26] refactor: split monolithic integration tests into numbered per-area files Split the 1544-line Integration.Tests.ps1 into 18 focused test files with numeric prefixes for execution ordering. New shared helper (_IntegrationHelper.ps1): - Credential-based auth (root@pam) instead of API tokens - Skip helpers for env var checks - Resource discovery (Find-TestVm) for cross-file dependencies - Register-TestResource for env-var state passing between files Files created: 00_Connection, 01_Nodes, 02_Users, 03_Storage, 03a_SharedStorage, 04_Network, 05_SDN, 06_VMs, 07_Snapshots, 08_Templates, 09_CloudInit, 10_Containers, 11_LinuxVM, 12_OVA, 13_Firewall, 14_Backup, 15_Tasks, 99_Cleanup Key changes: - All tests use root@pam credentials (not API tokens) - Each file self-contained with own BeforeAll/AfterAll cleanup - Token CRUD tests now idempotent (remove-before-create) - 99_Cleanup is safety-net for any leftover pester-* resources - SharedStorage renamed to 03a_SharedStorage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integration/00_Connection.Tests.ps1 | 48 + .../Integration/01_Nodes.Tests.ps1 | 29 + .../Integration/02_Users.Tests.ps1 | 155 ++ .../Integration/03_Storage.Tests.ps1 | 91 + ....Tests.ps1 => 03a_SharedStorage.Tests.ps1} | 51 +- .../Integration/04_Network.Tests.ps1 | 87 + .../Integration/05_SDN.Tests.ps1 | 144 ++ .../Integration/06_VMs.Tests.ps1 | 146 ++ .../Integration/07_Snapshots.Tests.ps1 | 73 + .../Integration/08_Templates.Tests.ps1 | 21 + .../Integration/09_CloudInit.Tests.ps1 | 43 + .../Integration/10_Containers.Tests.ps1 | 216 +++ .../Integration/11_LinuxVM.Tests.ps1 | 275 +++ .../Integration/12_OVA.Tests.ps1 | 90 + .../Integration/13_Firewall.Tests.ps1 | 118 ++ .../Integration/14_Backup.Tests.ps1 | 63 + .../Integration/15_Tasks.Tests.ps1 | 43 + .../Integration/99_Cleanup.Tests.ps1 | 194 +++ .../Integration/Integration.Tests.ps1 | 1544 ----------------- .../Integration/_IntegrationHelper.ps1 | 187 ++ 20 files changed, 2029 insertions(+), 1589 deletions(-) create mode 100644 tests/PSProxmoxVE.Tests/Integration/00_Connection.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/01_Nodes.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/02_Users.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/03_Storage.Tests.ps1 rename tests/PSProxmoxVE.Tests/Integration/{SharedStorage.Tests.ps1 => 03a_SharedStorage.Tests.ps1} (75%) create mode 100644 tests/PSProxmoxVE.Tests/Integration/04_Network.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/05_SDN.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/08_Templates.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 delete mode 100644 tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 create mode 100644 tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 diff --git a/tests/PSProxmoxVE.Tests/Integration/00_Connection.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/00_Connection.Tests.ps1 new file mode 100644 index 0000000..e3ce150 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/00_Connection.Tests.ps1 @@ -0,0 +1,48 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + Disconnect-TestPve +} + +Describe 'Connection — Integration' -Tag 'Integration' { + Context 'Connection' { + It 'Should connect to PVE server' { + if (Skip-IfNoTarget) { return } + + $session = Connect-PveServer ` + -Server $script:Host_ ` + -Port $script:Port ` + -Credential (New-Object System.Management.Automation.PSCredential( + 'root@pam', + (ConvertTo-SecureString $script:Password -AsPlainText -Force) + )) ` + -SkipCertificateCheck ` + -PassThru + + $session | Should -Not -BeNullOrEmpty + $session.Hostname | Should -Be $script:Host_ + $session.AuthMode.ToString() | Should -Be 'Ticket' + + Test-PveConnection | Should -BeTrue + } + + It 'Should detect server version' { + if (Skip-IfNoTarget) { return } + + $detail = Test-PveConnection -Detailed + $detail | Should -Not -BeNullOrEmpty + $detail.ServerVersion | Should -Not -BeNullOrEmpty + $detail.ServerVersion.Major | Should -BeIn @(8, 9) + + # If we know the expected version, verify it matches + if ($script:PveVersion) { + $detail.ServerVersion.Major | Should -Be ([int]$script:PveVersion) + } + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/01_Nodes.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/01_Nodes.Tests.ps1 new file mode 100644 index 0000000..3e31a72 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/01_Nodes.Tests.ps1 @@ -0,0 +1,29 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + Disconnect-TestPve +} + +Describe 'Nodes — Integration' -Tag 'Integration' { + Context 'Nodes' { + It 'Should list nodes' { + if (Skip-IfNoTarget) { return } + + $nodes = Get-PveNode + $nodes | Should -Not -BeNullOrEmpty + } + + It 'Should get node status' { + if (Skip-IfNoTarget) { return } + + $status = Get-PveNodeStatus -Node $script:Node + $status | Should -Not -BeNullOrEmpty + $status.Node | Should -Be $script:Node + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/02_Users.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/02_Users.Tests.ps1 new file mode 100644 index 0000000..d43ea72 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/02_Users.Tests.ps1 @@ -0,0 +1,155 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + if (-not $script:SkipReason) { + # Clean up users created during this file + foreach ($userId in @('pester-user@pam', 'pester-tokenuser@pam', 'pester-permuser@pam')) { + try { Remove-PveUser -UserId $userId -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + # Clean up roles + try { Remove-PveRole -RoleId 'PesterTestRole' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + Disconnect-TestPve +} + +Describe 'Users — Integration' -Tag 'Integration' { + Context 'User CRUD' { + It 'Should create a new user' { + if (Skip-IfNoTarget) { return } + + { New-PveUser -UserId 'pester-user@pam' -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should list users and find the new user' { + if (Skip-IfNoTarget) { return } + + $users = Get-PveUser + $users | Should -Not -BeNullOrEmpty + $users | Where-Object { $_.UserId -eq 'pester-user@pam' } | + Should -Not -BeNullOrEmpty + } + + It 'Should update user properties' { + if (Skip-IfNoTarget) { return } + + { Set-PveUser -UserId 'pester-user@pam' ` + -Comment 'Updated by Pester integration test' ` + -ErrorAction Stop } | Should -Not -Throw + + $user = Get-PveUser | Where-Object { $_.UserId -eq 'pester-user@pam' } + $user.Comment | Should -Be 'Updated by Pester integration test' + } + + It 'Should remove the user' { + if (Skip-IfNoTarget) { return } + + { Remove-PveUser -UserId 'pester-user@pam' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + + $users = Get-PveUser + $users | Where-Object { $_.UserId -eq 'pester-user@pam' } | + Should -BeNullOrEmpty + } + } + + Context 'Role CRUD' { + It 'Should create a new role' { + if (Skip-IfNoTarget) { return } + + { New-PveRole -RoleId 'PesterTestRole' ` + -Privileges 'VM.Audit,VM.Console' ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list roles and find the new role' { + if (Skip-IfNoTarget) { return } + + $roles = Get-PveRole + $roles | Should -Not -BeNullOrEmpty + $roles | Where-Object { $_.RoleId -eq 'PesterTestRole' } | + Should -Not -BeNullOrEmpty + } + + It 'Should remove the role' { + if (Skip-IfNoTarget) { return } + + { Remove-PveRole -RoleId 'PesterTestRole' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + } + + Context 'API Token CRUD' { + BeforeAll { + if ($null -eq $script:SkipReason) { + # Idempotent setup: remove existing token/user first if they exist + try { Remove-PveApiToken -UserId 'pester-tokenuser@pam' -TokenId 'pester-token' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + try { Remove-PveUser -UserId 'pester-tokenuser@pam' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + + New-PveUser -UserId 'pester-tokenuser@pam' -ErrorAction SilentlyContinue + } + } + + It 'Should create an API token' { + if (Skip-IfNoTarget) { return } + + $result = New-PveApiToken ` + -UserId 'pester-tokenuser@pam' ` + -TokenId 'pester-token' ` + -ErrorAction Stop + + $result | Should -Not -BeNullOrEmpty + } + + It 'Should list API tokens for the user' { + if (Skip-IfNoTarget) { return } + + $tokens = Get-PveApiToken -UserId 'pester-tokenuser@pam' + $tokens | Should -Not -BeNullOrEmpty + $tokens | Where-Object { $_.TokenId -eq 'pester-token' } | + Should -Not -BeNullOrEmpty + } + + It 'Should remove the API token' { + if (Skip-IfNoTarget) { return } + + { Remove-PveApiToken ` + -UserId 'pester-tokenuser@pam' ` + -TokenId 'pester-token' ` + -Confirm:$false ` + -ErrorAction Stop } | Should -Not -Throw + } + } + + Context 'Permissions' { + BeforeAll { + if ($null -eq $script:SkipReason) { + # Idempotent setup + try { Remove-PveUser -UserId 'pester-permuser@pam' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + New-PveUser -UserId 'pester-permuser@pam' -ErrorAction SilentlyContinue + } + } + + It 'Should list permissions' { + if (Skip-IfNoTarget) { return } + + { Get-PvePermission -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should set a permission' { + if (Skip-IfNoTarget) { return } + + { Set-PvePermission ` + -Path '/' ` + -Role 'PVEAuditor' ` + -UgId 'pester-permuser@pam' ` + -Type 'user' ` + -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/03_Storage.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/03_Storage.Tests.ps1 new file mode 100644 index 0000000..a06e502 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/03_Storage.Tests.ps1 @@ -0,0 +1,91 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + if (-not $script:SkipReason) { + # Clean up pester-store if it still exists + try { Remove-PveStorage -Storage 'pester-store' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + Disconnect-TestPve +} + +Describe 'Storage — Integration' -Tag 'Integration' { + Context 'Storage' { + It 'Should list storage' { + if (Skip-IfNoTarget) { return } + + $storage = Get-PveStorage -Node $script:Node + $storage | Should -Not -BeNullOrEmpty + } + + It 'Should list storage content' { + if (Skip-IfNoTarget) { return } + + { Get-PveStorageContent ` + -Node $script:Node ` + -Storage $script:Storage ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should upload an ISO' { + if (Skip-IfNoStorage) { return } + + if (-not (Test-Path $script:IsoPath)) { + Set-ItResult -Skipped -Because "ISO file not found at PVETEST_ISO_PATH: $($script:IsoPath)" + return + } + + { + Send-PveFile ` + -Node $script:Node ` + -Storage $script:Storage ` + -Path $script:IsoPath ` + -ErrorAction Stop + } | Should -Not -Throw + } + } + + Context 'Storage — CRUD' { + It 'Should create a directory storage (New-PveStorage)' { + if (Skip-IfNoTarget) { return } + + $result = New-PveStorage -Storage 'pester-store' -Type 'dir' ` + -Path '/tmp/pester-storage' -Content 'iso,vztmpl,backup' ` + -ErrorAction Stop + + $result | Should -Not -BeNullOrEmpty + } + + It 'Should list and find the new storage (Get-PveStorage)' { + if (Skip-IfNoTarget) { return } + + $storages = Get-PveStorage -Node $script:Node + $storages | Where-Object { $_.Storage -eq 'pester-store' } | + Should -Not -BeNullOrEmpty + } + + It 'Should remove the storage (Remove-PveStorage)' { + if (Skip-IfNoTarget) { return } + + { Remove-PveStorage -Storage 'pester-store' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + } + + Context 'Storage — Download' { + It 'Should download a file from URL to storage (Invoke-PveStorageDownload)' { + if (Skip-IfNoTarget) { return } + + $templateUrl = 'http://download.proxmox.com/images/system/alpine-3.20-default_20240908_amd64.tar.xz' + $task = Invoke-PveStorageDownload -Node $script:Node -Storage $script:Storage ` + -Url $templateUrl -Filename 'alpine-3.20-default_20240908_amd64.tar.xz' ` + -ContentType 'vztmpl' -Wait -ErrorAction Stop + + $task | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/SharedStorage.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/03a_SharedStorage.Tests.ps1 similarity index 75% rename from tests/PSProxmoxVE.Tests/Integration/SharedStorage.Tests.ps1 rename to tests/PSProxmoxVE.Tests/Integration/03a_SharedStorage.Tests.ps1 index 62e07fc..91901d4 100644 --- a/tests/PSProxmoxVE.Tests/Integration/SharedStorage.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/03a_SharedStorage.Tests.ps1 @@ -17,41 +17,17 @@ #> BeforeAll { - . $PSScriptRoot/../_TestHelper.ps1 - - # --- Base integration vars --- - $baseVars = @('PVETEST_HOST', 'PVETEST_PORT', 'PVETEST_APITOKEN', 'PVETEST_NODE') - $script:SkipReason = $null - - foreach ($var in $baseVars) { - if (-not [System.Environment]::GetEnvironmentVariable($var)) { - $script:SkipReason = "No live Proxmox VE target configured. Set: $($baseVars -join ', ')" - break - } - } + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve # --- Shared storage vars --- - $script:StorageVmIp = [System.Environment]::GetEnvironmentVariable('PVETEST_STORAGE_VM_IP') - $script:IscsiIqn = [System.Environment]::GetEnvironmentVariable('PVETEST_ISCSI_IQN') - $script:NfsExport = [System.Environment]::GetEnvironmentVariable('PVETEST_NFS_EXPORT') - - $script:Host_ = [System.Environment]::GetEnvironmentVariable('PVETEST_HOST') - $portEnv = [System.Environment]::GetEnvironmentVariable('PVETEST_PORT') - $script:Port = [int]$(if ($portEnv) { $portEnv } else { '8006' }) - $script:ApiToken = [System.Environment]::GetEnvironmentVariable('PVETEST_APITOKEN') - $script:Node = [System.Environment]::GetEnvironmentVariable('PVETEST_NODE') + $script:StorageVmIp = $env:PVETEST_STORAGE_VM_IP + $script:IscsiIqn = $env:PVETEST_ISCSI_IQN + $script:NfsExport = $env:PVETEST_NFS_EXPORT # Track created storages for cleanup $script:CreatedStorages = [System.Collections.Generic.List[string]]::new() - function script:Skip-IfNoTarget { - if ($script:SkipReason) { - Set-ItResult -Skipped -Because $script:SkipReason - return $true - } - return $false - } - function script:Skip-IfNoSharedStorage { if (Skip-IfNoTarget) { return $true } if (-not $script:StorageVmIp -or -not $script:IscsiIqn -or -not $script:NfsExport) { @@ -68,26 +44,11 @@ AfterAll { try { Remove-PveStorage -Storage $name -Confirm:$false -ErrorAction Stop } catch { Write-Warning "Cleanup: failed to remove storage '$name': $_" } } + Disconnect-TestPve } Describe 'Shared Storage — Integration' -Tag 'Integration' { - # ------------------------------------------------------------------- - Context 'Connection' { - It 'Should connect to PVE node' { - if (Skip-IfNoTarget) { return } - - $session = Connect-PveServer ` - -Server $script:Host_ ` - -Port $script:Port ` - -ApiToken $script:ApiToken ` - -SkipCertificateCheck ` - -PassThru - - $session | Should -Not -BeNullOrEmpty - } - } - # ------------------------------------------------------------------- Context 'NFS Storage' { It 'Should create NFS storage' { diff --git a/tests/PSProxmoxVE.Tests/Integration/04_Network.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/04_Network.Tests.ps1 new file mode 100644 index 0000000..4879912 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/04_Network.Tests.ps1 @@ -0,0 +1,87 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + if (-not $script:SkipReason) { + # Clean up pester network bridge if it still exists + try { Remove-PveNetwork -Node $script:Node -Iface 'vmbr99' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + try { Invoke-PveNetworkApply -Node $script:Node -Wait -ErrorAction SilentlyContinue } catch { } + } + Disconnect-TestPve +} + +Describe 'Network — Integration' -Tag 'Integration' { + Context 'Network — Read' { + It 'Should list networks' { + if (Skip-IfNoTarget) { return } + + $networks = Get-PveNetwork -Node $script:Node + $networks | Should -Not -BeNullOrEmpty + } + + It 'Should filter by interface type' { + if (Skip-IfNoTarget) { return } + + # Filter for bridges — every PVE node has at least vmbr0 + $bridges = Get-PveNetwork -Node $script:Node -Type 'bridge' + $bridges | Should -Not -BeNullOrEmpty + $bridges | ForEach-Object { $_.Type | Should -Be 'bridge' } + } + } + + Context 'Network — CRUD' { + It 'Should create a Linux bridge' { + if (Skip-IfNoTarget) { return } + + { New-PveNetwork ` + -Node $script:Node ` + -Iface 'vmbr99' ` + -Type 'bridge' ` + -Autostart ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should find the new bridge in pending changes' { + if (Skip-IfNoTarget) { return } + + $networks = Get-PveNetwork -Node $script:Node + $networks | Where-Object { $_.Iface -eq 'vmbr99' } | + Should -Not -BeNullOrEmpty + } + + It 'Should update bridge comments' { + if (Skip-IfNoTarget) { return } + + { Set-PveNetwork ` + -Node $script:Node ` + -Iface 'vmbr99' ` + -Type 'bridge' ` + -Comments 'Created by Pester integration test' ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the bridge' { + if (Skip-IfNoTarget) { return } + + { Remove-PveNetwork ` + -Node $script:Node ` + -Iface 'vmbr99' ` + -Confirm:$false ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should revert pending changes (apply to baseline)' { + if (Skip-IfNoTarget) { return } + + # Apply reverts any pending changes back to the running config + { Invoke-PveNetworkApply ` + -Node $script:Node ` + -Wait ` + -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/05_SDN.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/05_SDN.Tests.ps1 new file mode 100644 index 0000000..a1c3e46 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/05_SDN.Tests.ps1 @@ -0,0 +1,144 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + if (-not $script:SkipReason) { + # Clean up SDN resources in reverse dependency order + try { Remove-PveSdnSubnet -Vnet 'pestervn' -Subnet 'pesterz-10.99.0.0-24' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + Start-Sleep -Seconds 2 + try { Remove-PveSdnVnet -Vnet 'pestervn' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + Start-Sleep -Seconds 2 + try { Remove-PveSdnZone -Zone 'pesterz' -Confirm:$false -ErrorAction SilentlyContinue } catch { } + } + Disconnect-TestPve +} + +Describe 'SDN — Integration' -Tag 'Integration' { + BeforeAll { + if ($null -eq $script:SkipReason) { + # SDN requires PVE 8+. Check server version and skip if older. + $detail = Test-PveConnection -Detailed + if ($detail.ServerVersion.Major -lt 8) { + $script:SkipSdn = 'SDN requires Proxmox VE 8.0 or later' + } else { + $script:SkipSdn = $null + } + } else { + $script:SkipSdn = $script:SkipReason + } + } + + Context 'SDN' { + It 'Should create an SDN zone (New-PveSdnZone)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + { New-PveSdnZone -Zone 'pesterz' -Type 'simple' -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should list SDN zones (Get-PveSdnZone)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + $zones = Get-PveSdnZone + $zones | Should -Not -BeNullOrEmpty + $zones | Where-Object { $_.Zone -eq 'pesterz' } | + Should -Not -BeNullOrEmpty + } + + It 'Should create an SDN VNet (New-PveSdnVnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + { New-PveSdnVnet -Vnet 'pestervn' -Zone 'pesterz' -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should list SDN VNets (Get-PveSdnVnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + $vnets = Get-PveSdnVnet + $vnets | Should -Not -BeNullOrEmpty + $vnets | Where-Object { $_.Vnet -eq 'pestervn' } | + Should -Not -BeNullOrEmpty + } + + It 'Should create an SDN subnet (New-PveSdnSubnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + { New-PveSdnSubnet -Vnet 'pestervn' -Subnet '10.99.0.0/24' -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should list SDN subnets (Get-PveSdnSubnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + $subnets = Get-PveSdnSubnet -Vnet 'pestervn' + $subnets | Should -Not -BeNullOrEmpty + } + + It 'Should remove SDN subnet (Remove-PveSdnSubnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + # PVE stores subnet IDs in the format: {zone}-{ip}-{prefix} + { Remove-PveSdnSubnet -Vnet 'pestervn' -Subnet 'pesterz-10.99.0.0-24' ` + -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove SDN VNet (Remove-PveSdnVnet)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + # Allow PVE to propagate subnet removal + Start-Sleep -Seconds 3 + { Remove-PveSdnVnet -Vnet 'pestervn' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should remove SDN zone (Remove-PveSdnZone)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + Start-Sleep -Seconds 2 + { Remove-PveSdnZone -Zone 'pesterz' -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + } + } + + Context 'SDN — IPAM, DNS, Controllers' { + It 'Should list SDN IPAM plugins (Get-PveSdnIpam)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + # PVE always has a built-in 'pve' IPAM plugin + $ipams = Get-PveSdnIpam + $ipams | Should -Not -BeNullOrEmpty + ($ipams | Where-Object { $_.Type -eq 'pve' }) | Should -Not -BeNullOrEmpty + } + + It 'Should list SDN DNS plugins (Get-PveSdnDns)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + # Zero DNS plugins is acceptable; just verify no throw. + { Get-PveSdnDns -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list SDN controllers (Get-PveSdnController)' { + if (Skip-IfNoTarget) { return } + if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } + + # Zero controllers is acceptable; just verify no throw. + { Get-PveSdnController -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 new file mode 100644 index 0000000..86263c2 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 @@ -0,0 +1,146 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:TestVmId = $null + + function script:Skip-IfNoTestVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:TestVmId) { + Set-ItResult -Skipped -Because 'No test VM was created' + return $true + } + return $false + } +} + +AfterAll { + if (-not $script:SkipReason -and $script:TestVmId) { + # Stop and remove pester-test-vm and any clone + foreach ($vmId in @($script:TestVmId, ($script:TestVmId + 1000))) { + try { + Stop-PveVm -Node $script:Node -VmId $vmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } + Disconnect-TestPve +} + +Describe 'VMs — Integration' -Tag 'Integration' { + Context 'VMs' { + It 'Should list VMs' { + if (Skip-IfNoTarget) { return } + + # Does not assert count — the node may have zero VMs. + { Get-PveVm -Node $script:Node -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create a test VM' { + if (Skip-IfNoTarget) { return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-test-vm' ` + -Memory 512 ` + -Cores 1 ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + # Retrieve the new VM ID from the cluster. + $vm = Get-PveVm -Node $script:Node -Name 'pester-test-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + + $script:TestVmId = $vm.VmId + Register-TestResource -Name 'pester-test-vm' -VmId $vm.VmId + } + + It 'Should get and set VM config' { + if (Skip-IfNoTestVm) { return } + + $config = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId + $config | Should -Not -BeNullOrEmpty + + { Set-PveVmConfig ` + -Node $script:Node ` + -VmId $script:TestVmId ` + -Description 'Updated by Pester integration test' ` + -ErrorAction Stop } | Should -Not -Throw + + $updated = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId + $updated.Description | Should -Be 'Updated by Pester integration test' + } + + It 'Should start and stop a VM' { + if (Skip-IfNoTestVm) { return } + + $startTask = Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 + $startTask | Should -Not -BeNullOrEmpty + + $stopTask = Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false + $stopTask | Should -Not -BeNullOrEmpty + } + + It 'Should hard-reset a running VM' { + if (Skip-IfNoTestVm) { return } + + # Start the VM first + Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 | Out-Null + + # Hard reset (no ACPI — works even without guest OS) + $task = Reset-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false + $task | Should -Not -BeNullOrEmpty + + Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + + It 'Should clone a VM' { + if (Skip-IfNoTestVm) { return } + + # Pick a high VMID for the clone to avoid collisions + $cloneId = $script:TestVmId + 1000 + + $task = Copy-PveVm ` + -SourceNode $script:Node ` + -VmId $script:TestVmId ` + -NewVmId $cloneId ` + -NewName 'pester-clone-vm' ` + -Full ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $cloned = Get-PveVm -Node $script:Node -Name 'pester-clone-vm' | + Select-Object -First 1 + $cloned | Should -Not -BeNullOrEmpty + } + } + + Context 'VM — Suspend / Resume / Resize' { + # Suspend/Resume is tested on the Linux VM (Guest Agent VM — Lifecycle) + # because an empty VM with no OS may not reliably transition to 'paused'. + + It 'Should resize a VM disk (Resize-PveVmDisk)' { + if (Skip-IfNoTestVm) { return } + + # First add a small disk via Set-PveVmConfig + { Set-PveVmConfig -Node $script:Node -VmId $script:TestVmId ` + -AdditionalConfig @{ scsi0 = 'local-lvm:1' } ` + -ErrorAction Stop } | Should -Not -Throw + + # Resize the disk by +1G + $task = Resize-PveVmDisk -Node $script:Node -VmId $script:TestVmId ` + -Disk 'scsi0' -Size '+1G' + $task | Should -Not -BeNullOrEmpty + + # Verify config shows scsi0 exists (larger disk) + $config = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId + $config | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 new file mode 100644 index 0000000..f207400 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 @@ -0,0 +1,73 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + # Find the test VM created by 06_VMs.Tests.ps1 + $script:TestVmId = Find-TestVm + + function script:Skip-IfNoTestVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:TestVmId) { + Set-ItResult -Skipped -Because 'No test VM found (pester-test-vm must exist from 06_VMs)' + return $true + } + return $false + } +} + +AfterAll { + # Snapshot cleanup is inline (remove within the test). + # No VM cleanup here — 06_VMs owns pester-test-vm. + Disconnect-TestPve +} + +Describe 'Snapshots — Integration' -Tag 'Integration' { + Context 'Snapshots' { + It 'Should create, list, and remove a snapshot' { + if (Skip-IfNoTestVm) { return } + + # Ensure the VM is stopped for snapshot + $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } + if ($vm.Status -eq 'running') { + Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + + $snapName = 'pester-snap' + + # Create + $createTask = New-PveSnapshot ` + -Node $script:Node ` + -VmId $script:TestVmId ` + -Name $snapName ` + -Description 'Created by Pester integration test' ` + -Wait + + $createTask | Should -Not -BeNullOrEmpty + + # List and verify + $snapshots = Get-PveSnapshot -Node $script:Node -VmId $script:TestVmId + $snap = $snapshots | Where-Object { $_.Name -eq $snapName } + $snap | Should -Not -BeNullOrEmpty + + # Restore + { Restore-PveSnapshot ` + -Node $script:Node ` + -VmId $script:TestVmId ` + -Name $snapName ` + -Confirm:$false ` + -Wait } | Should -Not -Throw + + # Remove + $removeTask = Remove-PveSnapshot ` + -Node $script:Node ` + -VmId $script:TestVmId ` + -Name $snapName ` + -Confirm:$false ` + -Wait + + $removeTask | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/08_Templates.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/08_Templates.Tests.ps1 new file mode 100644 index 0000000..acd13bc --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/08_Templates.Tests.ps1 @@ -0,0 +1,21 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + Disconnect-TestPve +} + +Describe 'Templates — Integration' -Tag 'Integration' { + Context 'Templates' { + It 'Should list templates' { + if (Skip-IfNoTarget) { return } + + # Zero templates is acceptable; just verify the cmdlet does not throw. + { Get-PveTemplate -Node $script:Node -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 new file mode 100644 index 0000000..0ecf8b5 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 @@ -0,0 +1,43 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + # Find a test VM for cloud-init config read test + $script:TestVmId = Find-TestVm + $script:LinuxVmId = Find-TestVm -Name 'pester-linux-vm' +} + +AfterAll { + # Read-only tests — no cleanup needed + Disconnect-TestPve +} + +Describe 'Cloud-Init — Integration' -Tag 'Integration' { + Context 'Cloud-Init' { + It 'Should get cloud-init config' { + if (Skip-IfNoTarget) { return } + if ($null -eq $script:TestVmId) { + Set-ItResult -Skipped -Because 'No test VM found' + return + } + + # Get-PveCloudInitConfig should not throw even if the VM has no cloud-init drive. + { Get-PveCloudInitConfig -Node $script:Node -VmId $script:TestVmId -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should regenerate cloud-init image (Invoke-PveCloudInitRegenerate)' { + if (Skip-IfNoTarget) { return } + if ($null -eq $script:LinuxVmId) { + Set-ItResult -Skipped -Because 'Linux VM was not provisioned (PVETEST_PASSWORD may not be set)' + return + } + + # The Linux VM has a cloud-init drive from provisioning + { Invoke-PveCloudInitRegenerate -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction Stop } | + Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 new file mode 100644 index 0000000..d1312df --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 @@ -0,0 +1,216 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:TestContainerId = $null + $script:CreatedContainerIds = [System.Collections.Generic.List[int]]::new() + + function script:Skip-IfNoTestContainer { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:TestContainerId) { + Set-ItResult -Skipped -Because 'No test container was created' + return $true + } + return $false + } +} + +AfterAll { + if ($null -eq $script:SkipReason) { + foreach ($ctId in $script:CreatedContainerIds) { + try { + Stop-PveContainer -Node $script:Node -VmId $ctId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + Remove-PveContainer -Node $script:Node -VmId $ctId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } + catch { <# non-fatal #> } + } + } + Disconnect-TestPve +} + +Describe 'Containers — Integration' -Tag 'Integration' { + + Context 'Containers' { + BeforeAll { + if ($null -eq $script:SkipReason) { + # Download a small Alpine LXC template + $templateUrl = 'http://download.proxmox.com/images/system/alpine-3.20-default_20240908_amd64.tar.xz' + try { + Invoke-PveStorageDownload -Node $script:Node -Storage $script:Storage ` + -Url $templateUrl -Filename 'alpine-3.20-default_20240908_amd64.tar.xz' ` + -ContentType 'vztmpl' -Wait -ErrorAction Stop + } catch { + # Template may already exist from a previous run or the Storage — Download context + } + } + $script:TestContainerId = $null + } + + It 'Should list containers (Get-PveContainer)' { + if (Skip-IfNoTarget) { return } + + # Zero containers is acceptable; just verify the cmdlet does not throw. + { Get-PveContainer -Node $script:Node -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create a container (New-PveContainer)' { + if (Skip-IfNoTarget) { return } + + $securePassword = ConvertTo-SecureString 'Pester12345!' -AsPlainText -Force + + $task = New-PveContainer ` + -Node $script:Node ` + -Hostname 'pester-ct' ` + -Memory 128 ` + -Cores 1 ` + -RootFsStorage 'local-lvm' ` + -RootFsSize '1' ` + -OsTemplate "$($script:Storage):vztmpl/alpine-3.20-default_20240908_amd64.tar.xz" ` + -Password $securePassword ` + -Bridge 'vmbr0' ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + # Retrieve the new container ID from the cluster + $ct = Get-PveContainer -Node $script:Node -Name 'pester-ct' | + Select-Object -First 1 + $ct | Should -Not -BeNullOrEmpty + + $script:TestContainerId = $ct.VmId + $script:CreatedContainerIds.Add($ct.VmId) + } + + It 'Should get container config (Get-PveContainerConfig)' { + if (Skip-IfNoTestContainer) { return } + + $config = Get-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId + $config | Should -Not -BeNullOrEmpty + } + + It 'Should update container config (Set-PveContainerConfig)' { + if (Skip-IfNoTestContainer) { return } + + { Set-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId ` + -Description 'Updated by Pester integration test' ` + -ErrorAction Stop } | Should -Not -Throw + + $config = Get-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId + $config.Description.Trim() | Should -Be 'Updated by Pester integration test' + } + + It 'Should start a container (Start-PveContainer)' { + if (Skip-IfNoTestContainer) { return } + + $task = Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 + $task | Should -Not -BeNullOrEmpty + + $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId + $ct.Status | Should -Be 'running' + } + + It 'Should stop a container (Stop-PveContainer)' { + if (Skip-IfNoTestContainer) { return } + + $task = Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false + $task | Should -Not -BeNullOrEmpty + + $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId + $ct.Status | Should -Be 'stopped' + } + + It 'Should restart a container (Restart-PveContainer)' { + if (Skip-IfNoTestContainer) { return } + + # Start first so we can restart + Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 | Out-Null + + $task = Restart-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false + $task | Should -Not -BeNullOrEmpty + + $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId + $ct.Status | Should -Be 'running' + + # Stop for subsequent tests + Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + + It 'Should clone a container (Copy-PveContainer)' { + if (Skip-IfNoTestContainer) { return } + + $cloneId = $script:TestContainerId + 1000 + + $task = Copy-PveContainer ` + -SourceNode $script:Node ` + -VmId $script:TestContainerId ` + -NewVmId $cloneId ` + -NewName 'pester-clone-ct' ` + -Full ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $cloned = Get-PveContainer -Node $script:Node -Name 'pester-clone-ct' | + Select-Object -First 1 + $cloned | Should -Not -BeNullOrEmpty + + $script:CreatedContainerIds.Add($cloned.VmId) + } + } + + Context 'Container Snapshots' { + It 'Should create a container snapshot (New-PveContainerSnapshot)' { + if (Skip-IfNoTestContainer) { return } + + # Ensure container is stopped + $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId + if ($ct.Status -eq 'running') { + Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + + $task = New-PveContainerSnapshot ` + -Node $script:Node ` + -VmId $script:TestContainerId ` + -Name 'pester-ct-snap' ` + -Description 'Created by Pester integration test' ` + -Wait + + $task | Should -Not -BeNullOrEmpty + } + + It 'Should list container snapshots (Get-PveContainerSnapshot)' { + if (Skip-IfNoTestContainer) { return } + + $snapshots = Get-PveContainerSnapshot -Node $script:Node -VmId $script:TestContainerId + $snap = $snapshots | Where-Object { $_.Name -eq 'pester-ct-snap' } + $snap | Should -Not -BeNullOrEmpty + } + + It 'Should restore a container snapshot (Restore-PveContainerSnapshot)' { + if (Skip-IfNoTestContainer) { return } + + { Restore-PveContainerSnapshot ` + -Node $script:Node ` + -VmId $script:TestContainerId ` + -Name 'pester-ct-snap' ` + -Confirm:$false ` + -Wait } | Should -Not -Throw + } + + It 'Should remove a container snapshot (Remove-PveContainerSnapshot)' { + if (Skip-IfNoTestContainer) { return } + + $task = Remove-PveContainerSnapshot ` + -Node $script:Node ` + -VmId $script:TestContainerId ` + -Name 'pester-ct-snap' ` + -Confirm:$false ` + -Wait + + $task | Should -Not -BeNullOrEmpty + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 new file mode 100644 index 0000000..ecdf2c5 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 @@ -0,0 +1,275 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:LinuxVmId = $null + $script:CreatedVmIds = [System.Collections.Generic.List[int]]::new() + + function script:Skip-IfNoLinuxVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:LinuxVmId) { + Set-ItResult -Skipped -Because 'Linux VM was not provisioned (PVETEST_PASSWORD may not be set)' + return $true + } + return $false + } +} + +AfterAll { + if ($null -eq $script:SkipReason) { + foreach ($vmId in $script:CreatedVmIds) { + try { + Stop-PveVm -Node $script:Node -VmId $vmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } + catch { <# non-fatal #> } + } + } + Disconnect-TestPve +} + +Describe 'Linux VM — Integration' -Tag 'Integration' { + + Context 'Linux VM — Provisioning' { + It 'Should upload cloud image to PVE storage (Send-PveFile)' { + if (Skip-IfNoPassword) { return } + + $task = Send-PveFile ` + -Node $script:Node -Storage $script:Storage ` + -Path $script:CloudImagePath ` + -ContentType 'import' -Wait + + $task | Should -Not -BeNullOrEmpty + $task.IsSuccessful | Should -BeTrue + } + + It 'Should create a Linux VM (New-PveVm)' { + if (Skip-IfNoPassword) { return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-linux-vm' ` + -Memory 512 -Cores 1 -OsType 'l26' -Wait + + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node -Name 'pester-linux-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + + $script:LinuxVmId = $vm.VmId + $script:CreatedVmIds.Add($vm.VmId) + Register-TestResource -Name 'pester-linux-vm' -VmId $vm.VmId + } + + It 'Should import cloud image disk (Import-PveVmDisk)' { + if (Skip-IfNoLinuxVm) { return } + + $cloudImageFilename = [System.IO.Path]::GetFileName($script:CloudImagePath) + $task = Import-PveVmDisk ` + -Node $script:Node -VmId $script:LinuxVmId ` + -Disk 'scsi0' -TargetStorage 'local-lvm' ` + -Source "$($script:Storage):import/$cloudImageFilename" ` + -Wait + + $task | Should -Not -BeNullOrEmpty + $task.IsSuccessful | Should -BeTrue + } + + It 'Should configure VM hardware (Set-PveVmConfig)' { + if (Skip-IfNoLinuxVm) { return } + + { Set-PveVmConfig -Node $script:Node -VmId $script:LinuxVmId ` + -AdditionalConfig @{ + scsihw = 'virtio-scsi-single' + boot = 'order=scsi0' + serial0 = 'socket' + agent = '1' + net0 = 'virtio,bridge=vmbr0' + ide2 = 'local-lvm:cloudinit' + cicustom = "user=$($script:Storage):snippets/test-vm-userdata.yml" + } -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should set cloud-init config (Set-PveCloudInitConfig)' { + if (Skip-IfNoLinuxVm) { return } + + { Set-PveCloudInitConfig -Node $script:Node -VmId $script:LinuxVmId ` + -CiUser 'root' ` + -Password (ConvertTo-SecureString $script:Password -AsPlainText -Force) ` + -IpConfig0 'ip=dhcp' ` + -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should start the Linux VM (Start-PveVm)' { + if (Skip-IfNoLinuxVm) { return } + + $task = Start-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 + $task | Should -Not -BeNullOrEmpty + } + + It 'Should wait for guest agent (Test-PveVmGuestAgent)' { + if (Skip-IfNoLinuxVm) { return } + + $timeout = 300; $elapsed = 0 + $agentReady = $false + while ($elapsed -lt $timeout) { + if (Test-PveVmGuestAgent -Node $script:Node -VmId $script:LinuxVmId) { + $agentReady = $true + break + } + Start-Sleep -Seconds 10 + $elapsed += 10 + } + + $agentReady | Should -BeTrue -Because "Guest agent should respond within ${timeout}s" + } + } + + Context 'Guest Agent — Cmdlets' { + It 'Should ping guest agent (Test-PveVmGuestAgent)' { + if (Skip-IfNoLinuxVm) { return } + + $result = Test-PveVmGuestAgent -Node $script:Node -VmId $script:LinuxVmId + $result | Should -BeTrue + } + + It 'Should discover guest network interfaces (Get-PveVmGuestNetwork)' { + if (Skip-IfNoLinuxVm) { return } + + $interfaces = Get-PveVmGuestNetwork -Node $script:Node -VmId $script:LinuxVmId + $interfaces | Should -Not -BeNullOrEmpty + + # Should have at least one interface with an IPv4 address + $withIp = $interfaces | Where-Object { + $_.IpAddresses | Where-Object { $_.Type -eq 'ipv4' -and $_.Address -ne '127.0.0.1' } + } + $withIp | Should -Not -BeNullOrEmpty + } + + It 'Should execute a command in guest (Invoke-PveVmGuestExec)' { + if (Skip-IfNoLinuxVm) { return } + + $result = Invoke-PveVmGuestExec -Node $script:Node -VmId $script:LinuxVmId -Command 'hostname' + $result | Should -Not -BeNullOrEmpty + $result.ExitCode | Should -Be 0 + $result.Stdout | Should -Not -BeNullOrEmpty + } + } + + Context 'Guest Agent VM — Lifecycle' { + It 'Should have a running Linux VM with guest agent' { + if (Skip-IfNoLinuxVm) { return } + + $vm = Get-PveVm -Node $script:Node | + Where-Object { $_.VmId -eq $script:LinuxVmId } + $vm | Should -Not -BeNullOrEmpty + $vm.Status | Should -Be 'running' + } + + It 'Should suspend and resume a running VM (Suspend-PveVm / Resume-PveVm)' { + if (Skip-IfNoLinuxVm) { return } + + # Suspend — -Wait -Timeout polls qmpstatus until 'paused' + $suspendTask = Suspend-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false + $suspendTask | Should -Not -BeNullOrEmpty + + # Verify with -Detailed (fetches qmpstatus from status/current endpoint) + $vm = Get-PveVm -Node $script:Node -VmId $script:LinuxVmId -Detailed + $vm.EffectiveStatus | Should -Be 'paused' + + # Resume — -Wait -Timeout polls qmpstatus until 'running' + $resumeTask = Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 + $resumeTask | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node -VmId $script:LinuxVmId -Detailed + $vm.EffectiveStatus | Should -Be 'running' + } + + It 'Should gracefully restart a VM via ACPI (Restart-PveVm)' { + if (Skip-IfNoLinuxVm) { return } + + # Ensure VM is running (resume if paused from a failed suspend test) + try { Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 10 -ErrorAction SilentlyContinue | Out-Null } + catch { <# already running #> } + + $task = Restart-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 60 -Confirm:$false + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node | + Where-Object { $_.VmId -eq $script:LinuxVmId } + $vm.Status | Should -Be 'running' + } + + It 'Should gracefully stop a VM via ACPI (Stop-PveVm)' { + if (Skip-IfNoLinuxVm) { return } + + $task = Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node | + Where-Object { $_.VmId -eq $script:LinuxVmId } + $vm.Status | Should -Be 'stopped' + } + } + + Context 'Templates — Convert and Clone' { + It 'Should convert the Linux VM to a template' { + if (Skip-IfNoLinuxVm) { return } + + # Ensure stopped first + $vm = Get-PveVm -Node $script:Node | + Where-Object { $_.VmId -eq $script:LinuxVmId } + if ($vm.Status -eq 'running') { + Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + + { New-PveTemplate -Node $script:Node -VmId $script:LinuxVmId -Confirm:$false -ErrorAction Stop } | + Should -Not -Throw + + # Allow a moment for the template flag to propagate + Start-Sleep -Seconds 3 + + # Verify it appears as a template + $templates = Get-PveTemplate -Node $script:Node + $templates | Where-Object { $_.VmId -eq $script:LinuxVmId } | + Should -Not -BeNullOrEmpty + } + + It 'Should clone a VM from the template (New-PveVmFromTemplate)' { + if (Skip-IfNoLinuxVm) { return } + + $cloneId = $script:LinuxVmId + 1000 + + $task = New-PveVmFromTemplate ` + -TemplateNode $script:Node ` + -VmId $script:LinuxVmId ` + -NewVmId $cloneId ` + -NewName 'pester-from-template' ` + -Full ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $cloned = Get-PveVm -Node $script:Node -Name 'pester-from-template' | + Select-Object -First 1 + $cloned | Should -Not -BeNullOrEmpty + + # Track for cleanup + $script:CreatedVmIds.Add($cloned.VmId) + } + + It 'Should remove a template (Remove-PveTemplate)' { + if (Skip-IfNoLinuxVm) { return } + + { Remove-PveTemplate -Node $script:Node -VmId $script:LinuxVmId ` + -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + + $script:CreatedVmIds.Remove($script:LinuxVmId) | Out-Null + $script:LinuxVmId = $null + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1 new file mode 100644 index 0000000..c5003d7 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/12_OVA.Tests.ps1 @@ -0,0 +1,90 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:CreatedVmIds = [System.Collections.Generic.List[int]]::new() +} + +AfterAll { + if ($null -eq $script:SkipReason) { + foreach ($vmId in $script:CreatedVmIds) { + try { + Stop-PveVm -Node $script:Node -VmId $vmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } + catch { <# non-fatal #> } + } + } + Disconnect-TestPve +} + +Describe 'OVA Import — Integration' -Tag 'Integration' { + + Context 'OVA Import' { + It 'Should parse OVF metadata from an OVA (client-side)' { + if (Skip-IfNoTarget) { return } + if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) { + Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found' + return + } + + $metadata = [PSProxmoxVE.Core.Models.Vms.OvfMetadata]::FromOva($script:OvaPath) + $metadata | Should -Not -BeNullOrEmpty + $metadata.Name | Should -Not -BeNullOrEmpty + $metadata.CpuCount | Should -BeGreaterThan 0 + $metadata.MemoryMB | Should -BeGreaterThan 0 + $metadata.Disks.Count | Should -BeGreaterThan 0 + } + + It 'Should import OVA as a VM with correct metadata (Import-PveOva)' { + if (Skip-IfNoTarget) { return } + if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) { + Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found' + return + } + + $vm = Import-PveOva -Node $script:Node -Storage $script:Storage ` + -Path $script:OvaPath -TargetStorage 'local-lvm' ` + -Name 'pester-ova-vm' -Wait + + $vm | Should -Not -BeNullOrEmpty + $script:CreatedVmIds.Add($vm.VmId) + + # Verify VM exists with correct name + $found = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' | Select-Object -First 1 + $found | Should -Not -BeNullOrEmpty + $found.Name | Should -Be 'pester-ova-vm' + + # Verify config matches OVF metadata + $config = Get-PveVmConfig -Node $script:Node -VmId $vm.VmId + $config.Memory | Should -BeGreaterThan 0 + $config.Cores | Should -BeGreaterThan 0 + $config.OsType | Should -Be 'l26' + $config.Scsi0 | Should -Not -BeNullOrEmpty -Because 'disk should be imported to scsi0' + $config.Net0 | Should -Not -BeNullOrEmpty -Because 'network adapter should be configured' + } + + It 'Should start the OVA-imported VM' { + if (Skip-IfNoTarget) { return } + + $ovaVm = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $ovaVm) { + Set-ItResult -Skipped -Because 'OVA VM was not imported' + return + } + + $task = Start-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 60 + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $ovaVm.VmId } + $vm.Status | Should -Be 'running' + + # Clean up + Stop-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 new file mode 100644 index 0000000..14c052f --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 @@ -0,0 +1,118 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:FirewallTestRulePos = $null +} + +AfterAll { + if ($null -eq $script:SkipReason) { + # Cleanup: firewall test artifacts + try { + $rules = Get-PveFirewallRule -Level Cluster -ErrorAction SilentlyContinue + $testRules = $rules | Where-Object { $_.Comment -like 'pester-test*' } + foreach ($r in $testRules) { + Remove-PveFirewallRule -Level Cluster -Position $r.Pos -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + + # Cleanup: firewall aliases + try { + Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + + # Cleanup: firewall IP sets + try { + Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + Disconnect-TestPve +} + +Describe 'Firewall — Integration' -Tag 'Integration' { + + Context 'Firewall — Cluster Rules' { + It 'Should create a firewall rule' { + Skip-IfNoTarget + { New-PveFirewallRule -Level Cluster -Type in -Action ACCEPT -Proto tcp -Dport '8080' -Comment 'pester-test-rule' -Enable -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list firewall rules and find the test rule' { + Skip-IfNoTarget + $rules = Get-PveFirewallRule -Level Cluster + $rules | Should -Not -BeNullOrEmpty + $testRule = $rules | Where-Object { $_.Comment -eq 'pester-test-rule' } + $testRule | Should -Not -BeNullOrEmpty + $script:FirewallTestRulePos = $testRule.Pos + } + + It 'Should update the firewall rule' { + Skip-IfNoTarget + if ($null -eq $script:FirewallTestRulePos) { + Set-ItResult -Skipped -Because 'No test rule was created' + } + { Set-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the firewall rule' { + Skip-IfNoTarget + if ($null -eq $script:FirewallTestRulePos) { + Set-ItResult -Skipped -Because 'No test rule was created' + } + { Remove-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should get firewall options' { + Skip-IfNoTarget + $opts = Get-PveFirewallOptions -Level Cluster + $opts | Should -Not -BeNullOrEmpty + } + } + + Context 'Firewall — Aliases and IP Sets' { + It 'Should create a firewall alias' { + Skip-IfNoTarget + { New-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Cidr '192.168.99.0/24' -Comment 'test alias' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list and find the alias' { + Skip-IfNoTarget + $aliases = Get-PveFirewallAlias -Level Cluster + $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty + } + + It 'Should remove the alias' { + Skip-IfNoTarget + { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create an IP set' { + Skip-IfNoTarget + { New-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Comment 'test ipset' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should add an entry to the IP set' { + Skip-IfNoTarget + { New-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Comment 'test entry' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list IP set entries' { + Skip-IfNoTarget + $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' + $entries | Should -Not -BeNullOrEmpty + ($entries | Where-Object { $_.Cidr -like '10.99.0.0*' }) | Should -Not -BeNullOrEmpty + } + + It 'Should remove the IP set entry' { + Skip-IfNoTarget + { Remove-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the IP set' { + Skip-IfNoTarget + { Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 new file mode 100644 index 0000000..26c7bcc --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 @@ -0,0 +1,63 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:BackupTestJobId = $null +} + +AfterAll { + if ($null -eq $script:SkipReason) { + try { + if ($script:BackupTestJobId) { + Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + } + Disconnect-TestPve +} + +Describe 'Backup Jobs — Integration' -Tag 'Integration' { + + Context 'Backup Jobs' { + It 'Should create a backup job' { + Skip-IfNoTarget + { New-PveBackupJob -Schedule 'sat 03:00' -Storage $script:Storage -Mode snapshot -All -Comment 'pester-test-backup' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should list backup jobs and find the test job' { + Skip-IfNoTarget + $jobs = Get-PveBackupJob + $testJob = $jobs | Where-Object { $_.Comment -eq 'pester-test-backup' } + $testJob | Should -Not -BeNullOrEmpty + $script:BackupTestJobId = $testJob.Id + } + + It 'Should update the backup job' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + { Set-PveBackupJob -Id $script:BackupTestJobId -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should remove the backup job' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + { Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should verify the backup job was removed' { + Skip-IfNoTarget + if (-not $script:BackupTestJobId) { + Set-ItResult -Skipped -Because 'No test backup job was created' + } + $jobs = Get-PveBackupJob + $testJob = $jobs | Where-Object { $_.Id -eq $script:BackupTestJobId } + $testJob | Should -BeNullOrEmpty + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 new file mode 100644 index 0000000..4537753 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 @@ -0,0 +1,43 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + # Find an existing test VM to use for task tests + $script:TestVmId = Find-TestVm +} + +AfterAll { + # Read-only tests — no cleanup needed + Disconnect-TestPve +} + +Describe 'Tasks — Integration' -Tag 'Integration' { + + Context 'Tasks' { + It 'Should get a task by UPID and wait for completion' { + if (Skip-IfNoTarget) { return } + if ($null -eq $script:TestVmId) { + Set-ItResult -Skipped -Because 'No test VM was created' + return + } + + # Start the VM to get a task object + $startResult = Start-PveVm -Node $script:Node -VmId $script:TestVmId + $startResult | Should -Not -BeNullOrEmpty + $startResult.Upid | Should -Not -BeNullOrEmpty + + # Wait for the task to complete + { Wait-PveTask -Node $script:Node -Upid $startResult.Upid } | Should -Not -Throw + + # Get the task status + $task = Get-PveTask -Node $script:Node -Upid $startResult.Upid + $task | Should -Not -BeNullOrEmpty + $task.IsSuccessful | Should -BeTrue + + # Clean up + Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 new file mode 100644 index 0000000..9fc8b1e --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 @@ -0,0 +1,194 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve +} + +AfterAll { + Disconnect-TestPve +} + +Describe 'Safety-Net Cleanup — Integration' -Tag 'Integration' { + + Context 'Resource cleanup' { + It 'Should remove all pester-* VMs' { + if (Skip-IfNoTarget) { return } + + $vms = Get-PveVm -Node $script:Node -ErrorAction SilentlyContinue + $pesterVms = $vms | Where-Object { $_.Name -like 'pester-*' } + foreach ($vm in $pesterVms) { + try { + Stop-PveVm -Node $script:Node -VmId $vm.VmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + } catch { } + try { + # Try removing as template first (templates need Remove-PveTemplate) + Remove-PveTemplate -Node $script:Node -VmId $vm.VmId -Confirm:$false -ErrorAction SilentlyContinue + } catch { + try { + Remove-PveVm -Node $script:Node -VmId $vm.VmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } + + # Verify + $remaining = Get-PveVm -Node $script:Node -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'pester-*' } + $remaining | Should -BeNullOrEmpty + } + + It 'Should remove all pester-* containers' { + if (Skip-IfNoTarget) { return } + + $containers = Get-PveContainer -Node $script:Node -ErrorAction SilentlyContinue + $pesterCts = $containers | Where-Object { $_.Name -like 'pester-*' } + foreach ($ct in $pesterCts) { + try { + Stop-PveContainer -Node $script:Node -VmId $ct.VmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + Start-Sleep -Seconds 3 + } catch { } + try { + Remove-PveContainer -Node $script:Node -VmId $ct.VmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + + # Verify + $remaining = Get-PveContainer -Node $script:Node -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'pester-*' } + $remaining | Should -BeNullOrEmpty + } + + It 'Should remove all pester-* users' { + if (Skip-IfNoTarget) { return } + + $users = Get-PveUser -ErrorAction SilentlyContinue + $pesterUsers = $users | Where-Object { $_.UserId -like 'pester-*' } + foreach ($user in $pesterUsers) { + try { + Remove-PveUser -UserId $user.UserId -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } + + It 'Should remove all pester-* roles' { + if (Skip-IfNoTarget) { return } + + $roles = Get-PveRole -ErrorAction SilentlyContinue + $pesterRoles = $roles | Where-Object { $_.RoleId -like 'pester-*' -or $_.RoleId -like 'Pester*' } + foreach ($role in $pesterRoles) { + try { + Remove-PveRole -RoleId $role.RoleId -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } + + It 'Should remove pester firewall rules, aliases, and IP sets' { + if (Skip-IfNoTarget) { return } + + # Firewall rules with pester comments + try { + $rules = Get-PveFirewallRule -Level Cluster -ErrorAction SilentlyContinue + $testRules = $rules | Where-Object { $_.Comment -like 'pester-*' -or $_.Comment -like 'pester *' } + # Remove in reverse position order to avoid position shifts + $testRules | Sort-Object -Property Pos -Descending | ForEach-Object { + Remove-PveFirewallRule -Level Cluster -Position $_.Pos -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + + # Firewall aliases + try { + $aliases = Get-PveFirewallAlias -Level Cluster -ErrorAction SilentlyContinue + $pesterAliases = $aliases | Where-Object { $_.Name -like 'pester-*' } + foreach ($alias in $pesterAliases) { + Remove-PveFirewallAlias -Level Cluster -Name $alias.Name -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + + # Firewall IP sets + try { + $ipsets = Get-PveFirewallIpSet -Level Cluster -ErrorAction SilentlyContinue + $pesterIpsets = $ipsets | Where-Object { $_.Name -like 'pester-*' } + foreach ($ipset in $pesterIpsets) { + # Remove entries first + try { + $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name $ipset.Name -ErrorAction SilentlyContinue + foreach ($entry in $entries) { + Remove-PveFirewallIpSetEntry -Level Cluster -Name $ipset.Name -Cidr $entry.Cidr -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + Remove-PveFirewallIpSet -Level Cluster -Name $ipset.Name -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + } + + It 'Should remove pester backup jobs' { + if (Skip-IfNoTarget) { return } + + try { + $jobs = Get-PveBackupJob -ErrorAction SilentlyContinue + $pesterJobs = $jobs | Where-Object { $_.Comment -like 'pester-*' -or $_.Comment -like 'pester *' } + foreach ($job in $pesterJobs) { + Remove-PveBackupJob -Id $job.Id -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + } + + It 'Should remove pester SDN resources' { + if (Skip-IfNoTarget) { return } + + # Subnets first (depend on vnets) + try { + $vnets = Get-PveSdnVnet -ErrorAction SilentlyContinue + $pesterVnets = $vnets | Where-Object { $_.Vnet -like 'pester*' } + foreach ($vnet in $pesterVnets) { + try { + $subnets = Get-PveSdnSubnet -Vnet $vnet.Vnet -ErrorAction SilentlyContinue + foreach ($subnet in $subnets) { + Remove-PveSdnSubnet -Vnet $vnet.Vnet -Subnet $subnet.Subnet -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + Start-Sleep -Seconds 2 + Remove-PveSdnVnet -Vnet $vnet.Vnet -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + + # Zones + try { + $zones = Get-PveSdnZone -ErrorAction SilentlyContinue + $pesterZones = $zones | Where-Object { $_.Zone -like 'pester*' } + foreach ($zone in $pesterZones) { + Start-Sleep -Seconds 2 + Remove-PveSdnZone -Zone $zone.Zone -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + } + + It 'Should remove pester storage definitions' { + if (Skip-IfNoTarget) { return } + + try { + $storages = Get-PveStorage -Node $script:Node -ErrorAction SilentlyContinue + $pesterStorages = $storages | Where-Object { $_.Storage -like 'pester-*' } + foreach ($storage in $pesterStorages) { + Remove-PveStorage -Storage $storage.Storage -Confirm:$false -ErrorAction SilentlyContinue + } + } catch { } + } + + It 'Should remove pester network interfaces' { + if (Skip-IfNoTarget) { return } + + try { + $networks = Get-PveNetwork -Node $script:Node -ErrorAction SilentlyContinue + $pesterNets = $networks | Where-Object { $_.Iface -like 'pester*' -or ($_.Comments -and $_.Comments -like '*pester*') } + foreach ($net in $pesterNets) { + Remove-PveNetwork -Node $script:Node -Iface $net.Iface -Confirm:$false -ErrorAction SilentlyContinue + } + if ($pesterNets) { + Invoke-PveNetworkApply -Node $script:Node -Wait -ErrorAction SilentlyContinue + } + } catch { } + } + } +} diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 deleted file mode 100644 index b92e378..0000000 --- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 +++ /dev/null @@ -1,1544 +0,0 @@ -#Requires -Module Pester -<# -.SYNOPSIS - Pester 5 integration tests for PSProxmoxVE. - - These tests are tagged 'Integration' and are SKIPPED by default. - They require a live, dedicated Proxmox VE test node and the following - environment variables to be set: - - PVETEST_HOST - Hostname or IP of the PVE test node - PVETEST_PORT - API port (usually 8006) - PVETEST_APITOKEN - API token in the format USER@REALM!TOKENID=UUID - PVETEST_NODE - PVE node name (e.g. pve-test1) - PVETEST_STORAGE - Storage pool name for disk/ISO operations (e.g. local) - PVETEST_ISO_PATH - Local filesystem path to a small .iso for upload tests - PVETEST_PVE_VERSION - (optional) Expected PVE major version (8 or 9) - PVETEST_PASSWORD - (optional) Root password for cloud-init Linux VM provisioning - PVETEST_CLOUD_IMAGE_PATH - (optional) Local path to a cloud image (.img) for VM provisioning - PVETEST_OVA_PATH - (optional) Local path to an OVA file for OVA import tests - - WARNING: These tests CREATE and DESTROY real resources (VMs, users, tokens, - roles, snapshots, ISO uploads, etc.) on the target node. - Never run against a production cluster. - - To run: - Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -Tag Integration - # or use the dev container: - ./tests/dev.ps1 integration -#> - -BeforeAll { - # ----------------------------------------------------------------------- - # Resolve and import module - # ----------------------------------------------------------------------- - . $PSScriptRoot/../_TestHelper.ps1 - - # ----------------------------------------------------------------------- - # Check required environment variables - # ----------------------------------------------------------------------- - $requiredVars = @( - 'PVETEST_HOST', - 'PVETEST_PORT', - 'PVETEST_APITOKEN', - 'PVETEST_NODE', - 'PVETEST_STORAGE', - 'PVETEST_ISO_PATH' - ) - - $script:SkipReason = $null - - foreach ($var in $requiredVars) { - if (-not [System.Environment]::GetEnvironmentVariable($var)) { - $script:SkipReason = ( - "No live Proxmox VE target configured. " + - "Set environment variables: $($requiredVars -join ', ')" - ) - break - } - } - - # Convenience accessors (only meaningful when SkipReason is null) - $script:Host_ = [System.Environment]::GetEnvironmentVariable('PVETEST_HOST') - $portEnv = [System.Environment]::GetEnvironmentVariable('PVETEST_PORT') - $script:Port = [int]$(if ($portEnv) { $portEnv } else { '8006' }) - $script:ApiToken = [System.Environment]::GetEnvironmentVariable('PVETEST_APITOKEN') - $script:Node = [System.Environment]::GetEnvironmentVariable('PVETEST_NODE') - $script:Storage = [System.Environment]::GetEnvironmentVariable('PVETEST_STORAGE') - $script:IsoPath = [System.Environment]::GetEnvironmentVariable('PVETEST_ISO_PATH') - $script:ExpectedPveVersion = [System.Environment]::GetEnvironmentVariable('PVETEST_PVE_VERSION') - $script:Password = [System.Environment]::GetEnvironmentVariable('PVETEST_PASSWORD') - $script:CloudImagePath = [System.Environment]::GetEnvironmentVariable('PVETEST_CLOUD_IMAGE_PATH') - $script:OvaPath = [System.Environment]::GetEnvironmentVariable('PVETEST_OVA_PATH') - $script:LinuxVmId = $null - - # Track resources created during the run so AfterAll can clean up. - $script:CreatedVmIds = [System.Collections.Generic.List[int]]::new() - $script:CreatedContainerIds = [System.Collections.Generic.List[int]]::new() - $script:CreatedUsers = [System.Collections.Generic.List[string]]::new() - $script:CreatedRoles = [System.Collections.Generic.List[string]]::new() - $script:TestVmId = $null - $script:TestContainerId = $null - $script:FirewallTestRulePos = $null - $script:BackupTestJobId = $null - - # Helper functions for skip logic - function script:Skip-IfNoTarget { - if ($script:SkipReason) { - Set-ItResult -Skipped -Because $script:SkipReason - return $true - } - return $false - } - - function script:Skip-IfNoTestVm { - if (Skip-IfNoTarget) { return $true } - if ($null -eq $script:TestVmId) { - Set-ItResult -Skipped -Because 'No test VM was created' - return $true - } - return $false - } - - function script:Skip-IfNoPassword { - if (Skip-IfNoTarget) { return $true } - if (-not $script:Password -or -not $script:CloudImagePath) { - Set-ItResult -Skipped -Because 'PVETEST_PASSWORD and PVETEST_CLOUD_IMAGE_PATH required for Linux VM provisioning' - return $true - } - return $false - } - - function script:Skip-IfNoLinuxVm { - if (Skip-IfNoTarget) { return $true } - if ($null -eq $script:LinuxVmId) { - Set-ItResult -Skipped -Because 'Linux VM was not provisioned (PVETEST_PASSWORD may not be set)' - return $true - } - return $false - } - - function script:Skip-IfNoTestContainer { - if (Skip-IfNoTarget) { return $true } - if ($null -eq $script:TestContainerId) { - Set-ItResult -Skipped -Because 'No test container was created' - return $true - } - return $false - } -} - -AfterAll { - if ($null -ne $script:SkipReason) { return } - - # Best-effort cleanup — remove resources created during the test run. - # Order matters: tokens/permissions removed with users, VMs last. - - foreach ($userId in $script:CreatedUsers) { - try { Remove-PveUser -UserId $userId -Confirm:$false -ErrorAction SilentlyContinue } - catch { <# non-fatal #> } - } - - foreach ($roleId in $script:CreatedRoles) { - try { Remove-PveRole -RoleId $roleId -Confirm:$false -ErrorAction SilentlyContinue } - catch { <# non-fatal #> } - } - - foreach ($ctId in $script:CreatedContainerIds) { - try { - Stop-PveContainer -Node $script:Node -VmId $ctId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 - Remove-PveContainer -Node $script:Node -VmId $ctId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue - } - catch { <# non-fatal #> } - } - - foreach ($vmId in $script:CreatedVmIds) { - try { - Stop-PveVm -Node $script:Node -VmId $vmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 - Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue - } - catch { <# non-fatal #> } - } - - # Cleanup: firewall test artifacts - try { - $rules = Get-PveFirewallRule -Level Cluster -ErrorAction SilentlyContinue - $testRules = $rules | Where-Object { $_.Comment -like 'pester-test*' } - foreach ($r in $testRules) { - Remove-PveFirewallRule -Level Cluster -Position $r.Pos -Confirm:$false -ErrorAction SilentlyContinue - } - } catch { } - - # Cleanup: firewall aliases - try { - Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction SilentlyContinue - } catch { } - - # Cleanup: firewall IP sets - try { - Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction SilentlyContinue - } catch { } - - # Cleanup: backup jobs - try { - if ($script:BackupTestJobId) { - Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction SilentlyContinue - } - } catch { } -} - -# =========================================================================== -# Integration Tests -# =========================================================================== -Describe 'Integration Tests' -Tag 'Integration' { - - # ----------------------------------------------------------------------- - Context 'Connection' { - It 'Should connect to PVE server' { - if (Skip-IfNoTarget) { return } - - $session = Connect-PveServer ` - -Server $script:Host_ ` - -Port $script:Port ` - -ApiToken $script:ApiToken ` - -SkipCertificateCheck ` - -PassThru - - $session | Should -Not -BeNullOrEmpty - $session.Hostname | Should -Be $script:Host_ - $session.AuthMode.ToString() | Should -Be 'ApiToken' - - Test-PveConnection | Should -BeTrue - } - - It 'Should detect server version' { - if (Skip-IfNoTarget) { return } - - $detail = Test-PveConnection -Detailed - $detail | Should -Not -BeNullOrEmpty - $detail.ServerVersion | Should -Not -BeNullOrEmpty - $detail.ServerVersion.Major | Should -BeIn @(8, 9) - - # If we know the expected version, verify it matches - if ($script:ExpectedPveVersion) { - $detail.ServerVersion.Major | Should -Be ([int]$script:ExpectedPveVersion) - } - } - } - - # ----------------------------------------------------------------------- - Context 'Nodes' { - It 'Should list nodes' { - if (Skip-IfNoTarget) { return } - - $nodes = Get-PveNode - $nodes | Should -Not -BeNullOrEmpty - } - - It 'Should get node status' { - if (Skip-IfNoTarget) { return } - - $status = Get-PveNodeStatus -Node $script:Node - $status | Should -Not -BeNullOrEmpty - $status.Node | Should -Be $script:Node - } - } - - # ----------------------------------------------------------------------- - Context 'User CRUD' { - It 'Should create a new user' { - if (Skip-IfNoTarget) { return } - - { New-PveUser -UserId 'pester-user@pam' -ErrorAction Stop } | - Should -Not -Throw - - $script:CreatedUsers.Add('pester-user@pam') - } - - It 'Should list users and find the new user' { - if (Skip-IfNoTarget) { return } - - $users = Get-PveUser - $users | Should -Not -BeNullOrEmpty - $users | Where-Object { $_.UserId -eq 'pester-user@pam' } | - Should -Not -BeNullOrEmpty - } - - It 'Should update user properties' { - if (Skip-IfNoTarget) { return } - - { Set-PveUser -UserId 'pester-user@pam' ` - -Comment 'Updated by Pester integration test' ` - -ErrorAction Stop } | Should -Not -Throw - - $user = Get-PveUser | Where-Object { $_.UserId -eq 'pester-user@pam' } - $user.Comment | Should -Be 'Updated by Pester integration test' - } - - It 'Should remove the user' { - if (Skip-IfNoTarget) { return } - - { Remove-PveUser -UserId 'pester-user@pam' -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - - $script:CreatedUsers.Remove('pester-user@pam') | Out-Null - - $users = Get-PveUser - $users | Where-Object { $_.UserId -eq 'pester-user@pam' } | - Should -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Role CRUD' { - It 'Should create a new role' { - if (Skip-IfNoTarget) { return } - - { New-PveRole -RoleId 'PesterTestRole' ` - -Privileges 'VM.Audit,VM.Console' ` - -ErrorAction Stop } | Should -Not -Throw - - $script:CreatedRoles.Add('PesterTestRole') - } - - It 'Should list roles and find the new role' { - if (Skip-IfNoTarget) { return } - - $roles = Get-PveRole - $roles | Should -Not -BeNullOrEmpty - $roles | Where-Object { $_.RoleId -eq 'PesterTestRole' } | - Should -Not -BeNullOrEmpty - } - - It 'Should remove the role' { - if (Skip-IfNoTarget) { return } - - { Remove-PveRole -RoleId 'PesterTestRole' -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - - $script:CreatedRoles.Remove('PesterTestRole') | Out-Null - } - } - - # ----------------------------------------------------------------------- - Context 'API Token CRUD' { - BeforeAll { - # Create a user to hold the test token - if ($null -eq $script:SkipReason) { - New-PveUser -UserId 'pester-tokenuser@pam' -ErrorAction SilentlyContinue - $script:CreatedUsers.Add('pester-tokenuser@pam') - } - } - - It 'Should create an API token' { - if (Skip-IfNoTarget) { return } - - $result = New-PveApiToken ` - -UserId 'pester-tokenuser@pam' ` - -TokenId 'pester-token' ` - -ErrorAction Stop - - $result | Should -Not -BeNullOrEmpty - } - - It 'Should list API tokens for the user' { - if (Skip-IfNoTarget) { return } - - $tokens = Get-PveApiToken -UserId 'pester-tokenuser@pam' - $tokens | Should -Not -BeNullOrEmpty - $tokens | Where-Object { $_.TokenId -eq 'pester-token' } | - Should -Not -BeNullOrEmpty - } - - It 'Should remove the API token' { - if (Skip-IfNoTarget) { return } - - { Remove-PveApiToken ` - -UserId 'pester-tokenuser@pam' ` - -TokenId 'pester-token' ` - -Confirm:$false ` - -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Permissions' { - BeforeAll { - # Create a user and role for permission testing - if ($null -eq $script:SkipReason) { - New-PveUser -UserId 'pester-permuser@pam' -ErrorAction SilentlyContinue - $script:CreatedUsers.Add('pester-permuser@pam') - } - } - - It 'Should list permissions' { - if (Skip-IfNoTarget) { return } - - { Get-PvePermission -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should set a permission' { - if (Skip-IfNoTarget) { return } - - { Set-PvePermission ` - -Path '/' ` - -Role 'PVEAuditor' ` - -UgId 'pester-permuser@pam' ` - -Type 'user' ` - -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'VMs' { - It 'Should list VMs' { - if (Skip-IfNoTarget) { return } - - # Does not assert count — the node may have zero VMs. - { Get-PveVm -Node $script:Node -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should create a test VM' { - if (Skip-IfNoTarget) { return } - - $task = New-PveVm ` - -Node $script:Node ` - -Name 'pester-test-vm' ` - -Memory 512 ` - -Cores 1 ` - -Wait - - $task | Should -Not -BeNullOrEmpty - - # Retrieve the new VM ID from the cluster. - $vm = Get-PveVm -Node $script:Node -Name 'pester-test-vm' | - Select-Object -First 1 - $vm | Should -Not -BeNullOrEmpty - - $script:TestVmId = $vm.VmId - $script:CreatedVmIds.Add($vm.VmId) - } - - It 'Should get and set VM config' { - if (Skip-IfNoTestVm) { return } - - $config = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId - $config | Should -Not -BeNullOrEmpty - - { Set-PveVmConfig ` - -Node $script:Node ` - -VmId $script:TestVmId ` - -Description 'Updated by Pester integration test' ` - -ErrorAction Stop } | Should -Not -Throw - - $updated = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId - $updated.Description | Should -Be 'Updated by Pester integration test' - } - - It 'Should start and stop a VM' { - if (Skip-IfNoTestVm) { return } - - $startTask = Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 - $startTask | Should -Not -BeNullOrEmpty - - $stopTask = Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false - $stopTask | Should -Not -BeNullOrEmpty - } - - It 'Should hard-reset a running VM' { - if (Skip-IfNoTestVm) { return } - - # Start the VM first - Start-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 | Out-Null - - # Hard reset (no ACPI — works even without guest OS) - $task = Reset-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false - $task | Should -Not -BeNullOrEmpty - - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - It 'Should clone a VM' { - if (Skip-IfNoTestVm) { return } - - # Pick a high VMID for the clone to avoid collisions - $cloneId = $script:TestVmId + 1000 - - $task = Copy-PveVm ` - -SourceNode $script:Node ` - -VmId $script:TestVmId ` - -NewVmId $cloneId ` - -NewName 'pester-clone-vm' ` - -Full ` - -Wait - - $task | Should -Not -BeNullOrEmpty - - $cloned = Get-PveVm -Node $script:Node -Name 'pester-clone-vm' | - Select-Object -First 1 - $cloned | Should -Not -BeNullOrEmpty - - $script:CreatedVmIds.Add($cloned.VmId) - } - } - - # ----------------------------------------------------------------------- - Context 'VM — Suspend / Resume / Resize' { - # Suspend/Resume is tested on the Linux VM (Guest Agent VM — Lifecycle) - # because an empty VM with no OS may not reliably transition to 'paused'. - - It 'Should resize a VM disk (Resize-PveVmDisk)' { - if (Skip-IfNoTestVm) { return } - - # First add a small disk via Set-PveVmConfig - { Set-PveVmConfig -Node $script:Node -VmId $script:TestVmId ` - -AdditionalConfig @{ scsi0 = 'local-lvm:1' } ` - -ErrorAction Stop } | Should -Not -Throw - - # Resize the disk by +1G - $task = Resize-PveVmDisk -Node $script:Node -VmId $script:TestVmId ` - -Disk 'scsi0' -Size '+1G' - $task | Should -Not -BeNullOrEmpty - - # Verify config shows scsi0 exists (larger disk) - $config = Get-PveVmConfig -Node $script:Node -VmId $script:TestVmId - $config | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Snapshots' { - It 'Should create, list, and remove a snapshot' { - if (Skip-IfNoTestVm) { return } - - # Ensure the VM is stopped for snapshot - $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } - if ($vm.Status -eq 'running') { - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - $snapName = 'pester-snap' - - # Create - $createTask = New-PveSnapshot ` - -Node $script:Node ` - -VmId $script:TestVmId ` - -Name $snapName ` - -Description 'Created by Pester integration test' ` - -Wait - - $createTask | Should -Not -BeNullOrEmpty - - # List and verify - $snapshots = Get-PveSnapshot -Node $script:Node -VmId $script:TestVmId - $snap = $snapshots | Where-Object { $_.Name -eq $snapName } - $snap | Should -Not -BeNullOrEmpty - - # Restore - { Restore-PveSnapshot ` - -Node $script:Node ` - -VmId $script:TestVmId ` - -Name $snapName ` - -Confirm:$false ` - -Wait } | Should -Not -Throw - - # Remove - $removeTask = Remove-PveSnapshot ` - -Node $script:Node ` - -VmId $script:TestVmId ` - -Name $snapName ` - -Confirm:$false ` - -Wait - - $removeTask | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Storage' { - It 'Should list storage' { - if (Skip-IfNoTarget) { return } - - $storage = Get-PveStorage -Node $script:Node - $storage | Should -Not -BeNullOrEmpty - } - - It 'Should list storage content' { - if (Skip-IfNoTarget) { return } - - { Get-PveStorageContent ` - -Node $script:Node ` - -Storage $script:Storage ` - -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should upload an ISO' { - if (Skip-IfNoTarget) { return } - - if (-not (Test-Path $script:IsoPath)) { - Set-ItResult -Skipped -Because "ISO file not found at PVETEST_ISO_PATH: $($script:IsoPath)" - return - } - - { - Send-PveFile ` - -Node $script:Node ` - -Storage $script:Storage ` - -Path $script:IsoPath ` - -ErrorAction Stop - } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Storage — CRUD' { - It 'Should create a directory storage (New-PveStorage)' { - if (Skip-IfNoTarget) { return } - - $result = New-PveStorage -Storage 'pester-store' -Type 'dir' ` - -Path '/tmp/pester-storage' -Content 'iso,vztmpl,backup' ` - -ErrorAction Stop - - $result | Should -Not -BeNullOrEmpty - } - - It 'Should list and find the new storage (Get-PveStorage)' { - if (Skip-IfNoTarget) { return } - - $storages = Get-PveStorage -Node $script:Node - $storages | Where-Object { $_.Storage -eq 'pester-store' } | - Should -Not -BeNullOrEmpty - } - - It 'Should remove the storage (Remove-PveStorage)' { - if (Skip-IfNoTarget) { return } - - { Remove-PveStorage -Storage 'pester-store' -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Storage — Download' { - It 'Should download a file from URL to storage (Invoke-PveStorageDownload)' { - if (Skip-IfNoTarget) { return } - - $templateUrl = 'http://download.proxmox.com/images/system/alpine-3.20-default_20240908_amd64.tar.xz' - $task = Invoke-PveStorageDownload -Node $script:Node -Storage $script:Storage ` - -Url $templateUrl -Filename 'alpine-3.20-default_20240908_amd64.tar.xz' ` - -ContentType 'vztmpl' -Wait -ErrorAction Stop - - $task | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Tasks' { - It 'Should get a task by UPID and wait for completion' { - if (Skip-IfNoTestVm) { return } - - # Start the VM to get a task object - $startResult = Start-PveVm -Node $script:Node -VmId $script:TestVmId - $startResult | Should -Not -BeNullOrEmpty - $startResult.Upid | Should -Not -BeNullOrEmpty - - # Wait for the task to complete - { Wait-PveTask -Node $script:Node -Upid $startResult.Upid } | Should -Not -Throw - - # Get the task status - $task = Get-PveTask -Node $script:Node -Upid $startResult.Upid - $task | Should -Not -BeNullOrEmpty - $task.IsSuccessful | Should -BeTrue - - # Clean up - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - } - - # ----------------------------------------------------------------------- - Context 'Network — Read' { - It 'Should list networks' { - if (Skip-IfNoTarget) { return } - - $networks = Get-PveNetwork -Node $script:Node - $networks | Should -Not -BeNullOrEmpty - } - - It 'Should filter by interface type' { - if (Skip-IfNoTarget) { return } - - # Filter for bridges — every PVE node has at least vmbr0 - $bridges = Get-PveNetwork -Node $script:Node -Type 'bridge' - $bridges | Should -Not -BeNullOrEmpty - $bridges | ForEach-Object { $_.Type | Should -Be 'bridge' } - } - } - - # ----------------------------------------------------------------------- - Context 'Network — CRUD' { - It 'Should create a Linux bridge' { - if (Skip-IfNoTarget) { return } - - { New-PveNetwork ` - -Node $script:Node ` - -Iface 'vmbr99' ` - -Type 'bridge' ` - -Autostart ` - -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should find the new bridge in pending changes' { - if (Skip-IfNoTarget) { return } - - $networks = Get-PveNetwork -Node $script:Node - $networks | Where-Object { $_.Iface -eq 'vmbr99' } | - Should -Not -BeNullOrEmpty - } - - It 'Should update bridge comments' { - if (Skip-IfNoTarget) { return } - - { Set-PveNetwork ` - -Node $script:Node ` - -Iface 'vmbr99' ` - -Type 'bridge' ` - -Comments 'Created by Pester integration test' ` - -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should remove the bridge' { - if (Skip-IfNoTarget) { return } - - { Remove-PveNetwork ` - -Node $script:Node ` - -Iface 'vmbr99' ` - -Confirm:$false ` - -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should revert pending changes (apply to baseline)' { - if (Skip-IfNoTarget) { return } - - # Apply reverts any pending changes back to the running config - { Invoke-PveNetworkApply ` - -Node $script:Node ` - -Wait ` - -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'SDN' { - BeforeAll { - if ($null -eq $script:SkipReason) { - # SDN requires PVE 8+. Check server version and skip if older. - $detail = Test-PveConnection -Detailed - if ($detail.ServerVersion.Major -lt 8) { - $script:SkipSdn = 'SDN requires Proxmox VE 8.0 or later' - } else { - $script:SkipSdn = $null - } - } else { - $script:SkipSdn = $script:SkipReason - } - } - - It 'Should create an SDN zone (New-PveSdnZone)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - { New-PveSdnZone -Zone 'pesterz' -Type 'simple' -ErrorAction Stop } | - Should -Not -Throw - } - - It 'Should list SDN zones (Get-PveSdnZone)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - $zones = Get-PveSdnZone - $zones | Should -Not -BeNullOrEmpty - $zones | Where-Object { $_.Zone -eq 'pesterz' } | - Should -Not -BeNullOrEmpty - } - - It 'Should create an SDN VNet (New-PveSdnVnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - { New-PveSdnVnet -Vnet 'pestervn' -Zone 'pesterz' -ErrorAction Stop } | - Should -Not -Throw - } - - It 'Should list SDN VNets (Get-PveSdnVnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - $vnets = Get-PveSdnVnet - $vnets | Should -Not -BeNullOrEmpty - $vnets | Where-Object { $_.Vnet -eq 'pestervn' } | - Should -Not -BeNullOrEmpty - } - - It 'Should create an SDN subnet (New-PveSdnSubnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - { New-PveSdnSubnet -Vnet 'pestervn' -Subnet '10.99.0.0/24' -ErrorAction Stop } | - Should -Not -Throw - } - - It 'Should list SDN subnets (Get-PveSdnSubnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - $subnets = Get-PveSdnSubnet -Vnet 'pestervn' - $subnets | Should -Not -BeNullOrEmpty - } - - It 'Should remove SDN subnet (Remove-PveSdnSubnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - # PVE stores subnet IDs in the format: {zone}-{ip}-{prefix} - { Remove-PveSdnSubnet -Vnet 'pestervn' -Subnet 'pesterz-10.99.0.0-24' ` - -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should remove SDN VNet (Remove-PveSdnVnet)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - # Allow PVE to propagate subnet removal - Start-Sleep -Seconds 3 - { Remove-PveSdnVnet -Vnet 'pestervn' -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - } - - It 'Should remove SDN zone (Remove-PveSdnZone)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - Start-Sleep -Seconds 2 - { Remove-PveSdnZone -Zone 'pesterz' -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'SDN — IPAM, DNS, Controllers' { - It 'Should list SDN IPAM plugins (Get-PveSdnIpam)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - # PVE always has a built-in 'pve' IPAM plugin - $ipams = Get-PveSdnIpam - $ipams | Should -Not -BeNullOrEmpty - ($ipams | Where-Object { $_.Type -eq 'pve' }) | Should -Not -BeNullOrEmpty - } - - It 'Should list SDN DNS plugins (Get-PveSdnDns)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - # Zero DNS plugins is acceptable; just verify no throw. - { Get-PveSdnDns -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should list SDN controllers (Get-PveSdnController)' { - if (Skip-IfNoTarget) { return } - if ($script:SkipSdn) { Set-ItResult -Skipped -Because $script:SkipSdn; return } - - # Zero controllers is acceptable; just verify no throw. - { Get-PveSdnController -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Templates' { - It 'Should list templates' { - if (Skip-IfNoTarget) { return } - - # Zero templates is acceptable; just verify the cmdlet does not throw. - { Get-PveTemplate -Node $script:Node -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Cloud-Init' { - It 'Should get cloud-init config' { - if (Skip-IfNoTestVm) { return } - - # Get-PveCloudInitConfig should not throw even if the VM has no cloud-init drive. - { Get-PveCloudInitConfig -Node $script:Node -VmId $script:TestVmId -ErrorAction Stop } | - Should -Not -Throw - } - - It 'Should regenerate cloud-init image (Invoke-PveCloudInitRegenerate)' { - if (Skip-IfNoLinuxVm) { return } - - # The Linux VM has a cloud-init drive from provisioning - { Invoke-PveCloudInitRegenerate -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction Stop } | - Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Containers' { - BeforeAll { - if ($null -eq $script:SkipReason) { - # Download a small Alpine LXC template - $templateUrl = 'http://download.proxmox.com/images/system/alpine-3.20-default_20240908_amd64.tar.xz' - try { - Invoke-PveStorageDownload -Node $script:Node -Storage $script:Storage ` - -Url $templateUrl -Filename 'alpine-3.20-default_20240908_amd64.tar.xz' ` - -ContentType 'vztmpl' -Wait -ErrorAction Stop - } catch { - # Template may already exist from a previous run or the Storage — Download context - } - } - $script:TestContainerId = $null - } - - It 'Should list containers (Get-PveContainer)' { - if (Skip-IfNoTarget) { return } - - # Zero containers is acceptable; just verify the cmdlet does not throw. - { Get-PveContainer -Node $script:Node -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should create a container (New-PveContainer)' { - if (Skip-IfNoTarget) { return } - - $securePassword = ConvertTo-SecureString 'Pester12345!' -AsPlainText -Force - - $task = New-PveContainer ` - -Node $script:Node ` - -Hostname 'pester-ct' ` - -Memory 128 ` - -Cores 1 ` - -RootFsStorage 'local-lvm' ` - -RootFsSize '1' ` - -OsTemplate "$($script:Storage):vztmpl/alpine-3.20-default_20240908_amd64.tar.xz" ` - -Password $securePassword ` - -Bridge 'vmbr0' ` - -Wait - - $task | Should -Not -BeNullOrEmpty - - # Retrieve the new container ID from the cluster - $ct = Get-PveContainer -Node $script:Node -Name 'pester-ct' | - Select-Object -First 1 - $ct | Should -Not -BeNullOrEmpty - - $script:TestContainerId = $ct.VmId - $script:CreatedContainerIds.Add($ct.VmId) - } - - It 'Should get container config (Get-PveContainerConfig)' { - if (Skip-IfNoTestContainer) { return } - - $config = Get-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId - $config | Should -Not -BeNullOrEmpty - } - - It 'Should update container config (Set-PveContainerConfig)' { - if (Skip-IfNoTestContainer) { return } - - { Set-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId ` - -Description 'Updated by Pester integration test' ` - -ErrorAction Stop } | Should -Not -Throw - - $config = Get-PveContainerConfig -Node $script:Node -VmId $script:TestContainerId - $config.Description.Trim() | Should -Be 'Updated by Pester integration test' - } - - It 'Should start a container (Start-PveContainer)' { - if (Skip-IfNoTestContainer) { return } - - $task = Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 - $task | Should -Not -BeNullOrEmpty - - $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId - $ct.Status | Should -Be 'running' - } - - It 'Should stop a container (Stop-PveContainer)' { - if (Skip-IfNoTestContainer) { return } - - $task = Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false - $task | Should -Not -BeNullOrEmpty - - $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId - $ct.Status | Should -Be 'stopped' - } - - It 'Should restart a container (Restart-PveContainer)' { - if (Skip-IfNoTestContainer) { return } - - # Start first so we can restart - Start-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 | Out-Null - - $task = Restart-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false - $task | Should -Not -BeNullOrEmpty - - $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId - $ct.Status | Should -Be 'running' - - # Stop for subsequent tests - Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - It 'Should clone a container (Copy-PveContainer)' { - if (Skip-IfNoTestContainer) { return } - - $cloneId = $script:TestContainerId + 1000 - - $task = Copy-PveContainer ` - -SourceNode $script:Node ` - -VmId $script:TestContainerId ` - -NewVmId $cloneId ` - -NewName 'pester-clone-ct' ` - -Full ` - -Wait - - $task | Should -Not -BeNullOrEmpty - - $cloned = Get-PveContainer -Node $script:Node -Name 'pester-clone-ct' | - Select-Object -First 1 - $cloned | Should -Not -BeNullOrEmpty - - $script:CreatedContainerIds.Add($cloned.VmId) - } - } - - # ----------------------------------------------------------------------- - Context 'Container Snapshots' { - It 'Should create a container snapshot (New-PveContainerSnapshot)' { - if (Skip-IfNoTestContainer) { return } - - # Ensure container is stopped - $ct = Get-PveContainer -Node $script:Node -VmId $script:TestContainerId - if ($ct.Status -eq 'running') { - Stop-PveContainer -Node $script:Node -VmId $script:TestContainerId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - $task = New-PveContainerSnapshot ` - -Node $script:Node ` - -VmId $script:TestContainerId ` - -Name 'pester-ct-snap' ` - -Description 'Created by Pester integration test' ` - -Wait - - $task | Should -Not -BeNullOrEmpty - } - - It 'Should list container snapshots (Get-PveContainerSnapshot)' { - if (Skip-IfNoTestContainer) { return } - - $snapshots = Get-PveContainerSnapshot -Node $script:Node -VmId $script:TestContainerId - $snap = $snapshots | Where-Object { $_.Name -eq 'pester-ct-snap' } - $snap | Should -Not -BeNullOrEmpty - } - - It 'Should restore a container snapshot (Restore-PveContainerSnapshot)' { - if (Skip-IfNoTestContainer) { return } - - { Restore-PveContainerSnapshot ` - -Node $script:Node ` - -VmId $script:TestContainerId ` - -Name 'pester-ct-snap' ` - -Confirm:$false ` - -Wait } | Should -Not -Throw - } - - It 'Should remove a container snapshot (Remove-PveContainerSnapshot)' { - if (Skip-IfNoTestContainer) { return } - - $task = Remove-PveContainerSnapshot ` - -Node $script:Node ` - -VmId $script:TestContainerId ` - -Name 'pester-ct-snap' ` - -Confirm:$false ` - -Wait - - $task | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Linux VM — Provisioning' { - It 'Should upload cloud image to PVE storage (Send-PveFile)' { - if (Skip-IfNoPassword) { return } - - $task = Send-PveFile ` - -Node $script:Node -Storage $script:Storage ` - -Path $script:CloudImagePath ` - -ContentType 'import' -Wait - - $task | Should -Not -BeNullOrEmpty - $task.IsSuccessful | Should -BeTrue - } - - It 'Should create a Linux VM (New-PveVm)' { - if (Skip-IfNoPassword) { return } - - $task = New-PveVm ` - -Node $script:Node ` - -Name 'pester-linux-vm' ` - -Memory 512 -Cores 1 -OsType 'l26' -Wait - - $task | Should -Not -BeNullOrEmpty - - $vm = Get-PveVm -Node $script:Node -Name 'pester-linux-vm' | - Select-Object -First 1 - $vm | Should -Not -BeNullOrEmpty - - $script:LinuxVmId = $vm.VmId - $script:CreatedVmIds.Add($vm.VmId) - } - - It 'Should import cloud image disk (Import-PveVmDisk)' { - if (Skip-IfNoLinuxVm) { return } - - $cloudImageFilename = [System.IO.Path]::GetFileName($script:CloudImagePath) - $task = Import-PveVmDisk ` - -Node $script:Node -VmId $script:LinuxVmId ` - -Disk 'scsi0' -TargetStorage 'local-lvm' ` - -Source "$($script:Storage):import/$cloudImageFilename" ` - -Wait - - $task | Should -Not -BeNullOrEmpty - $task.IsSuccessful | Should -BeTrue - } - - It 'Should configure VM hardware (Set-PveVmConfig)' { - if (Skip-IfNoLinuxVm) { return } - - { Set-PveVmConfig -Node $script:Node -VmId $script:LinuxVmId ` - -AdditionalConfig @{ - scsihw = 'virtio-scsi-single' - boot = 'order=scsi0' - serial0 = 'socket' - agent = '1' - net0 = 'virtio,bridge=vmbr0' - ide2 = 'local-lvm:cloudinit' - cicustom = "user=$($script:Storage):snippets/test-vm-userdata.yml" - } -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should set cloud-init config (Set-PveCloudInitConfig)' { - if (Skip-IfNoLinuxVm) { return } - - { Set-PveCloudInitConfig -Node $script:Node -VmId $script:LinuxVmId ` - -CiUser 'root' ` - -Password (ConvertTo-SecureString $script:Password -AsPlainText -Force) ` - -IpConfig0 'ip=dhcp' ` - -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should start the Linux VM (Start-PveVm)' { - if (Skip-IfNoLinuxVm) { return } - - $task = Start-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 - $task | Should -Not -BeNullOrEmpty - } - - It 'Should wait for guest agent (Test-PveVmGuestAgent)' { - if (Skip-IfNoLinuxVm) { return } - - $timeout = 300; $elapsed = 0 - $agentReady = $false - while ($elapsed -lt $timeout) { - if (Test-PveVmGuestAgent -Node $script:Node -VmId $script:LinuxVmId) { - $agentReady = $true - break - } - Start-Sleep -Seconds 10 - $elapsed += 10 - } - - $agentReady | Should -BeTrue -Because "Guest agent should respond within ${timeout}s" - } - } - - # ----------------------------------------------------------------------- - Context 'Guest Agent — Cmdlets' { - It 'Should ping guest agent (Test-PveVmGuestAgent)' { - if (Skip-IfNoLinuxVm) { return } - - $result = Test-PveVmGuestAgent -Node $script:Node -VmId $script:LinuxVmId - $result | Should -BeTrue - } - - It 'Should discover guest network interfaces (Get-PveVmGuestNetwork)' { - if (Skip-IfNoLinuxVm) { return } - - $interfaces = Get-PveVmGuestNetwork -Node $script:Node -VmId $script:LinuxVmId - $interfaces | Should -Not -BeNullOrEmpty - - # Should have at least one interface with an IPv4 address - $withIp = $interfaces | Where-Object { - $_.IpAddresses | Where-Object { $_.Type -eq 'ipv4' -and $_.Address -ne '127.0.0.1' } - } - $withIp | Should -Not -BeNullOrEmpty - } - - It 'Should execute a command in guest (Invoke-PveVmGuestExec)' { - if (Skip-IfNoLinuxVm) { return } - - $result = Invoke-PveVmGuestExec -Node $script:Node -VmId $script:LinuxVmId -Command 'hostname' - $result | Should -Not -BeNullOrEmpty - $result.ExitCode | Should -Be 0 - $result.Stdout | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Guest Agent VM — Lifecycle' { - It 'Should have a running Linux VM with guest agent' { - if (Skip-IfNoLinuxVm) { return } - - $vm = Get-PveVm -Node $script:Node | - Where-Object { $_.VmId -eq $script:LinuxVmId } - $vm | Should -Not -BeNullOrEmpty - $vm.Status | Should -Be 'running' - } - - It 'Should suspend and resume a running VM (Suspend-PveVm / Resume-PveVm)' { - if (Skip-IfNoLinuxVm) { return } - - # Suspend — -Wait -Timeout polls qmpstatus until 'paused' - $suspendTask = Suspend-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false - $suspendTask | Should -Not -BeNullOrEmpty - - # Verify with -Detailed (fetches qmpstatus from status/current endpoint) - $vm = Get-PveVm -Node $script:Node -VmId $script:LinuxVmId -Detailed - $vm.EffectiveStatus | Should -Be 'paused' - - # Resume — -Wait -Timeout polls qmpstatus until 'running' - $resumeTask = Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 - $resumeTask | Should -Not -BeNullOrEmpty - - $vm = Get-PveVm -Node $script:Node -VmId $script:LinuxVmId -Detailed - $vm.EffectiveStatus | Should -Be 'running' - } - - It 'Should gracefully restart a VM via ACPI (Restart-PveVm)' { - if (Skip-IfNoLinuxVm) { return } - - # Ensure VM is running (resume if paused from a failed suspend test) - try { Resume-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 10 -ErrorAction SilentlyContinue | Out-Null } - catch { <# already running #> } - - $task = Restart-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 60 -Confirm:$false - $task | Should -Not -BeNullOrEmpty - - $vm = Get-PveVm -Node $script:Node | - Where-Object { $_.VmId -eq $script:LinuxVmId } - $vm.Status | Should -Be 'running' - } - - It 'Should gracefully stop a VM via ACPI (Stop-PveVm)' { - if (Skip-IfNoLinuxVm) { return } - - $task = Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false - $task | Should -Not -BeNullOrEmpty - - $vm = Get-PveVm -Node $script:Node | - Where-Object { $_.VmId -eq $script:LinuxVmId } - $vm.Status | Should -Be 'stopped' - } - } - - # ----------------------------------------------------------------------- - Context 'OVA Import' { - It 'Should parse OVF metadata from an OVA (client-side)' { - if (Skip-IfNoTarget) { return } - if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) { - Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found' - return - } - - $metadata = [PSProxmoxVE.Core.Models.Vms.OvfMetadata]::FromOva($script:OvaPath) - $metadata | Should -Not -BeNullOrEmpty - $metadata.Name | Should -Not -BeNullOrEmpty - $metadata.CpuCount | Should -BeGreaterThan 0 - $metadata.MemoryMB | Should -BeGreaterThan 0 - $metadata.Disks.Count | Should -BeGreaterThan 0 - } - - It 'Should import OVA as a VM with correct metadata (Import-PveOva)' { - if (Skip-IfNoTarget) { return } - if (-not $script:OvaPath -or -not (Test-Path $script:OvaPath)) { - Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH not set or file not found' - return - } - - $vm = Import-PveOva -Node $script:Node -Storage $script:Storage ` - -Path $script:OvaPath -TargetStorage 'local-lvm' ` - -Name 'pester-ova-vm' -Wait - - $vm | Should -Not -BeNullOrEmpty - $script:CreatedVmIds.Add($vm.VmId) - - # Verify VM exists with correct name - $found = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' | Select-Object -First 1 - $found | Should -Not -BeNullOrEmpty - $found.Name | Should -Be 'pester-ova-vm' - - # Verify config matches OVF metadata - $config = Get-PveVmConfig -Node $script:Node -VmId $vm.VmId - $config.Memory | Should -BeGreaterThan 0 - $config.Cores | Should -BeGreaterThan 0 - $config.OsType | Should -Be 'l26' - $config.Scsi0 | Should -Not -BeNullOrEmpty -Because 'disk should be imported to scsi0' - $config.Net0 | Should -Not -BeNullOrEmpty -Because 'network adapter should be configured' - } - - It 'Should start the OVA-imported VM' { - if (Skip-IfNoTarget) { return } - - $ovaVm = Get-PveVm -Node $script:Node -Name 'pester-ova-vm' -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $ovaVm) { - Set-ItResult -Skipped -Because 'OVA VM was not imported' - return - } - - $task = Start-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 60 - $task | Should -Not -BeNullOrEmpty - - $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $ovaVm.VmId } - $vm.Status | Should -Be 'running' - - # Clean up - Stop-PveVm -Node $script:Node -VmId $ovaVm.VmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - } - - # ----------------------------------------------------------------------- - Context 'Templates — Convert and Clone' { - It 'Should convert the Linux VM to a template' { - if (Skip-IfNoLinuxVm) { return } - - # Ensure stopped first - $vm = Get-PveVm -Node $script:Node | - Where-Object { $_.VmId -eq $script:LinuxVmId } - if ($vm.Status -eq 'running') { - Stop-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - { New-PveTemplate -Node $script:Node -VmId $script:LinuxVmId -Confirm:$false -ErrorAction Stop } | - Should -Not -Throw - - # Allow a moment for the template flag to propagate - Start-Sleep -Seconds 3 - - # Verify it appears as a template - $templates = Get-PveTemplate -Node $script:Node - $templates | Where-Object { $_.VmId -eq $script:LinuxVmId } | - Should -Not -BeNullOrEmpty - } - - It 'Should clone a VM from the template (New-PveVmFromTemplate)' { - if (Skip-IfNoLinuxVm) { return } - - $cloneId = $script:LinuxVmId + 1000 - - $task = New-PveVmFromTemplate ` - -TemplateNode $script:Node ` - -VmId $script:LinuxVmId ` - -NewVmId $cloneId ` - -NewName 'pester-from-template' ` - -Full ` - -Wait - - $task | Should -Not -BeNullOrEmpty - - $cloned = Get-PveVm -Node $script:Node -Name 'pester-from-template' | - Select-Object -First 1 - $cloned | Should -Not -BeNullOrEmpty - - # Track for cleanup - $script:CreatedVmIds.Add($cloned.VmId) - } - - It 'Should remove a template (Remove-PveTemplate)' { - if (Skip-IfNoLinuxVm) { return } - - { Remove-PveTemplate -Node $script:Node -VmId $script:LinuxVmId ` - -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - - $script:CreatedVmIds.Remove($script:LinuxVmId) | Out-Null - $script:LinuxVmId = $null - } - } - - # ----------------------------------------------------------------------- - Context 'VM Cleanup' { - It 'Should remove a VM' { - if (Skip-IfNoTestVm) { return } - - # Ensure stopped - $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } - if ($vm.Status -eq 'running') { - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } - - { Remove-PveVm ` - -Node $script:Node ` - -VmId $script:TestVmId ` - -Force ` - -Purge ` - -Confirm:$false ` - -ErrorAction Stop } | Should -Not -Throw - - $script:CreatedVmIds.Remove($script:TestVmId) | Out-Null - $script:TestVmId = $null - } - } - - # ----------------------------------------------------------------------- - Context 'Firewall — Cluster Rules' { - It 'Should create a firewall rule' { - Skip-IfNoTarget - { New-PveFirewallRule -Level Cluster -Type in -Action ACCEPT -Proto tcp -Dport '8080' -Comment 'pester-test-rule' -Enable -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should list firewall rules and find the test rule' { - Skip-IfNoTarget - $rules = Get-PveFirewallRule -Level Cluster - $rules | Should -Not -BeNullOrEmpty - $testRule = $rules | Where-Object { $_.Comment -eq 'pester-test-rule' } - $testRule | Should -Not -BeNullOrEmpty - $script:FirewallTestRulePos = $testRule.Pos - } - - It 'Should update the firewall rule' { - Skip-IfNoTarget - if ($null -eq $script:FirewallTestRulePos) { - Set-ItResult -Skipped -Because 'No test rule was created' - } - { Set-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should remove the firewall rule' { - Skip-IfNoTarget - if ($null -eq $script:FirewallTestRulePos) { - Set-ItResult -Skipped -Because 'No test rule was created' - } - { Remove-PveFirewallRule -Level Cluster -Position $script:FirewallTestRulePos -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should get firewall options' { - Skip-IfNoTarget - $opts = Get-PveFirewallOptions -Level Cluster - $opts | Should -Not -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Backup Jobs' { - It 'Should create a backup job' { - Skip-IfNoTarget - { New-PveBackupJob -Schedule 'sat 03:00' -Storage $script:Storage -Mode snapshot -All -Comment 'pester-test-backup' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should list backup jobs and find the test job' { - Skip-IfNoTarget - $jobs = Get-PveBackupJob - $testJob = $jobs | Where-Object { $_.Comment -eq 'pester-test-backup' } - $testJob | Should -Not -BeNullOrEmpty - $script:BackupTestJobId = $testJob.Id - } - - It 'Should update the backup job' { - Skip-IfNoTarget - if (-not $script:BackupTestJobId) { - Set-ItResult -Skipped -Because 'No test backup job was created' - } - { Set-PveBackupJob -Id $script:BackupTestJobId -Comment 'pester-test-updated' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should remove the backup job' { - Skip-IfNoTarget - if (-not $script:BackupTestJobId) { - Set-ItResult -Skipped -Because 'No test backup job was created' - } - { Remove-PveBackupJob -Id $script:BackupTestJobId -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should verify the backup job was removed' { - Skip-IfNoTarget - if (-not $script:BackupTestJobId) { - Set-ItResult -Skipped -Because 'No test backup job was created' - } - $jobs = Get-PveBackupJob - $testJob = $jobs | Where-Object { $_.Id -eq $script:BackupTestJobId } - $testJob | Should -BeNullOrEmpty - } - } - - # ----------------------------------------------------------------------- - Context 'Firewall — Aliases and IP Sets' { - It 'Should create a firewall alias' { - Skip-IfNoTarget - { New-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Cidr '192.168.99.0/24' -Comment 'test alias' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should list and find the alias' { - Skip-IfNoTarget - $aliases = Get-PveFirewallAlias -Level Cluster - $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty - } - - It 'Should remove the alias' { - Skip-IfNoTarget - { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should create an IP set' { - Skip-IfNoTarget - { New-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Comment 'test ipset' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should add an entry to the IP set' { - Skip-IfNoTarget - { New-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Comment 'test entry' -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should list IP set entries' { - Skip-IfNoTarget - $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' - $entries | Should -Not -BeNullOrEmpty - ($entries | Where-Object { $_.Cidr -like '10.99.0.0*' }) | Should -Not -BeNullOrEmpty - } - - It 'Should remove the IP set entry' { - Skip-IfNoTarget - { Remove-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should remove the IP set' { - Skip-IfNoTarget - { Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw - } - } - - # ----------------------------------------------------------------------- - Context 'Disconnect' { - It 'Should disconnect from PVE server (Disconnect-PveServer)' { - if (Skip-IfNoTarget) { return } - - { Disconnect-PveServer -ErrorAction Stop } | Should -Not -Throw - } - - It 'Should fail commands after disconnect' { - if (Skip-IfNoTarget) { return } - - { Get-PveNode -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' - } - } -} diff --git a/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 new file mode 100644 index 0000000..bf4b566 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 @@ -0,0 +1,187 @@ +<# +.SYNOPSIS + Shared setup helper for integration tests. + Dot-source this in each integration test file's BeforeAll block. + +.DESCRIPTION + Provides: + - Module loading via _TestHelper.ps1 + - Environment variable reading (PVETEST_*) + - Skip helper functions + - Connect-TestPve: establishes a credential-based connection if needed + - Resource discovery helpers for cross-file test dependencies +#> + +# Load the module +. $PSScriptRoot/../_TestHelper.ps1 + +# ── Environment variables ──────────────────────────────────────────────── +$script:Host_ = $env:PVETEST_HOST +$script:Port = if ($env:PVETEST_PORT) { [int]$env:PVETEST_PORT } else { 8006 } +$script:Node = $env:PVETEST_NODE +$script:Password = $env:PVETEST_PASSWORD +$script:Storage = $env:PVETEST_STORAGE +$script:IsoPath = $env:PVETEST_ISO_PATH +$script:PveVersion = $env:PVETEST_PVE_VERSION +$script:CloudImagePath = $env:PVETEST_CLOUD_IMAGE_PATH +$script:OvaPath = $env:PVETEST_OVA_PATH + +# Multi-node (cluster tests) +$script:HostB = $env:PVETEST_HOST_B +$script:PasswordB = $env:PVETEST_PASSWORD_B # Falls back to Password if not set +if (-not $script:PasswordB) { $script:PasswordB = $script:Password } + +# ── Skip reason ────────────────────────────────────────────────────────── +$script:SkipReason = $null + +$requiredVars = @('PVETEST_HOST', 'PVETEST_NODE', 'PVETEST_PASSWORD') +foreach ($var in $requiredVars) { + if (-not [System.Environment]::GetEnvironmentVariable($var)) { + $script:SkipReason = "Missing required env var: $var (need: $($requiredVars -join ', '))" + break + } +} + +# ── Skip helpers ───────────────────────────────────────────────────────── + +function script:Skip-IfNoTarget { + if ($script:SkipReason) { + Set-ItResult -Skipped -Because $script:SkipReason + return $true + } + return $false +} + +function script:Skip-IfNoStorage { + if (Skip-IfNoTarget) { return $true } + if (-not $script:Storage -or -not $script:IsoPath) { + Set-ItResult -Skipped -Because 'PVETEST_STORAGE and PVETEST_ISO_PATH required' + return $true + } + return $false +} + +function script:Skip-IfNoPassword { + if (Skip-IfNoTarget) { return $true } + if (-not $script:Password -or -not $script:CloudImagePath) { + Set-ItResult -Skipped -Because 'PVETEST_PASSWORD and PVETEST_CLOUD_IMAGE_PATH required' + return $true + } + return $false +} + +function script:Skip-IfNoOva { + if (Skip-IfNoTarget) { return $true } + if (-not $script:OvaPath) { + Set-ItResult -Skipped -Because 'PVETEST_OVA_PATH required' + return $true + } + return $false +} + +function script:Skip-IfNoNodeB { + if (Skip-IfNoTarget) { return $true } + if (-not $script:HostB -or -not $script:Password) { + Set-ItResult -Skipped -Because 'Multi-node env vars not set (PVETEST_HOST_B, PVETEST_PASSWORD)' + return $true + } + return $false +} + +function script:Skip-IfNoPve9 { + if (Skip-IfNoTarget) { return $true } + if ($script:PveVersion -and [int]$script:PveVersion -lt 9) { + Set-ItResult -Skipped -Because 'Requires PVE 9.0+' + return $true + } + # If version not set, try to detect + if (-not $script:PveVersion) { + try { + $detail = Test-PveConnection -Detailed -ErrorAction Stop + if ($detail.ServerVersion.Major -lt 9) { + Set-ItResult -Skipped -Because "PVE version $($detail.ServerVersion) < 9.0" + return $true + } + } catch { + Set-ItResult -Skipped -Because 'Cannot determine PVE version' + return $true + } + } + return $false +} + +# ── Connection helper ──────────────────────────────────────────────────── + +function script:Connect-TestPve { + <# + .SYNOPSIS + Connects to the test PVE server using root@pam credentials. + Reuses existing connection if already connected to the same host. + #> + if ($script:SkipReason) { return } + + # Check if we're already connected to the right host + try { + if (Test-PveConnection) { + $detail = Test-PveConnection -Detailed + if ($detail.Hostname -eq $script:Host_) { return } + } + } catch { } + + # Connect with credentials (not API token) — all cluster/privileged ops work + $secPw = ConvertTo-SecureString $script:Password -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential('root@pam', $secPw) + Connect-PveServer ` + -Server $script:Host_ ` + -Port $script:Port ` + -Credential $cred ` + -SkipCertificateCheck +} + +function script:Disconnect-TestPve { + <# + .SYNOPSIS + Disconnects the test PVE session if one was established by this file. + #> + try { Disconnect-PveServer -ErrorAction SilentlyContinue } catch { } +} + +# ── Resource discovery helpers ─────────────────────────────────────────── +# These allow later test files to find resources created by earlier files +# without sharing script-scoped variables. If the resource doesn't exist, +# the caller gets $null and should skip. + +function script:Find-TestVm { + <# + .SYNOPSIS + Finds the pester-test-vm by checking env state or querying PVE. + #> + param([string]$Name = 'pester-test-vm') + + # Check env var first (set by the file that created it) + $envKey = "PVETEST_STATE_$($Name.Replace('-','_').ToUpper())" + $vmId = [System.Environment]::GetEnvironmentVariable($envKey) + if ($vmId) { return [int]$vmId } + + # Fall back to querying PVE + try { + $vms = Get-PveVm -Node $script:Node -ErrorAction Stop + $match = $vms | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 + if ($match) { return [int]$match.VmId } + } catch { } + + return $null +} + +function script:Register-TestResource { + <# + .SYNOPSIS + Registers a resource VMID in env for cross-file discovery. + #> + param( + [string]$Name, + [int]$VmId + ) + $envKey = "PVETEST_STATE_$($Name.Replace('-','_').ToUpper())" + [System.Environment]::SetEnvironmentVariable($envKey, $VmId.ToString()) +} From f62e02e2a2875f9d6f8f686647e179f307730e79 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 11:53:24 -0500 Subject: [PATCH 02/26] refactor: redesign dev.ps1 with switch-based CLI and -Tests filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace positional $Command parameter with switches that can be combined: -Provision -Integration -Cleanup -DockerHost 172.16.40.113 New -Tests parameter filters integration tests by area name: -Tests Connection,VMs,Snapshots New -Version parameter (alias for old PveVersion): -Version 9 Integration without Provision checks for config.json and errors if environment is not ready. Actions execute in logical order: Stop → Rebuild → Shell → Build → Test → Provision → Integration → Cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 250 ++++++++++++++++++++++++++------------------------ 1 file changed, 131 insertions(+), 119 deletions(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index d08c13a..1ce3361 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -7,87 +7,111 @@ Manages Docker-based dev containers for building, testing, and running integration tests for PSProxmoxVE. Works on Windows, macOS, and Linux. + Use switches to compose actions: -Provision -Integration -Cleanup can + be combined in a single invocation. + For x86-only commands (integration, provision, cleanup), use -DockerHost to run containers on a remote Docker host via SSH. The script syncs the repo to the remote host automatically. -.PARAMETER Command - The action to perform: - shell - Open pwsh in the dev container (default) - build - Build the module inside the container - test - Run unit tests (ARM + x86) - integration - Provision nested PVE VMs, run integration tests, cleanup (x86 only) - provision - Provision nested PVE VMs only, no tests (x86 only) - cleanup - Destroy provisioned VMs (x86 only) - stop - Stop all containers - rebuild - Rebuild container image(s) +.PARAMETER Shell + Open an interactive pwsh shell in the dev container. -.PARAMETER PveVersion +.PARAMETER Build + Build the module inside the container. + +.PARAMETER Test + Run unit tests (Pester, excluding Integration tag). + +.PARAMETER Provision + Provision nested PVE VMs (x86 only). Required before -Integration + unless VMs are already running. + +.PARAMETER Integration + Run integration tests against provisioned PVE VMs. + +.PARAMETER Cleanup + Destroy provisioned VMs (x86 only). + +.PARAMETER Stop + Stop all containers. + +.PARAMETER Rebuild + Rebuild container images from scratch. + +.PARAMETER Tests + Filter integration tests by area name. Comma-separated list of test + area names that match the numbered file prefixes. Examples: + -Tests Connection,Nodes # runs 00_Connection + 01_Nodes + -Tests VMs,Snapshots # runs 06_VMs + 07_Snapshots + -Tests Cluster # runs 16_Cluster + When omitted, all integration test files are run. + +.PARAMETER Version PVE version to test against (8, 9, or all). Default: all. - Only used with 'integration' and 'provision' commands. - -.PARAMETER NoCleanup - When used with 'integration', skips cleanup after tests complete. - Nested PVE VMs are left running for inspection or re-testing. - Run './tests/dev.ps1 cleanup' to destroy them later. .PARAMETER DockerHost - SSH destination for a remote Docker host (e.g. user@runner-vm). - When set, the repo is rsynced to the remote host and all Docker - commands run against the remote daemon. Useful for running x86 - containers from an ARM Mac. + SSH destination for a remote Docker host (e.g. 172.16.40.113). - Prerequisites on the remote host: - - Docker installed - - SSH user in the 'docker' group (sudo usermod -aG docker $USER) - - SSH key-based auth from the local machine +.PARAMETER NoCleanup + When used with -Integration, skips cleanup after tests complete. .EXAMPLE - ./tests/dev.ps1 + ./tests/dev.ps1 -Shell # Opens a pwsh shell in the dev container .EXAMPLE - ./tests/dev.ps1 test + ./tests/dev.ps1 -Build -Test # Builds the module and runs unit tests .EXAMPLE - ./tests/dev.ps1 integration -DockerHost user@runner-vm - # Syncs repo to runner-vm, provisions nested PVE, runs tests, cleans up + ./tests/dev.ps1 -Provision -Integration -Cleanup -DockerHost 172.16.40.113 + # Full lifecycle: provision, test, cleanup on remote host .EXAMPLE - ./tests/dev.ps1 integration 9 -DockerHost user@runner-vm - # Same but only for PVE 9 + ./tests/dev.ps1 -Integration -Tests Connection,VMs -Version 9 -DockerHost 172.16.40.113 + # Run only Connection and VMs integration tests for PVE 9 + +.EXAMPLE + ./tests/dev.ps1 -Provision -Integration -Tests Cluster,HA -Version 9 -Cleanup -DockerHost 172.16.40.113 + # Provision, run cluster+HA tests for PVE 9, cleanup #> [CmdletBinding()] param( - [Parameter(Position = 0)] - [ValidateSet('shell', 'build', 'test', 'integration', 'provision', 'cleanup', 'stop', 'rebuild')] - [string] $Command = 'shell', + [switch] $Shell, + [switch] $Build, + [switch] $Test, + [switch] $Provision, + [switch] $Integration, + [switch] $Cleanup, + [switch] $Stop, + [switch] $Rebuild, - [Parameter(Position = 1)] - [string] $PveVersion = 'all', + [string[]] $Tests, + + [Alias('PveVersion')] + [string] $Version = 'all', - [Parameter()] [string] $DockerHost, - [Parameter()] [Alias('k')] [switch] $NoCleanup ) $ErrorActionPreference = 'Stop' +# If no switches specified, default to -Shell +$anySwitchSet = $Shell -or $Build -or $Test -or $Provision -or $Integration -or $Cleanup -or $Stop -or $Rebuild +if (-not $anySwitchSet) { + $Shell = $true +} + # Resolve repo root (parent of tests/) $RepoRoot = Split-Path -Parent $PSScriptRoot Push-Location $RepoRoot try { # ── Remote Docker host support ──────────────────────────────────────── -# When -DockerHost is specified, we rsync the repo (including .env.test) -# to the remote host and set DOCKER_HOST so all docker/compose commands -# execute on the remote daemon. The compose file's volume mount (..:/repo) -# and build context reference the remote copy. - $RemoteRepoPath = $null if ($DockerHost) { @@ -95,11 +119,9 @@ if ($DockerHost) { $env:DOCKER_HOST = "ssh://$DockerHost" Write-Host "Syncing repo to ${DockerHost}:${RemoteRepoPath}..." - # Create remote directory ssh $DockerHost "mkdir -p $RemoteRepoPath" if ($LASTEXITCODE -ne 0) { throw "Failed to create remote directory" } - # Rsync repo to remote host, excluding build artifacts rsync -az --delete ` --exclude 'bin/' ` --exclude 'obj/' ` @@ -114,19 +136,12 @@ if ($DockerHost) { Write-Host "Using remote Docker host: $DockerHost" } -# When running remotely, docker compose reads the compose file locally but -# volume mounts resolve on the remote host. We generate a temporary override -# file that remaps volume mounts to the rsynced remote path. $ComposeFile = 'tests/docker-compose.test.yml' $ComposeArgs = @('-f', $ComposeFile) $OverrideFile = $null if ($RemoteRepoPath) { $OverrideFile = Join-Path ([System.IO.Path]::GetTempPath()) 'docker-compose.remote-override.yml' - - # Override volume mounts to point at the rsynced remote repo path. - # env_file is NOT overridden — compose reads it locally and injects - # the values as container env vars, which is what we want. @" services: dev: @@ -146,7 +161,6 @@ $InfraContainer = 'psproxmoxve-dev-infra' $RunIntegration = 'tests/infrastructure/scripts/run-integration.sh' function Start-DevContainer { - # 'up -d' is idempotent: starts if stopped, recreates if config/env changed, no-ops if current docker compose @ComposeArgs up -d dev if ($LASTEXITCODE -ne 0) { throw 'Failed to start dev container' } } @@ -166,78 +180,76 @@ echo 'Module installed to /usr/local/share/powershell/Modules/PSProxmoxVE' if ($LASTEXITCODE -ne 0) { throw "Module build failed (exit code $LASTEXITCODE)" } } -switch ($Command) { - 'shell' { - if ($DockerHost) { - Write-Warning "Interactive shell over remote Docker is not supported. Use: ssh $DockerHost 'docker exec -it $DevContainer pwsh -NoProfile'" - return - } - Start-DevContainer - docker exec -it $DevContainer pwsh -NoProfile - } +# Build test filter argument for run-integration.sh +$TestFilter = '' +if ($Tests) { + $TestFilter = ($Tests -join ',') +} - 'build' { - Start-DevContainer - Invoke-BuildModule $DevContainer - } +# ── Execute actions in order ────────────────────────────────────────── - 'test' { - Start-DevContainer - Invoke-BuildModule $DevContainer - docker exec $DevContainer pwsh -NoProfile -Command @' - $config = New-PesterConfiguration - $config.Run.Path = 'tests/PSProxmoxVE.Tests' - $config.Run.Exit = $true - $config.Filter.ExcludeTag = @('Integration') - $config.Output.Verbosity = 'Detailed' - Invoke-Pester -Configuration $config +if ($Stop) { + docker compose @ComposeArgs --profile infra down +} + +if ($Rebuild) { + docker compose @ComposeArgs --profile infra down + docker compose @ComposeArgs build --no-cache dev + docker compose @ComposeArgs --profile infra build --no-cache dev-infra + docker compose @ComposeArgs up -d dev +} + +if ($Shell) { + if ($DockerHost) { + Write-Warning "Interactive shell over remote Docker is not supported. Use: ssh $DockerHost 'docker exec -it $InfraContainer pwsh -NoProfile'" + return + } + Start-DevContainer + docker exec -it $DevContainer pwsh -NoProfile +} + +if ($Build) { + Start-DevContainer + Invoke-BuildModule $DevContainer +} + +if ($Test) { + Start-DevContainer + Invoke-BuildModule $DevContainer + docker exec $DevContainer pwsh -NoProfile -Command @' + $config = New-PesterConfiguration + $config.Run.Path = 'tests/PSProxmoxVE.Tests' + $config.Run.Exit = $true + $config.Filter.ExcludeTag = @('Integration') + $config.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $config '@ - if ($LASTEXITCODE -ne 0) { throw "Unit tests failed (exit code $LASTEXITCODE)" } + if ($LASTEXITCODE -ne 0) { throw "Unit tests failed (exit code $LASTEXITCODE)" } +} + +if ($Provision) { + Start-InfraContainer + docker exec $InfraContainer bash $RunIntegration provision + if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } +} + +if ($Integration) { + Start-InfraContainer + + # Verify environment is ready (config file exists from provisioning) + $configCheck = docker exec $InfraContainer bash -c 'test -f /tmp/pve-integration/config.json && echo OK || echo MISSING' + if ($configCheck.Trim() -eq 'MISSING' -and -not $Provision) { + throw "Integration environment not ready. Run with -Provision first, or use -Provision -Integration together." } - 'integration' { - # Full lifecycle: provision nested PVE VMs -> test -> cleanup (x86 only) - # Requires PVE_ENDPOINT, PVE_API_TOKEN, PVE_TARGET_NODE, PVE_PASSWORD in .env.test - # run-integration.sh handles module build if no pre-built artifact exists - Start-InfraContainer - if ($NoCleanup) { - # Provision and test separately — leave VMs running for inspection - docker exec $InfraContainer bash $RunIntegration provision - if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } - docker exec $InfraContainer bash $RunIntegration test $PveVersion - if ($LASTEXITCODE -ne 0) { throw "Integration tests failed (exit code $LASTEXITCODE)" } - Write-Host 'VMs left running (-NoCleanup). Run ./tests/dev.ps1 cleanup to destroy them.' - } else { - docker exec $InfraContainer bash $RunIntegration all $PveVersion - if ($LASTEXITCODE -ne 0) { throw "Integration tests failed (exit code $LASTEXITCODE)" } - } - } + docker exec $InfraContainer bash $RunIntegration test $Version $TestFilter + if ($LASTEXITCODE -ne 0) { throw "Integration tests failed (exit code $LASTEXITCODE)" } +} - 'provision' { - # Provision nested PVE VMs only, without running tests (x86 only) - # Useful for iterating: provision once, then shell in and run tests manually - Start-InfraContainer - docker exec $InfraContainer bash $RunIntegration provision - if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } - } - - 'cleanup' { - # Destroy provisioned VMs - Start-InfraContainer - docker exec $InfraContainer bash $RunIntegration cleanup - if ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } - } - - 'stop' { - docker compose @ComposeArgs --profile infra down - } - - 'rebuild' { - docker compose @ComposeArgs --profile infra down - docker compose @ComposeArgs build --no-cache dev - docker compose @ComposeArgs --profile infra build --no-cache dev-infra - docker compose @ComposeArgs up -d dev - } +if ($Cleanup) { + Start-InfraContainer + docker exec $InfraContainer bash $RunIntegration cleanup + if ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } } } finally { From 7d74bc9750a6f0bf6f79e9e70188bbe0fce604e3 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:05:27 -0500 Subject: [PATCH 03/26] feat: add test filter support to run-integration.sh cmd_test() now accepts an optional second argument for filtering integration tests by area name. Comma-separated names are matched against test filenames via glob (e.g. *Connection*.Tests.ps1). Usage: run-integration.sh test 9 Connection,VMs The Pester invocation builds a path array from matched files when a filter is specified, otherwise runs the entire Integration directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../infrastructure/scripts/run-integration.sh | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 6a152c4..9cb9299 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -8,10 +8,16 @@ # Docker containers on the runner host for iSCSI/NFS shared storage. # # Usage: -# run-integration.sh provision Provision nested PVE VMs + start storage containers -# run-integration.sh test [8|9|all] Run integration tests (default: all) -# run-integration.sh cleanup Destroy provisioned VMs -# run-integration.sh all [8|9|all] Full lifecycle: provision → test → cleanup +# run-integration.sh provision Provision nested PVE VMs + start storage containers +# run-integration.sh test [8|9|all] [filter] Run integration tests (default: all, no filter) +# run-integration.sh cleanup Destroy provisioned VMs +# run-integration.sh all [8|9|all] Full lifecycle: provision → test → cleanup +# +# The optional [filter] is a comma-separated list of test area names. +# Each name is matched against integration test filenames (case-insensitive). +# Examples: +# run-integration.sh test 9 Connection,VMs # Run Connection + VMs tests for PVE 9 +# run-integration.sh test all Cluster # Run Cluster tests for all PVE versions # # Required env vars (provision/cleanup): # PVE_ENDPOINT Parent PVE API URL (e.g. https://pve.example.com:8006) @@ -280,6 +286,7 @@ cmd_provision() { cmd_test() { local requested="${1:-all}" + local test_filter="${2:-}" local versions_to_test if [[ "$requested" == "all" ]]; then @@ -380,17 +387,49 @@ cmd_test() { # Run Pester local test_exit=0 + local pester_filter_arg="" + if [[ -n "$test_filter" ]]; then + pester_filter_arg="$test_filter" + log "Test filter: $test_filter" + fi + pwsh -NoProfile -Command ' + param($PveVersion, $TestFilter) Import-Module Pester -MinimumVersion 5.0 $config = New-PesterConfiguration - $config.Run.Path = "tests/PSProxmoxVE.Tests/Integration" + + if ($TestFilter) { + # Build list of matching test files + $integrationDir = "tests/PSProxmoxVE.Tests/Integration" + $areas = $TestFilter -split "," + $paths = @() + foreach ($area in $areas) { + $area = $area.Trim() + $matches = Get-ChildItem "$integrationDir/*${area}*.Tests.ps1" -ErrorAction SilentlyContinue + if ($matches) { + $paths += $matches.FullName + } else { + Write-Warning "No test files matched filter: $area" + } + } + if ($paths.Count -eq 0) { + Write-Error "No test files matched any filter in: $TestFilter" + exit 1 + } + $config.Run.Path = $paths + Write-Host "Running $($paths.Count) test file(s):" + $paths | ForEach-Object { Write-Host " $_" } + } else { + $config.Run.Path = "tests/PSProxmoxVE.Tests/Integration" + } + $config.Filter.Tag = "Integration" $config.Output.Verbosity = "Detailed" $config.TestResult.Enabled = $true $config.TestResult.OutputFormat = "NUnitXml" - $config.TestResult.OutputPath = "TestResults/integration-results-pve'"$v"'.xml" + $config.TestResult.OutputPath = "TestResults/integration-results-pve${PveVersion}.xml" Invoke-Pester -Configuration $config - ' || test_exit=$? + ' -- "$v" "$pester_filter_arg" || test_exit=$? if [[ $test_exit -ne 0 ]]; then ci_error "PVE $v integration tests failed (exit code $test_exit)" @@ -452,13 +491,16 @@ main() { cleanup) cmd_cleanup "$@" ;; all) cmd_all "$@" ;; *) - echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all]" + echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all] [test-filter]" echo "" echo "Subcommands:" - echo " provision Provision nested PVE VMs + start storage containers" - echo " test [8|9|all] Run integration tests (default: all versions)" - echo " cleanup Destroy all provisioned VMs" - echo " all [8|9|all] Full lifecycle: provision → test → cleanup" + echo " provision Provision nested PVE VMs + start storage containers" + echo " test [8|9|all] [filter] Run integration tests (default: all versions, no filter)" + echo " cleanup Destroy all provisioned VMs" + echo " all [8|9|all] Full lifecycle: provision → test → cleanup" + echo "" + echo "Test filter: comma-separated area names matching test filenames." + echo " Examples: Connection,VMs Cluster Storage,Network" exit 1 ;; esac From 858047c477ca72de5ed951e140b3b7d6c6b1a052 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:21:53 -0500 Subject: [PATCH 04/26] fix: check correct config path in dev.ps1 integration readiness check Config file is at /opt/pve-isos/test-config.json (CACHE_DIR default), not /tmp/pve-integration/config.json. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 1ce3361..76c88db 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -237,7 +237,7 @@ if ($Integration) { Start-InfraContainer # Verify environment is ready (config file exists from provisioning) - $configCheck = docker exec $InfraContainer bash -c 'test -f /tmp/pve-integration/config.json && echo OK || echo MISSING' + $configCheck = docker exec $InfraContainer bash -c 'test -f "${CONFIG_FILE:-/opt/pve-isos/test-config.json}" && echo OK || echo MISSING' if ($configCheck.Trim() -eq 'MISSING' -and -not $Provision) { throw "Integration environment not ready. Run with -Provision first, or use -Provision -Integration together." } From d3d73e57021828688f747bbd5c12781a1076ae01 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:23:33 -0500 Subject: [PATCH 05/26] fix: move config.json from CACHE_DIR to WORK_DIR (/tmp/pve-integration) Config file now defaults to $WORK_DIR/config.json instead of $CACHE_DIR/test-config.json. This keeps test artifacts in /tmp/pve-integration alongside other build/work files, separate from the ISO cache in /opt/pve-isos. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 2 +- tests/infrastructure/scripts/run-integration.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 76c88db..2991b7f 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -237,7 +237,7 @@ if ($Integration) { Start-InfraContainer # Verify environment is ready (config file exists from provisioning) - $configCheck = docker exec $InfraContainer bash -c 'test -f "${CONFIG_FILE:-/opt/pve-isos/test-config.json}" && echo OK || echo MISSING' + $configCheck = docker exec $InfraContainer bash -c 'test -f "${CONFIG_FILE:-/tmp/pve-integration/config.json}" && echo OK || echo MISSING' if ($configCheck.Trim() -eq 'MISSING' -and -not $Provision) { throw "Integration environment not ready. Run with -Provision first, or use -Provision -Integration together." } diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 9cb9299..036c2c1 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -33,7 +33,7 @@ # Optional env vars: # CACHE_DIR ISO/image cache (default: /opt/pve-isos) # WORK_DIR Temp dir for build artifacts (default: $RUNNER_TEMP or /tmp/pve-integration) -# CONFIG_FILE Test config JSON path (default: $CACHE_DIR/test-config.json) +# CONFIG_FILE Test config JSON path (default: $WORK_DIR/config.json) # MODULE_ARTIFACT Path to built module DLLs (default: ./publish/netstandard2.0) # PVE_VERSIONS Space-separated versions to provision (default: "9 8") # STORAGE_ISCSI_IQN iSCSI IQN for storage target (default: iqn.2024-01.local.test:storage) @@ -48,7 +48,7 @@ REPO_ROOT="$(cd "$INFRA_DIR/../.." && pwd)" # ── Defaults ──────────────────────────────────────────────────────── CACHE_DIR="${CACHE_DIR:-/opt/pve-isos}" WORK_DIR="${WORK_DIR:-${RUNNER_TEMP:-/tmp/pve-integration}}" -CONFIG_FILE="${CONFIG_FILE:-$CACHE_DIR/test-config.json}" +CONFIG_FILE="${CONFIG_FILE:-$WORK_DIR/config.json}" MODULE_ARTIFACT="${MODULE_ARTIFACT:-$REPO_ROOT/publish/netstandard2.0}" PVE_VERSIONS="${PVE_VERSIONS:-9 8}" SKIP_PROVISION="${SKIP_PROVISION:-false}" From df70e278edc27ed33f3c7496696df42c813e50cd Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:25:49 -0500 Subject: [PATCH 06/26] fix: inject pwsh variables directly instead of using -- separator PowerShell's -Command doesn't support the -- argument separator for param() blocks. Inject $PveVersion and $TestFilter as variable assignments at the top of the script string instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../infrastructure/scripts/run-integration.sh | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 036c2c1..cb17a33 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -393,43 +393,44 @@ cmd_test() { log "Test filter: $test_filter" fi - pwsh -NoProfile -Command ' - param($PveVersion, $TestFilter) + pwsh -NoProfile -Command " + \$PveVersion = '$v' + \$TestFilter = '$pester_filter_arg' Import-Module Pester -MinimumVersion 5.0 - $config = New-PesterConfiguration + \$config = New-PesterConfiguration - if ($TestFilter) { + if (\$TestFilter) { # Build list of matching test files - $integrationDir = "tests/PSProxmoxVE.Tests/Integration" - $areas = $TestFilter -split "," - $paths = @() - foreach ($area in $areas) { - $area = $area.Trim() - $matches = Get-ChildItem "$integrationDir/*${area}*.Tests.ps1" -ErrorAction SilentlyContinue - if ($matches) { - $paths += $matches.FullName + \$integrationDir = 'tests/PSProxmoxVE.Tests/Integration' + \$areas = \$TestFilter -split ',' + \$paths = @() + foreach (\$area in \$areas) { + \$area = \$area.Trim() + \$matched = Get-ChildItem \"\$integrationDir/*\${area}*.Tests.ps1\" -ErrorAction SilentlyContinue + if (\$matched) { + \$paths += \$matched.FullName } else { - Write-Warning "No test files matched filter: $area" + Write-Warning \"No test files matched filter: \$area\" } } - if ($paths.Count -eq 0) { - Write-Error "No test files matched any filter in: $TestFilter" + if (\$paths.Count -eq 0) { + Write-Error \"No test files matched any filter in: \$TestFilter\" exit 1 } - $config.Run.Path = $paths - Write-Host "Running $($paths.Count) test file(s):" - $paths | ForEach-Object { Write-Host " $_" } + \$config.Run.Path = \$paths + Write-Host \"Running \$(\$paths.Count) test file(s):\" + \$paths | ForEach-Object { Write-Host \" \$_\" } } else { - $config.Run.Path = "tests/PSProxmoxVE.Tests/Integration" + \$config.Run.Path = 'tests/PSProxmoxVE.Tests/Integration' } - $config.Filter.Tag = "Integration" - $config.Output.Verbosity = "Detailed" - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = "NUnitXml" - $config.TestResult.OutputPath = "TestResults/integration-results-pve${PveVersion}.xml" - Invoke-Pester -Configuration $config - ' -- "$v" "$pester_filter_arg" || test_exit=$? + \$config.Filter.Tag = 'Integration' + \$config.Output.Verbosity = 'Detailed' + \$config.TestResult.Enabled = \$true + \$config.TestResult.OutputFormat = 'NUnitXml' + \$config.TestResult.OutputPath = \"TestResults/integration-results-pve\${PveVersion}.xml\" + Invoke-Pester -Configuration \$config + " || test_exit=$? if [[ $test_exit -ne 0 ]]; then ci_error "PVE $v integration tests failed (exit code $test_exit)" From 72280e369767a709f4adcabac4d8589ebc801d7f Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:32:51 -0500 Subject: [PATCH 07/26] =?UTF-8?q?fix:=20don't=20clean=20up=20test=20VM=20i?= =?UTF-8?q?n=2006=5FVMs=20=E2=80=94=20later=20files=20depend=20on=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 06_VMs AfterAll was deleting pester-test-vm, causing 07_Snapshots, 09_CloudInit, and 15_Tasks to fail with VMID 100 (wrong VM or nonexistent). Now 99_Cleanup handles all pester-* resource removal. Also fixed cleanup timing: - Use -Wait on Stop-PveVm/Container before Remove - Add sleep after removal for API propagation - 10_Containers AfterAll uses -Wait on stop Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 | 12 ++---------- .../Integration/10_Containers.Tests.ps1 | 9 +++++---- .../Integration/99_Cleanup.Tests.ps1 | 12 ++++++++---- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 index 86263c2..4190347 100644 --- a/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 @@ -17,16 +17,8 @@ BeforeAll { } AfterAll { - if (-not $script:SkipReason -and $script:TestVmId) { - # Stop and remove pester-test-vm and any clone - foreach ($vmId in @($script:TestVmId, ($script:TestVmId + 1000))) { - try { - Stop-PveVm -Node $script:Node -VmId $vmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 - Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue - } catch { } - } - } + # Do NOT clean up pester-test-vm here — later test files (07_Snapshots, + # 09_CloudInit, 15_Tasks) depend on it. 99_Cleanup handles removal. Disconnect-TestPve } diff --git a/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 index d1312df..f5cf4d3 100644 --- a/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 @@ -21,11 +21,12 @@ AfterAll { if ($null -eq $script:SkipReason) { foreach ($ctId in $script:CreatedContainerIds) { try { - Stop-PveContainer -Node $script:Node -VmId $ctId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 + Stop-PveContainer -Node $script:Node -VmId $ctId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { } + Start-Sleep -Seconds 2 + try { Remove-PveContainer -Node $script:Node -VmId $ctId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue - } - catch { <# non-fatal #> } + } catch { } } } Disconnect-TestPve diff --git a/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 index 9fc8b1e..247324b 100644 --- a/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 @@ -19,9 +19,9 @@ Describe 'Safety-Net Cleanup — Integration' -Tag 'Integration' { $pesterVms = $vms | Where-Object { $_.Name -like 'pester-*' } foreach ($vm in $pesterVms) { try { - Stop-PveVm -Node $script:Node -VmId $vm.VmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 + Stop-PveVm -Node $script:Node -VmId $vm.VmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch { } + Start-Sleep -Seconds 2 try { # Try removing as template first (templates need Remove-PveTemplate) Remove-PveTemplate -Node $script:Node -VmId $vm.VmId -Confirm:$false -ErrorAction SilentlyContinue @@ -31,6 +31,8 @@ Describe 'Safety-Net Cleanup — Integration' -Tag 'Integration' { } catch { } } } + # Wait for removals to complete + Start-Sleep -Seconds 3 # Verify $remaining = Get-PveVm -Node $script:Node -ErrorAction SilentlyContinue | @@ -45,13 +47,15 @@ Describe 'Safety-Net Cleanup — Integration' -Tag 'Integration' { $pesterCts = $containers | Where-Object { $_.Name -like 'pester-*' } foreach ($ct in $pesterCts) { try { - Stop-PveContainer -Node $script:Node -VmId $ct.VmId -Confirm:$false -ErrorAction SilentlyContinue | Out-Null - Start-Sleep -Seconds 3 + Stop-PveContainer -Node $script:Node -VmId $ct.VmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } catch { } + Start-Sleep -Seconds 2 try { Remove-PveContainer -Node $script:Node -VmId $ct.VmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue } catch { } } + # Wait for removals to complete + Start-Sleep -Seconds 3 # Verify $remaining = Get-PveContainer -Node $script:Node -ErrorAction SilentlyContinue | From 117b10d22267d29cb2647ccacee71ecfcda1f8be Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:40:05 -0500 Subject: [PATCH 08/26] refactor: make all integration test files self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each test file now creates and cleans up its own resources: - 06_VMs: restores own AfterAll cleanup for pester-test-vm - 07_Snapshots: creates pester-snap-vm, tests snapshots, cleans up - 09_CloudInit: creates pester-ci-vm with cloud-init drive, cleans up - 15_Tasks: creates pester-task-vm, tests task CRUD, cleans up Removed cross-file dependency helpers (Find-TestVm, Register-TestResource) from _IntegrationHelper.ps1 — no longer needed. Each file can now run independently via -Tests filter. If a Setup context fails, subsequent tests in the same file skip gracefully. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integration/06_VMs.Tests.ps1 | 14 +++- .../Integration/07_Snapshots.Tests.ps1 | 56 ++++++++----- .../Integration/09_CloudInit.Tests.ps1 | 80 ++++++++++++++----- .../Integration/11_LinuxVM.Tests.ps1 | 1 - .../Integration/15_Tasks.Tests.ps1 | 58 +++++++++++--- .../Integration/_IntegrationHelper.ps1 | 40 ---------- 6 files changed, 157 insertions(+), 92 deletions(-) diff --git a/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 index 4190347..d719a30 100644 --- a/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/06_VMs.Tests.ps1 @@ -17,8 +17,17 @@ BeforeAll { } AfterAll { - # Do NOT clean up pester-test-vm here — later test files (07_Snapshots, - # 09_CloudInit, 15_Tasks) depend on it. 99_Cleanup handles removal. + if (-not $script:SkipReason -and $script:TestVmId) { + foreach ($vmId in @($script:TestVmId, ($script:TestVmId + 1000))) { + try { + Stop-PveVm -Node $script:Node -VmId $vmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { } + Start-Sleep -Seconds 2 + try { + Remove-PveVm -Node $script:Node -VmId $vmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } Disconnect-TestPve } @@ -49,7 +58,6 @@ Describe 'VMs — Integration' -Tag 'Integration' { $vm | Should -Not -BeNullOrEmpty $script:TestVmId = $vm.VmId - Register-TestResource -Name 'pester-test-vm' -VmId $vm.VmId } It 'Should get and set VM config' { diff --git a/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 index f207400..7af1567 100644 --- a/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 @@ -4,13 +4,12 @@ BeforeAll { . $PSScriptRoot/_IntegrationHelper.ps1 Connect-TestPve - # Find the test VM created by 06_VMs.Tests.ps1 - $script:TestVmId = Find-TestVm + $script:SnapVmId = $null - function script:Skip-IfNoTestVm { + function script:Skip-IfNoSnapVm { if (Skip-IfNoTarget) { return $true } - if ($null -eq $script:TestVmId) { - Set-ItResult -Skipped -Because 'No test VM found (pester-test-vm must exist from 06_VMs)' + if ($null -eq $script:SnapVmId) { + Set-ItResult -Skipped -Because 'Snapshot test VM was not created' return $true } return $false @@ -18,28 +17,49 @@ BeforeAll { } AfterAll { - # Snapshot cleanup is inline (remove within the test). - # No VM cleanup here — 06_VMs owns pester-test-vm. + if (-not $script:SkipReason -and $script:SnapVmId) { + try { + Stop-PveVm -Node $script:Node -VmId $script:SnapVmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { } + Start-Sleep -Seconds 2 + try { + Remove-PveVm -Node $script:Node -VmId $script:SnapVmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } Disconnect-TestPve } Describe 'Snapshots — Integration' -Tag 'Integration' { + Context 'Setup' { + It 'Should create a VM for snapshot tests' { + if (Skip-IfNoTarget) { return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-snap-vm' ` + -Memory 128 ` + -Cores 1 ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node -Name 'pester-snap-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + $script:SnapVmId = $vm.VmId + } + } + Context 'Snapshots' { It 'Should create, list, and remove a snapshot' { - if (Skip-IfNoTestVm) { return } - - # Ensure the VM is stopped for snapshot - $vm = Get-PveVm -Node $script:Node | Where-Object { $_.VmId -eq $script:TestVmId } - if ($vm.Status -eq 'running') { - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null - } + if (Skip-IfNoSnapVm) { return } $snapName = 'pester-snap' # Create $createTask = New-PveSnapshot ` -Node $script:Node ` - -VmId $script:TestVmId ` + -VmId $script:SnapVmId ` -Name $snapName ` -Description 'Created by Pester integration test' ` -Wait @@ -47,14 +67,14 @@ Describe 'Snapshots — Integration' -Tag 'Integration' { $createTask | Should -Not -BeNullOrEmpty # List and verify - $snapshots = Get-PveSnapshot -Node $script:Node -VmId $script:TestVmId + $snapshots = Get-PveSnapshot -Node $script:Node -VmId $script:SnapVmId $snap = $snapshots | Where-Object { $_.Name -eq $snapName } $snap | Should -Not -BeNullOrEmpty # Restore { Restore-PveSnapshot ` -Node $script:Node ` - -VmId $script:TestVmId ` + -VmId $script:SnapVmId ` -Name $snapName ` -Confirm:$false ` -Wait } | Should -Not -Throw @@ -62,7 +82,7 @@ Describe 'Snapshots — Integration' -Tag 'Integration' { # Remove $removeTask = Remove-PveSnapshot ` -Node $script:Node ` - -VmId $script:TestVmId ` + -VmId $script:SnapVmId ` -Name $snapName ` -Confirm:$false ` -Wait diff --git a/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 index 0ecf8b5..83efd2a 100644 --- a/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 @@ -4,39 +4,81 @@ BeforeAll { . $PSScriptRoot/_IntegrationHelper.ps1 Connect-TestPve - # Find a test VM for cloud-init config read test - $script:TestVmId = Find-TestVm - $script:LinuxVmId = Find-TestVm -Name 'pester-linux-vm' + $script:CiVmId = $null + + function script:Skip-IfNoCiVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:CiVmId) { + Set-ItResult -Skipped -Because 'Cloud-init test VM was not created' + return $true + } + return $false + } } AfterAll { - # Read-only tests — no cleanup needed + if (-not $script:SkipReason -and $script:CiVmId) { + try { + Stop-PveVm -Node $script:Node -VmId $script:CiVmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { } + Start-Sleep -Seconds 2 + try { + Remove-PveVm -Node $script:Node -VmId $script:CiVmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } Disconnect-TestPve } Describe 'Cloud-Init — Integration' -Tag 'Integration' { + + Context 'Setup' { + It 'Should create a VM with cloud-init drive' { + if (Skip-IfNoTarget) { return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-ci-vm' ` + -Memory 128 ` + -Cores 1 ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node -Name 'pester-ci-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + $script:CiVmId = $vm.VmId + + # Add a cloud-init drive + Set-PveVmConfig -Node $script:Node -VmId $script:CiVmId ` + -AdditionalConfig @{ ide2 = "$($script:Storage):cloudinit" } ` + -ErrorAction Stop + } + } + Context 'Cloud-Init' { It 'Should get cloud-init config' { - if (Skip-IfNoTarget) { return } - if ($null -eq $script:TestVmId) { - Set-ItResult -Skipped -Because 'No test VM found' - return - } + if (Skip-IfNoCiVm) { return } - # Get-PveCloudInitConfig should not throw even if the VM has no cloud-init drive. - { Get-PveCloudInitConfig -Node $script:Node -VmId $script:TestVmId -ErrorAction Stop } | + { Get-PveCloudInitConfig -Node $script:Node -VmId $script:CiVmId -ErrorAction Stop } | Should -Not -Throw } - It 'Should regenerate cloud-init image (Invoke-PveCloudInitRegenerate)' { - if (Skip-IfNoTarget) { return } - if ($null -eq $script:LinuxVmId) { - Set-ItResult -Skipped -Because 'Linux VM was not provisioned (PVETEST_PASSWORD may not be set)' - return - } + It 'Should set cloud-init config' { + if (Skip-IfNoCiVm) { return } - # The Linux VM has a cloud-init drive from provisioning - { Invoke-PveCloudInitRegenerate -Node $script:Node -VmId $script:LinuxVmId -Wait -ErrorAction Stop } | + { Set-PveCloudInitConfig -Node $script:Node -VmId $script:CiVmId ` + -CiUser 'pester-user' -ErrorAction Stop } | + Should -Not -Throw + + $config = Get-PveCloudInitConfig -Node $script:Node -VmId $script:CiVmId -ErrorAction Stop + $config.CiUser | Should -Be 'pester-user' + } + + It 'Should regenerate cloud-init image' { + if (Skip-IfNoCiVm) { return } + + { Invoke-PveCloudInitRegenerate -Node $script:Node -VmId $script:CiVmId -Wait -ErrorAction Stop } | Should -Not -Throw } } diff --git a/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 index ecdf2c5..e72d5b4 100644 --- a/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 @@ -62,7 +62,6 @@ Describe 'Linux VM — Integration' -Tag 'Integration' { $script:LinuxVmId = $vm.VmId $script:CreatedVmIds.Add($vm.VmId) - Register-TestResource -Name 'pester-linux-vm' -VmId $vm.VmId } It 'Should import cloud image disk (Import-PveVmDisk)' { diff --git a/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 index 4537753..695cd4f 100644 --- a/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 @@ -4,27 +4,59 @@ BeforeAll { . $PSScriptRoot/_IntegrationHelper.ps1 Connect-TestPve - # Find an existing test VM to use for task tests - $script:TestVmId = Find-TestVm + $script:TaskVmId = $null + + function script:Skip-IfNoTaskVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:TaskVmId) { + Set-ItResult -Skipped -Because 'Task test VM was not created' + return $true + } + return $false + } } AfterAll { - # Read-only tests — no cleanup needed + if (-not $script:SkipReason -and $script:TaskVmId) { + try { + Stop-PveVm -Node $script:Node -VmId $script:TaskVmId -Wait -Timeout 30 -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } catch { } + Start-Sleep -Seconds 2 + try { + Remove-PveVm -Node $script:Node -VmId $script:TaskVmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } Disconnect-TestPve } Describe 'Tasks — Integration' -Tag 'Integration' { + Context 'Setup' { + It 'Should create a VM for task tests' { + if (Skip-IfNoTarget) { return } + + $task = New-PveVm ` + -Node $script:Node ` + -Name 'pester-task-vm' ` + -Memory 128 ` + -Cores 1 ` + -Wait + + $task | Should -Not -BeNullOrEmpty + + $vm = Get-PveVm -Node $script:Node -Name 'pester-task-vm' | + Select-Object -First 1 + $vm | Should -Not -BeNullOrEmpty + $script:TaskVmId = $vm.VmId + } + } + Context 'Tasks' { It 'Should get a task by UPID and wait for completion' { - if (Skip-IfNoTarget) { return } - if ($null -eq $script:TestVmId) { - Set-ItResult -Skipped -Because 'No test VM was created' - return - } + if (Skip-IfNoTaskVm) { return } # Start the VM to get a task object - $startResult = Start-PveVm -Node $script:Node -VmId $script:TestVmId + $startResult = Start-PveVm -Node $script:Node -VmId $script:TaskVmId $startResult | Should -Not -BeNullOrEmpty $startResult.Upid | Should -Not -BeNullOrEmpty @@ -36,8 +68,12 @@ Describe 'Tasks — Integration' -Tag 'Integration' { $task | Should -Not -BeNullOrEmpty $task.IsSuccessful | Should -BeTrue - # Clean up - Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait -Timeout 30 -Confirm:$false | Out-Null + # List tasks + $tasks = Get-PveTaskList -Node $script:Node + $tasks | Should -Not -BeNullOrEmpty + + # Clean up — stop the VM + Stop-PveVm -Node $script:Node -VmId $script:TaskVmId -Wait -Timeout 30 -Confirm:$false | Out-Null } } } diff --git a/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 index bf4b566..5eca74f 100644 --- a/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 @@ -145,43 +145,3 @@ function script:Disconnect-TestPve { #> try { Disconnect-PveServer -ErrorAction SilentlyContinue } catch { } } - -# ── Resource discovery helpers ─────────────────────────────────────────── -# These allow later test files to find resources created by earlier files -# without sharing script-scoped variables. If the resource doesn't exist, -# the caller gets $null and should skip. - -function script:Find-TestVm { - <# - .SYNOPSIS - Finds the pester-test-vm by checking env state or querying PVE. - #> - param([string]$Name = 'pester-test-vm') - - # Check env var first (set by the file that created it) - $envKey = "PVETEST_STATE_$($Name.Replace('-','_').ToUpper())" - $vmId = [System.Environment]::GetEnvironmentVariable($envKey) - if ($vmId) { return [int]$vmId } - - # Fall back to querying PVE - try { - $vms = Get-PveVm -Node $script:Node -ErrorAction Stop - $match = $vms | Where-Object { $_.Name -eq $Name } | Select-Object -First 1 - if ($match) { return [int]$match.VmId } - } catch { } - - return $null -} - -function script:Register-TestResource { - <# - .SYNOPSIS - Registers a resource VMID in env for cross-file discovery. - #> - param( - [string]$Name, - [int]$VmId - ) - $envKey = "PVETEST_STATE_$($Name.Replace('-','_').ToUpper())" - [System.Environment]::SetEnvironmentVariable($envKey, $VmId.ToString()) -} From db3629e705ab5f4139bf2aff7d56fe2d84c3b768 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:52:44 -0500 Subject: [PATCH 09/26] feat: support -Version for -Provision and -Cleanup commands Provision and cleanup now accept a version argument (8, 9, or all) to operate on a subset of PVE nodes: dev.ps1 -Provision -Version 9 -DockerHost ... # only PVE 9 nodes dev.ps1 -Cleanup -Version 8 -DockerHost ... # only PVE 8 nodes run-integration.sh provision/cleanup also accept the version arg, overriding PVE_VERSIONS and ALL_NODES for that invocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 4 ++-- .../infrastructure/scripts/run-integration.sh | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 2991b7f..f08ee0b 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -229,7 +229,7 @@ if ($Test) { if ($Provision) { Start-InfraContainer - docker exec $InfraContainer bash $RunIntegration provision + docker exec $InfraContainer bash $RunIntegration provision $Version if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } } @@ -248,7 +248,7 @@ if ($Integration) { if ($Cleanup) { Start-InfraContainer - docker exec $InfraContainer bash $RunIntegration cleanup + docker exec $InfraContainer bash $RunIntegration cleanup $Version if ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } } diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index cb17a33..4b9155e 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -8,9 +8,9 @@ # Docker containers on the runner host for iSCSI/NFS shared storage. # # Usage: -# run-integration.sh provision Provision nested PVE VMs + start storage containers +# run-integration.sh provision [8|9|all] Provision nested PVE VMs + start storage containers # run-integration.sh test [8|9|all] [filter] Run integration tests (default: all, no filter) -# run-integration.sh cleanup Destroy provisioned VMs +# run-integration.sh cleanup [8|9|all] Destroy provisioned VMs # run-integration.sh all [8|9|all] Full lifecycle: provision → test → cleanup # # The optional [filter] is a comma-separated list of test area names. @@ -116,6 +116,11 @@ require_env() { # ── Subcommands ───────────────────────────────────────────────────── cmd_provision() { + local requested="${1:-all}" + if [[ "$requested" != "all" ]]; then + PVE_VERSIONS="$requested" + ALL_NODES="$(expand_nodes)" + fi log "Starting provisioning..." log " Versions: $PVE_VERSIONS" log " Nodes: $ALL_NODES" @@ -444,6 +449,11 @@ cmd_test() { } cmd_cleanup() { + local requested="${1:-all}" + if [[ "$requested" != "all" ]]; then + PVE_VERSIONS="$requested" + ALL_NODES="$(expand_nodes)" + fi log "Starting cleanup..." # Clean up PVE nodes @@ -495,9 +505,9 @@ main() { echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all] [test-filter]" echo "" echo "Subcommands:" - echo " provision Provision nested PVE VMs + start storage containers" + echo " provision [8|9|all] Provision nested PVE VMs + start storage containers" echo " test [8|9|all] [filter] Run integration tests (default: all versions, no filter)" - echo " cleanup Destroy all provisioned VMs" + echo " cleanup [8|9|all] Destroy provisioned VMs (default: all)" echo " all [8|9|all] Full lifecycle: provision → test → cleanup" echo "" echo "Test filter: comma-separated area names matching test filenames." From f5c950f7fa3d5c61b3a118e398bae89fee71341e Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 13:57:42 -0500 Subject: [PATCH 10/26] fix: version-safe provisioning with Terraform -target When provisioning a subset of versions (e.g. -Version 9), use terraform -target to apply only the requested nodes. This prevents Terraform from destroying VMs for other versions that exist in state. Key changes: - Provision: ISOs, answer files, and API token creation only run for requested version nodes. Tfvars always include ALL versions for state consistency. -target limits what Terraform applies. - Cleanup: only destroys VMs for the requested version. Storage containers only stopped when cleaning all versions. - Config: merges with existing config.json to preserve entries from previously provisioned versions. Based on -target pattern from ~/Source/homelab Makefile. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../infrastructure/scripts/run-integration.sh | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 4b9155e..84cc8a1 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -117,13 +117,19 @@ require_env() { cmd_provision() { local requested="${1:-all}" + # Determine which versions/nodes to prepare and which to target + local provision_versions="$PVE_VERSIONS" + local provision_nodes="$ALL_NODES" if [[ "$requested" != "all" ]]; then - PVE_VERSIONS="$requested" - ALL_NODES="$(expand_nodes)" + provision_versions="$requested" + provision_nodes="" + for v in $provision_versions; do + provision_nodes="$provision_nodes ${v}a ${v}b" + done fi log "Starting provisioning..." - log " Versions: $PVE_VERSIONS" - log " Nodes: $ALL_NODES" + log " Versions: $provision_versions" + log " Nodes:$provision_nodes" log " Storage: Docker containers (iSCSI + NFS)" require_env PVE_ENDPOINT require_env PVE_API_TOKEN @@ -134,7 +140,7 @@ cmd_provision() { mkdir -p "$WORK_DIR" "$CACHE_DIR" # Ensure base ISOs (one per version, not per node) - for v in $PVE_VERSIONS; do + for v in $provision_versions; do log "Ensuring base ISO for PVE $v..." bash "$SCRIPT_DIR/ensure-base-iso.sh" "$(pve_iso "$v")" "$CACHE_DIR" done @@ -150,7 +156,7 @@ cmd_provision() { log "Generating answer files..." local escaped_pve_password escaped_pve_password=$(printf '%s' "$PVE_PASSWORD" | sed 's/[\/&\\]/\\&/g') - for node in $ALL_NODES; do + for node in $provision_nodes; do local fqdn fqdn="$(pve_fqdn "$node")" sed -e "s/\${root_password}/${escaped_pve_password}/" \ @@ -159,7 +165,7 @@ cmd_provision() { done # Prepare auto-install ISOs (one per node — each has unique answer file) - for node in $ALL_NODES; do + for node in $provision_nodes; do local iso_name iso_name="$(pve_iso "$node")" log "Preparing auto-install ISO for $node..." @@ -177,6 +183,8 @@ cmd_provision() { log "Running Terraform init..." (cd "$INFRA_DIR" && terraform init -input=false) + # Always build tfvars for ALL versions to keep Terraform state consistent. + # When provisioning a subset, we use -target to limit the apply. log "Building Terraform vars..." local tfvars="$WORK_DIR/instances.tfvars.json" local instances='{}' @@ -201,6 +209,18 @@ cmd_provision() { '{pve_instances: $pve_instances}' > "$tfvars" log "Running Terraform apply (PVE nodes)..." + # Build -target flags when provisioning a subset of versions. + # This prevents Terraform from destroying VMs for other versions + # that exist in state but aren't in the filtered tfvars. + local tf_targets="" + if [[ "$requested" != "all" ]]; then + for node in $provision_nodes; do + tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" + tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" + done + log "Terraform targets: $tf_targets" + fi + # TMPDIR: use work dir to avoid filling the container's /tmp with multi-GB ISO uploads. (cd "$INFRA_DIR" && \ TMPDIR="$WORK_DIR" \ @@ -208,7 +228,7 @@ cmd_provision() { TF_VAR_proxmox_api_token="$PVE_API_TOKEN" \ TF_VAR_target_node="$PVE_TARGET_NODE" \ TF_VAR_test_vm_password="$PVE_PASSWORD" \ - terraform apply -auto-approve -input=false -var-file="$tfvars") + terraform apply -auto-approve -input=false -var-file="$tfvars" $tf_targets) # Start iSCSI/NFS storage containers on the Docker host log "Starting storage containers (iSCSI + NFS)..." @@ -231,7 +251,7 @@ cmd_provision() { log "Storage services ready at $storage_ip (iSCSI: $STORAGE_ISCSI_IQN)" # Wait for PVE instances and create API tokens (per node) - for node in $ALL_NODES; do + for node in $provision_nodes; do log "Waiting for $node to boot and creating API token..." local output output=$(bash "$SCRIPT_DIR/create-api-token.sh" \ @@ -248,19 +268,23 @@ cmd_provision() { '{host: $host, token: $token, node: $node}' > "$WORK_DIR/${node}.json" done - # Prepare test environments on all PVE nodes - for node in $ALL_NODES; do + # Prepare test environments on provisioned PVE nodes + for node in $provision_nodes; do local ip ip=$(jq -r .host "$WORK_DIR/${node}.json") log "Preparing test environment on $node ($ip)..." bash "$SCRIPT_DIR/prepare-test-environment.sh" "$ip" "$PVE_PASSWORD" done - # Write test config + # Write test config — merge with existing config to preserve entries + # from previously provisioned versions log "Writing test config to $CONFIG_FILE..." local config='{}' + if [[ -f "$CONFIG_FILE" ]]; then + config=$(cat "$CONFIG_FILE") + fi - for v in $PVE_VERSIONS; do + for v in $provision_versions; do local node_a="${v}a" local node_b="${v}b" local version_config @@ -450,14 +474,17 @@ cmd_test() { cmd_cleanup() { local requested="${1:-all}" + local cleanup_nodes="$ALL_NODES" if [[ "$requested" != "all" ]]; then - PVE_VERSIONS="$requested" - ALL_NODES="$(expand_nodes)" + cleanup_nodes="" + for v in $requested; do + cleanup_nodes="$cleanup_nodes ${v}a ${v}b" + done fi log "Starting cleanup..." # Clean up PVE nodes - for node in $ALL_NODES; do + for node in $cleanup_nodes; do local iso_name iso_name="$(pve_iso "$node")" log "Cleaning up $node (VMID $(pve_vmid "$node"))..." @@ -467,9 +494,11 @@ cmd_cleanup() { || true done - # Stop storage containers - log "Stopping storage containers..." - docker compose -f "$STORAGE_COMPOSE" down -v 2>/dev/null || true + # Stop storage containers only when cleaning all versions + if [[ "$requested" == "all" ]]; then + log "Stopping storage containers..." + docker compose -f "$STORAGE_COMPOSE" down -v 2>/dev/null || true + fi log "Cleanup complete." } From 2e126b96a3107f24938e51fa51e23905600c1e90 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:06:28 -0500 Subject: [PATCH 11/26] fix: CloudInit regenerate calls PUT endpoint instead of GET dump RegenerateCloudInitImage was calling GET /cloudinit/dump?type=user which returns the cloud-init YAML content, not a task UPID. The cmdlet then passed this YAML string to WaitForTask, causing a 501 error trying to poll a URI like "GET nodes/.../tasks/%23cloud-config..." Fixed to call PUT /nodes/{node}/qemu/{vmid}/cloudinit which is the correct regeneration endpoint that returns a UPID. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PSProxmoxVE.Core/Services/CloudInitService.cs | 14 +++++--------- .../InvokePveCloudInitRegenerateCmdlet.cs | 7 +------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/PSProxmoxVE.Core/Services/CloudInitService.cs b/src/PSProxmoxVE.Core/Services/CloudInitService.cs index 2f5ef98..443b5df 100644 --- a/src/PSProxmoxVE.Core/Services/CloudInitService.cs +++ b/src/PSProxmoxVE.Core/Services/CloudInitService.cs @@ -105,13 +105,10 @@ namespace PSProxmoxVE.Core.Services } /// - /// Triggers regeneration of the Cloud-Init ISO drive attached to the VM. - /// This is done by calling the cloudinit dump endpoint, then touching the config to - /// force PVE to rebuild the CD-ROM image on next start. + /// Regenerates the Cloud-Init ISO drive attached to the VM + /// (PUT /nodes/{node}/qemu/{vmid}/cloudinit). /// - /// - /// The raw Cloud-Init dump (user-data) string as reported by the PVE API. - /// + /// The UPID of the regeneration task. public string RegenerateCloudInitImage(PveSession session, string node, int vmid) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -120,10 +117,9 @@ namespace PSProxmoxVE.Core.Services IPveHttpClient client = _injectedClient ?? new PveHttpClient(session); try { - // Request a Cloud-Init dump (user-data section) — this causes PVE to rebuild the image - var dumpResponse = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/cloudinit/dump?type=user") + var response = client.PutAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/cloudinit", null) .GetAwaiter().GetResult(); - var data = JObject.Parse(dumpResponse)["data"]; + var data = JObject.Parse(response)["data"]; return data?.ToString() ?? string.Empty; } finally diff --git a/src/PSProxmoxVE/Cmdlets/CloudInit/InvokePveCloudInitRegenerateCmdlet.cs b/src/PSProxmoxVE/Cmdlets/CloudInit/InvokePveCloudInitRegenerateCmdlet.cs index b862c78..c013e1a 100644 --- a/src/PSProxmoxVE/Cmdlets/CloudInit/InvokePveCloudInitRegenerateCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/CloudInit/InvokePveCloudInitRegenerateCmdlet.cs @@ -42,12 +42,7 @@ namespace PSProxmoxVE.Cmdlets.CloudInit var service = new CloudInitService(); var upid = service.RegenerateCloudInitImage(session, Node, VmId); - var task = new PveTask - { - Node = Node, - Status = "stopped", - ExitStatus = "OK" - }; + var task = new PveTask { Upid = upid, Node = Node, Status = "running" }; if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) { From 80871f750be8bc7cce004d629c471c7847a95159 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:16:52 -0500 Subject: [PATCH 12/26] feat: consolidate infrastructure into Terraform + terraform destroy cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major provisioning pipeline changes: Terraform: - Add kreuzwerker/docker provider to manage iSCSI and NFS storage containers alongside PVE VMs in a single Terraform config - New storage.tf with Docker container, image, and volume resources - docker_host_ip variable for PVE nodes to reach storage services Provisioning: - Replace docker-compose storage management with Terraform - Replace create-api-token.sh with wait-for-pve.sh (IP discovery + API readiness + auth verification only — no token creation) - Tests use root@pam credentials, not API tokens Cleanup: - Replace preflight-cleanup.sh loop with terraform destroy - Supports version filtering: cleanup 9 destroys only PVE 9 resources - Full cleanup also removes config.json and tfvars New commands: - taint [8|9|all]: marks VMs for recreation on next provision - dev.ps1 -Reprovision: runs taint before provision to force VM rebuild Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 10 + tests/infrastructure/main.tf | 9 + tests/infrastructure/outputs.tf | 15 ++ .../infrastructure/scripts/run-integration.sh | 174 ++++++++++++------ tests/infrastructure/scripts/wait-for-pve.sh | 96 ++++++++++ tests/infrastructure/storage.tf | 84 +++++++++ tests/infrastructure/variables.tf | 17 ++ 7 files changed, 344 insertions(+), 61 deletions(-) create mode 100755 tests/infrastructure/scripts/wait-for-pve.sh create mode 100644 tests/infrastructure/storage.tf diff --git a/tests/dev.ps1 b/tests/dev.ps1 index f08ee0b..a93efbc 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -39,6 +39,11 @@ .PARAMETER Rebuild Rebuild container images from scratch. +.PARAMETER Reprovision + When used with -Provision, taints the PVE VMs in Terraform state + before applying, forcing them to be destroyed and recreated. + Useful when the VMs are in a bad state (e.g. clustered, broken). + .PARAMETER Tests Filter integration tests by area name. Comma-separated list of test area names that match the numbered file prefixes. Examples: @@ -86,6 +91,7 @@ param( [switch] $Cleanup, [switch] $Stop, [switch] $Rebuild, + [switch] $Reprovision, [string[]] $Tests, @@ -229,6 +235,10 @@ if ($Test) { if ($Provision) { Start-InfraContainer + if ($Reprovision) { + docker exec $InfraContainer bash $RunIntegration taint $Version + if ($LASTEXITCODE -ne 0) { throw "Taint failed (exit code $LASTEXITCODE)" } + } docker exec $InfraContainer bash $RunIntegration provision $Version if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } } diff --git a/tests/infrastructure/main.tf b/tests/infrastructure/main.tf index 2199061..d294dde 100644 --- a/tests/infrastructure/main.tf +++ b/tests/infrastructure/main.tf @@ -5,6 +5,10 @@ terraform { source = "bpg/proxmox" version = ">= 0.70.0" } + docker = { + source = "kreuzwerker/docker" + version = ">= 3.0.0" + } } } @@ -14,6 +18,11 @@ provider "proxmox" { insecure = var.proxmox_insecure } +provider "docker" { + # Uses the Docker socket from the dev-infra container + # (mounted at /var/run/docker.sock) +} + resource "proxmox_virtual_environment_file" "auto_iso" { for_each = var.pve_instances content_type = "iso" diff --git a/tests/infrastructure/outputs.tf b/tests/infrastructure/outputs.tf index 4ecb5ee..565f7bb 100644 --- a/tests/infrastructure/outputs.tf +++ b/tests/infrastructure/outputs.tf @@ -7,3 +7,18 @@ output "pve_test_node_name" { value = "pve" description = "Default node name inside a fresh PVE install" } + +output "storage_ip" { + description = "IP address where storage services are reachable" + value = var.docker_host_ip +} + +output "storage_iscsi_iqn" { + description = "iSCSI target IQN" + value = var.storage_iscsi_iqn +} + +output "storage_nfs_export" { + description = "NFS export path" + value = "${var.docker_host_ip}:/srv/nfs/shared" +} diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 84cc8a1..462f527 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -180,6 +180,18 @@ cmd_provision() { # Terraform — remove any stale .tfvars from previous manual runs rm -f "$INFRA_DIR/terraform.tfvars" + # Discover Docker host IP before Terraform apply (needed for docker_host_ip var) + local storage_ip + storage_ip=$(docker run --rm --net=host alpine ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}') + if [ -z "$storage_ip" ]; then + storage_ip=$(docker info --format '{{.Swarm.NodeAddr}}' 2>/dev/null | cut -d: -f1) + fi + if [ -z "$storage_ip" ]; then + ci_error "Could not determine Docker host IP for storage services" + exit 1 + fi + log "Docker host IP: $storage_ip" + log "Running Terraform init..." (cd "$INFRA_DIR" && terraform init -input=false) @@ -228,44 +240,22 @@ cmd_provision() { TF_VAR_proxmox_api_token="$PVE_API_TOKEN" \ TF_VAR_target_node="$PVE_TARGET_NODE" \ TF_VAR_test_vm_password="$PVE_PASSWORD" \ + TF_VAR_docker_host_ip="$storage_ip" \ terraform apply -auto-approve -input=false -var-file="$tfvars" $tf_targets) - # Start iSCSI/NFS storage containers on the Docker host - log "Starting storage containers (iSCSI + NFS)..." - # Get the Docker host's real IP (not the container's). The storage containers - # use host networking, so PVE nodes reach them via the host's IP. - local storage_ip - # Prefer the IP of the default route's interface on the Docker host. - storage_ip=$(docker run --rm --net=host alpine ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}') - if [ -z "$storage_ip" ]; then - # Fallback: use the local node address from Docker info (not RemoteManagers, - # which can return addresses of remote Swarm managers). - storage_ip=$(docker info --format '{{.Swarm.NodeAddr}}' 2>/dev/null | cut -d: -f1) - fi - if [ -z "$storage_ip" ]; then - ci_error "Could not determine Docker host IP for storage services" - exit 1 - fi - ISCSI_IQN="$STORAGE_ISCSI_IQN" \ - docker compose -f "$STORAGE_COMPOSE" up -d - log "Storage services ready at $storage_ip (iSCSI: $STORAGE_ISCSI_IQN)" - - # Wait for PVE instances and create API tokens (per node) + # Wait for PVE instances to boot and discover IPs for node in $provision_nodes; do - log "Waiting for $node to boot and creating API token..." + log "Waiting for $node to boot..." local output - output=$(bash "$SCRIPT_DIR/create-api-token.sh" \ + output=$(bash "$SCRIPT_DIR/wait-for-pve.sh" \ "$PVE_ENDPOINT" "$PVE_API_TOKEN" \ "$(pve_vmid "$node")" "$PVE_PASSWORD" 900) - local ip token + local ip node_name ip=$(echo "$output" | grep "^IP=" | cut -d= -f2) - token=$(echo "$output" | grep "^TOKEN=" | cut -d= -f2-) - # Node name = hostname portion of FQDN (e.g. pve9a.test.local -> pve9a) - local node_name - node_name="$(pve_fqdn "$node" | cut -d. -f1)" + node_name=$(echo "$output" | grep "^NODE=" | cut -d= -f2) log "$node ready at $ip (node: $node_name)" - jq -n --arg host "$ip" --arg token "$token" --arg node "$node_name" \ - '{host: $host, token: $token, node: $node}' > "$WORK_DIR/${node}.json" + jq -n --arg host "$ip" --arg node "$node_name" \ + '{host: $host, node: $node}' > "$WORK_DIR/${node}.json" done # Prepare test environments on provisioned PVE nodes @@ -354,15 +344,13 @@ cmd_test() { # Set env vars from config or from environment if [[ "$SKIP_PROVISION" == "true" ]]; then : "${PVETEST_HOST:?Set PVETEST_HOST when using SKIP_PROVISION}" - : "${PVETEST_APITOKEN:?Set PVETEST_APITOKEN when using SKIP_PROVISION}" + : "${PVETEST_PASSWORD:?Set PVETEST_PASSWORD when using SKIP_PROVISION}" export PVETEST_PORT="${PVETEST_PORT:-8006}" export PVETEST_NODE="${PVETEST_NODE:-pve}" export PVETEST_STORAGE="${PVETEST_STORAGE:-local}" export PVETEST_CLOUD_IMAGE_PATH="${PVETEST_CLOUD_IMAGE_PATH:-}" export PVETEST_OVA_PATH="${PVETEST_OVA_PATH:-}" - # Secondary node and storage VM may not be available in skip mode export PVETEST_HOST_B="${PVETEST_HOST_B:-}" - export PVETEST_APITOKEN_B="${PVETEST_APITOKEN_B:-}" export PVETEST_STORAGE_VM_IP="${PVETEST_STORAGE_VM_IP:-}" export PVETEST_ISCSI_IQN="${PVETEST_ISCSI_IQN:-}" export PVETEST_NFS_EXPORT="${PVETEST_NFS_EXPORT:-}" @@ -373,7 +361,6 @@ cmd_test() { fi # Primary node (a) export PVETEST_HOST=$(jq -r ".pve${v}.nodes.a.host" "$CONFIG_FILE") - export PVETEST_APITOKEN=$(jq -r ".pve${v}.nodes.a.token" "$CONFIG_FILE") export PVETEST_PORT=8006 export PVETEST_NODE=$(jq -r ".pve${v}.nodes.a.node" "$CONFIG_FILE") export PVETEST_STORAGE=local @@ -381,7 +368,6 @@ cmd_test() { export PVETEST_OVA_PATH=$(jq -r '.ova_path' "$CONFIG_FILE") # Secondary node (b) export PVETEST_HOST_B=$(jq -r ".pve${v}.nodes.b.host" "$CONFIG_FILE") - export PVETEST_APITOKEN_B=$(jq -r ".pve${v}.nodes.b.token" "$CONFIG_FILE") # Storage services (Docker on runner) export PVETEST_STORAGE_VM_IP=$(jq -r '.storage.ip' "$CONFIG_FILE") export PVETEST_ISCSI_IQN=$(jq -r '.storage.iscsi_iqn' "$CONFIG_FILE") @@ -392,12 +378,12 @@ cmd_test() { export PVETEST_PVE_VERSION="$v" export PVETEST_PASSWORD="${PVETEST_PASSWORD:-${PVE_PASSWORD:-}}" - # Verify API reachable (node A) + # Verify API reachable (node A) using ticket auth log "Verifying PVE $v node A API at $PVETEST_HOST:$PVETEST_PORT..." if ! curl -sk --connect-timeout 10 \ - -H "Authorization: PVEAPIToken=${PVETEST_APITOKEN}" \ - "https://${PVETEST_HOST}:${PVETEST_PORT}/api2/json/nodes" | grep -q '"node"'; then - ci_error "Cannot reach PVE $v node A API at ${PVETEST_HOST}:${PVETEST_PORT}" + -d "username=root@pam&password=${PVETEST_PASSWORD}" \ + "https://${PVETEST_HOST}:${PVETEST_PORT}/api2/json/access/ticket" | grep -q '"ticket"'; then + ci_error "Cannot authenticate to PVE $v node A at ${PVETEST_HOST}:${PVETEST_PORT}" overall_exit=3 continue fi @@ -406,9 +392,9 @@ cmd_test() { if [[ -n "${PVETEST_HOST_B:-}" ]]; then log "Verifying PVE $v node B API at $PVETEST_HOST_B:$PVETEST_PORT..." if ! curl -sk --connect-timeout 10 \ - -H "Authorization: PVEAPIToken=${PVETEST_APITOKEN_B}" \ - "https://${PVETEST_HOST_B}:${PVETEST_PORT}/api2/json/nodes" | grep -q '"node"'; then - ci_error "Cannot reach PVE $v node B API at ${PVETEST_HOST_B}:${PVETEST_PORT}" + -d "username=root@pam&password=${PVETEST_PASSWORD}" \ + "https://${PVETEST_HOST_B}:${PVETEST_PORT}/api2/json/access/ticket" | grep -q '"ticket"'; then + ci_error "Cannot authenticate to PVE $v node B at ${PVETEST_HOST_B}:${PVETEST_PORT}" overall_exit=3 continue fi @@ -474,35 +460,99 @@ cmd_test() { cmd_cleanup() { local requested="${1:-all}" - local cleanup_nodes="$ALL_NODES" + log "Starting cleanup..." + + require_env PVE_ENDPOINT + require_env PVE_API_TOKEN + require_env PVE_TARGET_NODE + + # Discover Docker host IP for the docker_host_ip variable + local storage_ip + storage_ip=$(docker run --rm --net=host alpine ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}') + if [ -z "$storage_ip" ]; then + storage_ip=$(docker info --format '{{.Swarm.NodeAddr}}' 2>/dev/null | cut -d: -f1) + fi + + # Build tfvars for all versions (Terraform needs the full variable map) + local tfvars="$WORK_DIR/instances.tfvars.json" + if [[ ! -f "$tfvars" ]]; then + # Generate minimal tfvars if none exist (cleanup without prior provision) + local instances='{}' + for node in $ALL_NODES; do + local vm_id vm_name + vm_id="$(pve_vmid "$node")" + vm_name="$(pve_vmname "$node")" + instances="$(jq \ + --arg key "$node" \ + --arg vm_name "$vm_name" \ + --argjson vm_id "$vm_id" \ + --arg iso_local_path "/dev/null" \ + '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name}}' \ + <<<"$instances")" + done + mkdir -p "$WORK_DIR" + jq -n --argjson pve_instances "$instances" \ + '{pve_instances: $pve_instances}' > "$tfvars" + fi + + (cd "$INFRA_DIR" && terraform init -input=false 2>/dev/null) + + # Build -target flags when destroying a subset + local tf_targets="" if [[ "$requested" != "all" ]]; then - cleanup_nodes="" + local cleanup_nodes="" for v in $requested; do cleanup_nodes="$cleanup_nodes ${v}a ${v}b" done + for node in $cleanup_nodes; do + tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" + tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" + done + log "Destroying PVE $requested nodes only..." + else + log "Destroying all resources..." fi - log "Starting cleanup..." - # Clean up PVE nodes - for node in $cleanup_nodes; do - local iso_name - iso_name="$(pve_iso "$node")" - log "Cleaning up $node (VMID $(pve_vmid "$node"))..." - bash "$SCRIPT_DIR/preflight-cleanup.sh" \ - "${PVE_ENDPOINT:-}" "${PVE_API_TOKEN:-}" \ - "$(pve_vmid "$node")" "${iso_name%.iso}-${node}-auto.iso" "$INFRA_DIR" \ - || true - done + (cd "$INFRA_DIR" && \ + TF_VAR_proxmox_endpoint="$PVE_ENDPOINT" \ + TF_VAR_proxmox_api_token="$PVE_API_TOKEN" \ + TF_VAR_target_node="$PVE_TARGET_NODE" \ + TF_VAR_test_vm_password="${PVE_PASSWORD:-placeholder}" \ + TF_VAR_docker_host_ip="${storage_ip:-127.0.0.1}" \ + terraform destroy -auto-approve -input=false -var-file="$tfvars" $tf_targets) || true - # Stop storage containers only when cleaning all versions + # Clean up work directory when destroying all if [[ "$requested" == "all" ]]; then - log "Stopping storage containers..." - docker compose -f "$STORAGE_COMPOSE" down -v 2>/dev/null || true + rm -f "$CONFIG_FILE" "$WORK_DIR"/instances.tfvars.json fi log "Cleanup complete." } +cmd_taint() { + local requested="${1:-all}" + local taint_nodes="$ALL_NODES" + if [[ "$requested" != "all" ]]; then + taint_nodes="" + for v in $requested; do + taint_nodes="$taint_nodes ${v}a ${v}b" + done + fi + + log "Tainting PVE VMs for reprovisioning..." + (cd "$INFRA_DIR" && terraform init -input=false 2>/dev/null) + + for node in $taint_nodes; do + log " Tainting VM: $node" + (cd "$INFRA_DIR" && \ + terraform taint "proxmox_virtual_environment_vm.nested_pve[\"$node\"]") 2>/dev/null || true + (cd "$INFRA_DIR" && \ + terraform taint "proxmox_virtual_environment_file.auto_iso[\"$node\"]") 2>/dev/null || true + done + + log "Taint complete. Next 'provision' will recreate these VMs." +} + cmd_all() { local test_versions="${1:-all}" local test_exit=0 @@ -529,14 +579,16 @@ main() { provision) cmd_provision "$@" ;; test) cmd_test "$@" ;; cleanup) cmd_cleanup "$@" ;; + taint) cmd_taint "$@" ;; all) cmd_all "$@" ;; *) - echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all] [test-filter]" + echo "Usage: $(basename "$0") {provision|test|cleanup|taint|all} [8|9|all] [test-filter]" echo "" echo "Subcommands:" - echo " provision [8|9|all] Provision nested PVE VMs + start storage containers" + echo " provision [8|9|all] Provision nested PVE VMs + storage containers" echo " test [8|9|all] [filter] Run integration tests (default: all versions, no filter)" - echo " cleanup [8|9|all] Destroy provisioned VMs (default: all)" + echo " cleanup [8|9|all] Destroy resources via terraform destroy (default: all)" + echo " taint [8|9|all] Mark VMs for recreation on next provision" echo " all [8|9|all] Full lifecycle: provision → test → cleanup" echo "" echo "Test filter: comma-separated area names matching test filenames." diff --git a/tests/infrastructure/scripts/wait-for-pve.sh b/tests/infrastructure/scripts/wait-for-pve.sh new file mode 100755 index 0000000..0b65f3a --- /dev/null +++ b/tests/infrastructure/scripts/wait-for-pve.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Wait for a fresh nested PVE instance to boot, discover its IP via the QEMU guest agent, +# then wait for the PVE API to become responsive. +# +# Usage: wait-for-pve.sh [max-wait-seconds] +# Outputs: +# IP= +# NODE= +set -euo pipefail + +PARENT_ENDPOINT="${1%/}" +PARENT_TOKEN="$2" +VM_ID="$3" +ROOT_PASSWORD="$4" +MAX_WAIT="${5:-600}" +INTERVAL=10 +PARENT_API="${PARENT_ENDPOINT}/api2/json" +NODES_JSON=$(curl -sk -H "Authorization: PVEAPIToken=${PARENT_TOKEN}" \ + "${PARENT_API}/nodes") +PARENT_NODE=$(echo "$NODES_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['node'])") + +# --- Phase 1: Discover IP via QEMU guest agent --- +echo "Waiting for guest agent on VM ${VM_ID} (node: ${PARENT_NODE})..." +VM_IP="" +elapsed=0 +while [ $elapsed -lt $MAX_WAIT ]; do + AGENT_RESPONSE=$(curl -sk \ + -H "Authorization: PVEAPIToken=${PARENT_TOKEN}" \ + "${PARENT_API}/nodes/${PARENT_NODE}/qemu/${VM_ID}/agent/network-get-interfaces" 2>/dev/null || true) + + VM_IP=$(echo "$AGENT_RESPONSE" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin).get('data', {}).get('result', []) + for iface in data: + if iface.get('name') == 'lo': + continue + for addr in iface.get('ip-addresses', []): + if addr.get('ip-address-type') == 'ipv4' and not addr['ip-address'].startswith('127.'): + print(addr['ip-address']) + sys.exit(0) +except: + pass +" 2>/dev/null || true) + + if [ -n "$VM_IP" ]; then + echo "Discovered VM IP: $VM_IP (after ${elapsed}s)" + break + fi + echo " Guest agent not ready yet (${elapsed}s elapsed)..." + sleep $INTERVAL + elapsed=$((elapsed + INTERVAL)) +done + +if [ -z "$VM_IP" ]; then + echo "ERROR: Could not discover VM IP via guest agent after ${MAX_WAIT}s" >&2 + exit 1 +fi + +# --- Phase 2: Wait for PVE API on the nested instance --- +NESTED_API="https://${VM_IP}:8006/api2/json" +echo "Waiting for nested PVE API at ${NESTED_API}..." +while [ $elapsed -lt $MAX_WAIT ]; do + if curl -sk --connect-timeout 5 "${NESTED_API}/access/domains" 2>/dev/null | grep -q '"realm"'; then + echo "Nested PVE API is responsive after ${elapsed}s" + break + fi + echo " API not ready yet (${elapsed}s elapsed)..." + sleep $INTERVAL + elapsed=$((elapsed + INTERVAL)) +done + +if [ $elapsed -ge $MAX_WAIT ]; then + echo "ERROR: Nested PVE API not responsive after ${MAX_WAIT}s" >&2 + exit 1 +fi + +# --- Phase 3: Verify authentication works --- +echo "Verifying root@pam authentication..." +AUTH_RESPONSE=$(curl -sk -d "username=root@pam&password=${ROOT_PASSWORD}" \ + "${NESTED_API}/access/ticket" 2>/dev/null || true) +TICKET=$(echo "$AUTH_RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['ticket'])" 2>/dev/null || true) + +if [ -z "$TICKET" ]; then + echo "ERROR: root@pam authentication failed on ${VM_IP}. Response: $AUTH_RESPONSE" >&2 + exit 1 +fi +echo "Authentication verified." + +# Discover the nested node's hostname +NODE_NAME=$(curl -sk -b "PVEAuthCookie=${TICKET}" \ + "${NESTED_API}/nodes" 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['node'])" 2>/dev/null || echo "unknown") + +echo "IP=${VM_IP}" +echo "NODE=${NODE_NAME}" diff --git a/tests/infrastructure/storage.tf b/tests/infrastructure/storage.tf new file mode 100644 index 0000000..9fea6dd --- /dev/null +++ b/tests/infrastructure/storage.tf @@ -0,0 +1,84 @@ +# ── Docker images & volumes ────────────────────────────────────────── + +resource "docker_image" "ubuntu" { + name = "ubuntu:24.04" +} + +resource "docker_volume" "iscsi_data" { + name = "pvetest-iscsi-data" +} + +resource "docker_volume" "nfs_data" { + name = "pvetest-nfs-data" +} + +# ── iSCSI target container ────────────────────────────────────────── + +resource "docker_container" "iscsi_target" { + name = "pvetest-iscsi" + image = docker_image.ubuntu.image_id + privileged = true + restart = "unless-stopped" + + network_mode = "host" + + volumes { + volume_name = docker_volume.iscsi_data.name + container_path = "/srv/iscsi" + } + + env = [ + "ISCSI_IQN=${var.storage_iscsi_iqn}", + "ISCSI_LUN_SIZE=${var.storage_iscsi_lun_size}", + ] + + entrypoint = ["/bin/bash", "-c"] + command = [<<-EOT + set -e + apt-get update -qq && apt-get install -y -qq tgt >/dev/null 2>&1 + mkdir -p /srv/iscsi + if [ ! -f /srv/iscsi/lun0.img ]; then + truncate -s $${ISCSI_LUN_SIZE} /srv/iscsi/lun0.img + fi + tgtd --foreground & + sleep 2 + if ! tgtadm --lld iscsi --op show --mode target | grep -q "Target 1: $${ISCSI_IQN}"; then + tgtadm --lld iscsi --op new --mode target --tid 1 -T $${ISCSI_IQN} + fi + if ! tgtadm --lld iscsi --op show --mode logicalunit --tid 1 2>/dev/null | grep -qE "LUN:[[:space:]]*1($$|[^0-9])"; then + tgtadm --lld iscsi --op new --mode logicalunit --tid 1 --lun 1 --backing-store /srv/iscsi/lun0.img + fi + if ! tgtadm --lld iscsi --op show --mode target --tid 1 2>/dev/null | grep -q "Initiator-address: ALL"; then + tgtadm --lld iscsi --op bind --mode target --tid 1 -I ALL + fi + echo "iSCSI target ready: $${ISCSI_IQN} (port 3260)" + wait + EOT + ] +} + +# ── NFS server container ──────────────────────────────────────────── + +resource "docker_container" "nfs_server" { + name = "pvetest-nfs" + image = "erichough/nfs-server:2.2.1" + privileged = true + restart = "unless-stopped" + + network_mode = "host" + + volumes { + volume_name = docker_volume.nfs_data.name + container_path = "/srv/nfs/shared" + } + + volumes { + host_path = "/lib/modules" + container_path = "/lib/modules" + read_only = true + } + + env = [ + "NFS_EXPORT_0=/srv/nfs/shared *(rw,sync,no_subtree_check,no_root_squash)", + ] +} diff --git a/tests/infrastructure/variables.tf b/tests/infrastructure/variables.tf index afcdd07..1674a38 100644 --- a/tests/infrastructure/variables.tf +++ b/tests/infrastructure/variables.tf @@ -71,3 +71,20 @@ variable "test_vm_password" { sensitive = true } +variable "storage_iscsi_iqn" { + description = "iSCSI target IQN for the test storage" + type = string + default = "iqn.2024-01.local.test:storage" +} + +variable "storage_iscsi_lun_size" { + description = "Size of the iSCSI LUN backing file" + type = string + default = "10G" +} + +variable "docker_host_ip" { + description = "IP of the Docker host, used by PVE nodes to reach storage containers" + type = string +} + From eb0ffdfeba7d7faf206caba1ed483171a84dbe87 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:19:02 -0500 Subject: [PATCH 13/26] fix: include Docker storage resources in version-filtered Terraform targets When provisioning a subset of versions (-Version 9), the -target flags only included PVE VM resources. Docker storage containers (iSCSI, NFS) were skipped because they weren't targeted. Now always includes all Docker resources in the target list since storage is shared across all PVE versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/scripts/run-integration.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 462f527..36af906 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -230,6 +230,12 @@ cmd_provision() { tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" done + # Always include shared Docker storage resources + tf_targets="$tf_targets -target=docker_image.ubuntu" + tf_targets="$tf_targets -target=docker_container.iscsi_target" + tf_targets="$tf_targets -target=docker_container.nfs_server" + tf_targets="$tf_targets -target=docker_volume.iscsi_data" + tf_targets="$tf_targets -target=docker_volume.nfs_data" log "Terraform targets: $tf_targets" fi From 943a9e61dd7e9017960ef8a1cd16d691a2e7d448 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:27:43 -0500 Subject: [PATCH 14/26] feat: add -Force cleanup that bypasses Terraform When Terraform state is corrupted (e.g. interrupted provision), -Cleanup -Force bypasses Terraform and: 1. Destroys VMs via direct PVE API calls (preflight-cleanup.sh) 2. Force-removes Docker storage containers and volumes 3. Deletes Terraform state files so next provision starts clean Usage: dev.ps1 -Cleanup -Force -DockerHost 172.16.40.113 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 13 +++++- .../infrastructure/scripts/run-integration.sh | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index a93efbc..19cfa32 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -44,6 +44,12 @@ before applying, forcing them to be destroyed and recreated. Useful when the VMs are in a bad state (e.g. clustered, broken). +.PARAMETER Force + When used with -Cleanup, bypasses Terraform and destroys VMs + directly via the PVE API. Useful when Terraform state is corrupted + (e.g. after an interrupted provision). Also removes Terraform + state files so the next provision starts clean. + .PARAMETER Tests Filter integration tests by area name. Comma-separated list of test area names that match the numbered file prefixes. Examples: @@ -92,6 +98,7 @@ param( [switch] $Stop, [switch] $Rebuild, [switch] $Reprovision, + [switch] $Force, [string[]] $Tests, @@ -258,7 +265,11 @@ if ($Integration) { if ($Cleanup) { Start-InfraContainer - docker exec $InfraContainer bash $RunIntegration cleanup $Version + if ($Force) { + docker exec $InfraContainer bash $RunIntegration force-cleanup $Version + } else { + docker exec $InfraContainer bash $RunIntegration cleanup $Version + } if ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } } diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 36af906..a8c2afd 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -535,6 +535,50 @@ cmd_cleanup() { log "Cleanup complete." } +cmd_force_cleanup() { + local requested="${1:-all}" + local cleanup_nodes="$ALL_NODES" + if [[ "$requested" != "all" ]]; then + cleanup_nodes="" + for v in $requested; do + cleanup_nodes="$cleanup_nodes ${v}a ${v}b" + done + fi + + log "Force cleanup — bypassing Terraform, using direct API calls..." + + # Destroy VMs via the PVE API (works even with broken Terraform state) + for node in $cleanup_nodes; do + local vm_id + vm_id="$(pve_vmid "$node")" + local iso_name + iso_name="$(pve_iso "$node")" + log "Force cleaning $node (VMID $vm_id)..." + bash "$SCRIPT_DIR/preflight-cleanup.sh" \ + "${PVE_ENDPOINT:-}" "${PVE_API_TOKEN:-}" \ + "$vm_id" "${iso_name%.iso}-${node}-auto.iso" "$INFRA_DIR" \ + || true + done + + # Stop Docker storage containers + if [[ "$requested" == "all" ]]; then + log "Stopping storage containers..." + docker rm -f pvetest-iscsi pvetest-nfs 2>/dev/null || true + docker volume rm pvetest-iscsi-data pvetest-nfs-data 2>/dev/null || true + fi + + # Remove Terraform state so next provision starts clean + log "Removing Terraform state..." + rm -f "$INFRA_DIR/terraform.tfstate" "$INFRA_DIR/terraform.tfstate.backup" + rm -f "$INFRA_DIR/.terraform.lock.hcl" + rm -rf "$INFRA_DIR/.terraform" + + # Remove work artifacts + rm -f "$CONFIG_FILE" "$WORK_DIR"/instances.tfvars.json + + log "Force cleanup complete. Next provision will start from scratch." +} + cmd_taint() { local requested="${1:-all}" local taint_nodes="$ALL_NODES" @@ -585,6 +629,7 @@ main() { provision) cmd_provision "$@" ;; test) cmd_test "$@" ;; cleanup) cmd_cleanup "$@" ;; + force-cleanup) cmd_force_cleanup "$@" ;; taint) cmd_taint "$@" ;; all) cmd_all "$@" ;; *) From b3005e1e7285afb53fcf6bedc576e1498427d2a3 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:38:15 -0500 Subject: [PATCH 15/26] feat: replace per-node baked ISOs with HTTP answer server Replace 4 per-node auto-install ISOs (~4GB) with 2 generic ISOs (~2GB) served by an HTTP answer server that routes per-node configs by MAC address. New flow: 1. Deterministic MAC addresses assigned per node (AA:BB:CC:00:VV:NN) 2. Per-MAC answer.toml files generated in answers/ directory 3. HTTP answer server (slothcroissant/proxmox-auto-installer-server) managed by Terraform, serves answer files on port 8000 4. Generic ISOs prepared with --fetch-from http --url pointing to the answer server 5. PVE installer POSTs system info, server matches MAC to answer file Benefits: - 50% reduction in ISO disk/tmp usage (2 ISOs instead of 4) - Faster ISO preparation (2 builds instead of 4) - Answer files can be updated without rebuilding ISOs - First-boot script embedded in generic ISO via --on-first-boot Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/main.tf | 5 +- .../infrastructure/scripts/run-integration.sh | 131 ++++++++++++------ tests/infrastructure/storage.tf | 20 +++ tests/infrastructure/variables.tf | 11 ++ 4 files changed, 124 insertions(+), 43 deletions(-) diff --git a/tests/infrastructure/main.tf b/tests/infrastructure/main.tf index d294dde..26f90b5 100644 --- a/tests/infrastructure/main.tf +++ b/tests/infrastructure/main.tf @@ -74,8 +74,9 @@ resource "proxmox_virtual_environment_vm" "nested_pve" { } network_device { - bridge = var.network_bridge - model = "virtio" + bridge = var.network_bridge + model = "virtio" + mac_address = each.value.mac_address } operating_system { diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index a8c2afd..6d6f2cb 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -88,6 +88,15 @@ pve_fqdn() { esac } +# Deterministic MAC addresses for each node. +# Scheme: AA:BB:CC:00:: +pve_mac() { + case "$1" in + 9a) echo "AA:BB:CC:00:09:1A" ;; 9b) echo "AA:BB:CC:00:09:1B" ;; + 8a) echo "AA:BB:CC:00:08:1A" ;; 8b) echo "AA:BB:CC:00:08:1B" ;; + esac +} + # Expand versions to node list: "9 8" -> "9a 9b 8a 8b" expand_nodes() { local nodes="" @@ -152,35 +161,8 @@ cmd_provision() { CLOUD_IMAGE_PATH=$(echo "$cloud_output" | grep "^CLOUD_IMAGE_PATH=" | cut -d= -f2) OVA_PATH=$(echo "$cloud_output" | grep "^OVA_PATH=" | cut -d= -f2) - # Generate per-node answer files (each needs unique FQDN for clustering) - log "Generating answer files..." - local escaped_pve_password - escaped_pve_password=$(printf '%s' "$PVE_PASSWORD" | sed 's/[\/&\\]/\\&/g') - for node in $provision_nodes; do - local fqdn - fqdn="$(pve_fqdn "$node")" - sed -e "s/\${root_password}/${escaped_pve_password}/" \ - -e "s/\${fqdn}/${fqdn}/" \ - "$INFRA_DIR/answer.toml.tftpl" > "$WORK_DIR/answer-${node}.toml" - done - - # Prepare auto-install ISOs (one per node — each has unique answer file) - for node in $provision_nodes; do - local iso_name - iso_name="$(pve_iso "$node")" - log "Preparing auto-install ISO for $node..." - bash "$SCRIPT_DIR/prepare-auto-iso.sh" \ - "$CACHE_DIR/$iso_name" \ - "$WORK_DIR/answer-${node}.toml" \ - "$SCRIPT_DIR/first-boot.sh" \ - "$WORK_DIR/${iso_name%.iso}-${node}-auto.iso" \ - --cache-dir "$CACHE_DIR" - done - - # Terraform — remove any stale .tfvars from previous manual runs - rm -f "$INFRA_DIR/terraform.tfvars" - - # Discover Docker host IP before Terraform apply (needed for docker_host_ip var) + # Discover Docker host IP early — needed for the HTTP auto-install ISO URL + # and for the docker_host_ip Terraform variable. local storage_ip storage_ip=$(docker run --rm --net=host alpine ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}') if [ -z "$storage_ip" ]; then @@ -192,6 +174,53 @@ cmd_provision() { fi log "Docker host IP: $storage_ip" + # Generate per-MAC answer files for the HTTP answer server. + # Each node gets a file named by its MAC address so the server can + # route the correct answer to each VM during auto-install. + log "Generating answer files..." + local escaped_pve_password + escaped_pve_password=$(printf '%s' "$PVE_PASSWORD" | sed 's/[\/&\\]/\\&/g') + mkdir -p "$WORK_DIR/answers" + + # Default answer file (fallback for unknown MACs) + sed -e "s/\${root_password}/${escaped_pve_password}/" \ + -e "s/\${fqdn}/pve-default.test.local/" \ + "$INFRA_DIR/answer.toml.tftpl" > "$WORK_DIR/default-answer.toml" + + for node in $provision_nodes; do + local mac fqdn + mac="$(pve_mac "$node")" + fqdn="$(pve_fqdn "$node")" + # Answer file named by lowercase MAC with colons (server matches on MAC) + sed -e "s/\${root_password}/${escaped_pve_password}/" \ + -e "s/\${fqdn}/${fqdn}/" \ + "$INFRA_DIR/answer.toml.tftpl" > "$WORK_DIR/answers/${mac}.toml" + done + + # Prepare generic HTTP auto-install ISOs (one per PVE version, not per node). + # The first-boot script is embedded in the ISO via --on-first-boot so that + # [first-boot] source = "from-iso" in the answer file still works. + for v in $provision_versions; do + local base_iso_name generic_iso + base_iso_name="$(pve_iso "$v")" + generic_iso="$WORK_DIR/${base_iso_name%.iso}-http-auto.iso" + if [ ! -f "$generic_iso" ]; then + log "Preparing HTTP auto-install ISO for PVE $v..." + proxmox-auto-install-assistant prepare-iso \ + --fetch-from http \ + --url "http://${storage_ip}:8000/answer" \ + --on-first-boot "$SCRIPT_DIR/first-boot.sh" \ + --tmp "$WORK_DIR" \ + --output "$generic_iso" \ + "$CACHE_DIR/$base_iso_name" + else + log "HTTP auto-install ISO for PVE $v already exists, skipping." + fi + done + + # Terraform — remove any stale .tfvars from previous manual runs + rm -f "$INFRA_DIR/terraform.tfvars" + log "Running Terraform init..." (cd "$INFRA_DIR" && terraform init -input=false) @@ -201,19 +230,23 @@ cmd_provision() { local tfvars="$WORK_DIR/instances.tfvars.json" local instances='{}' for node in $ALL_NODES; do + local v="${node%[ab]}" # Extract version: "9a" -> "9" local iso_name - iso_name="$(pve_iso "$node")" - local iso_path="$WORK_DIR/${iso_name%.iso}-${node}-auto.iso" + iso_name="$(pve_iso "$v")" + local iso_path="$WORK_DIR/${iso_name%.iso}-http-auto.iso" local vm_id vm_id="$(pve_vmid "$node")" local vm_name vm_name="$(pve_vmname "$node")" + local mac + mac="$(pve_mac "$node")" instances="$(jq \ --arg key "$node" \ --arg iso_local_path "$iso_path" \ --arg vm_name "$vm_name" \ --argjson vm_id "$vm_id" \ - '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name}}' \ + --arg mac_address "$mac" \ + '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ <<<"$instances")" done @@ -230,10 +263,11 @@ cmd_provision() { tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" done - # Always include shared Docker storage resources + # Always include shared Docker storage and answer server resources tf_targets="$tf_targets -target=docker_image.ubuntu" tf_targets="$tf_targets -target=docker_container.iscsi_target" tf_targets="$tf_targets -target=docker_container.nfs_server" + tf_targets="$tf_targets -target=docker_container.answer_server" tf_targets="$tf_targets -target=docker_volume.iscsi_data" tf_targets="$tf_targets -target=docker_volume.nfs_data" log "Terraform targets: $tf_targets" @@ -247,6 +281,8 @@ cmd_provision() { TF_VAR_target_node="$PVE_TARGET_NODE" \ TF_VAR_test_vm_password="$PVE_PASSWORD" \ TF_VAR_docker_host_ip="$storage_ip" \ + TF_VAR_answer_files_dir="$WORK_DIR/answers" \ + TF_VAR_default_answer_file="$WORK_DIR/default-answer.toml" \ terraform apply -auto-approve -input=false -var-file="$tfvars" $tf_targets) # Wait for PVE instances to boot and discover IPs @@ -488,12 +524,15 @@ cmd_cleanup() { local vm_id vm_name vm_id="$(pve_vmid "$node")" vm_name="$(pve_vmname "$node")" + local mac + mac="$(pve_mac "$node")" instances="$(jq \ --arg key "$node" \ --arg vm_name "$vm_name" \ --argjson vm_id "$vm_id" \ --arg iso_local_path "/dev/null" \ - '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name}}' \ + --arg mac_address "$mac" \ + '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ <<<"$instances")" done mkdir -p "$WORK_DIR" @@ -525,6 +564,8 @@ cmd_cleanup() { TF_VAR_target_node="$PVE_TARGET_NODE" \ TF_VAR_test_vm_password="${PVE_PASSWORD:-placeholder}" \ TF_VAR_docker_host_ip="${storage_ip:-127.0.0.1}" \ + TF_VAR_answer_files_dir="${WORK_DIR}/answers" \ + TF_VAR_default_answer_file="${WORK_DIR}/default-answer.toml" \ terraform destroy -auto-approve -input=false -var-file="$tfvars" $tf_targets) || true # Clean up work directory when destroying all @@ -548,22 +589,30 @@ cmd_force_cleanup() { log "Force cleanup — bypassing Terraform, using direct API calls..." # Destroy VMs via the PVE API (works even with broken Terraform state) + # Track which versions we've already cleaned up ISOs for (generic ISOs are shared) + local cleaned_iso_versions="" for node in $cleanup_nodes; do - local vm_id + local vm_id v iso_name iso_file vm_id="$(pve_vmid "$node")" - local iso_name - iso_name="$(pve_iso "$node")" + v="${node%[ab]}" + iso_name="$(pve_iso "$v")" + # Only clean up the generic ISO once per version + iso_file="" + if [[ ! " $cleaned_iso_versions " =~ " $v " ]]; then + iso_file="${iso_name%.iso}-http-auto.iso" + cleaned_iso_versions="$cleaned_iso_versions $v" + fi log "Force cleaning $node (VMID $vm_id)..." bash "$SCRIPT_DIR/preflight-cleanup.sh" \ "${PVE_ENDPOINT:-}" "${PVE_API_TOKEN:-}" \ - "$vm_id" "${iso_name%.iso}-${node}-auto.iso" "$INFRA_DIR" \ + "$vm_id" "$iso_file" "$INFRA_DIR" \ || true done - # Stop Docker storage containers + # Stop Docker storage and answer server containers if [[ "$requested" == "all" ]]; then - log "Stopping storage containers..." - docker rm -f pvetest-iscsi pvetest-nfs 2>/dev/null || true + log "Stopping storage and answer server containers..." + docker rm -f pvetest-iscsi pvetest-nfs pvetest-answer-server 2>/dev/null || true docker volume rm pvetest-iscsi-data pvetest-nfs-data 2>/dev/null || true fi diff --git a/tests/infrastructure/storage.tf b/tests/infrastructure/storage.tf index 9fea6dd..44ccfda 100644 --- a/tests/infrastructure/storage.tf +++ b/tests/infrastructure/storage.tf @@ -1,3 +1,23 @@ +# ── Answer server container ────────────────────────────────────────── + +resource "docker_container" "answer_server" { + name = "pvetest-answer-server" + image = "slothcroissant/proxmox-auto-installer-server:latest" + restart = "unless-stopped" + + network_mode = "host" + + volumes { + host_path = var.answer_files_dir + container_path = "/app/answers" + } + + volumes { + host_path = var.default_answer_file + container_path = "/app/default.toml" + } +} + # ── Docker images & volumes ────────────────────────────────────────── resource "docker_image" "ubuntu" { diff --git a/tests/infrastructure/variables.tf b/tests/infrastructure/variables.tf index 1674a38..cbffc84 100644 --- a/tests/infrastructure/variables.tf +++ b/tests/infrastructure/variables.tf @@ -26,6 +26,7 @@ variable "pve_instances" { iso_local_path = string vm_id = number vm_name = string + mac_address = string })) } @@ -88,3 +89,13 @@ variable "docker_host_ip" { type = string } +variable "answer_files_dir" { + description = "Host path to the directory containing per-MAC answer.toml files" + type = string +} + +variable "default_answer_file" { + description = "Host path to the default answer.toml file" + type = string +} + From 8c6790476bcafe402793a8ddd4e14cbbccbdf608 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:38:54 -0500 Subject: [PATCH 16/26] fix: always remove Docker containers in force cleanup Force cleanup must remove pvetest-* Docker containers unconditionally, not just when cleaning all versions. Stale containers cause Terraform to fail on next provision ("container already exists") since the state was also wiped. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/scripts/run-integration.sh | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 6d6f2cb..f87159f 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -609,12 +609,11 @@ cmd_force_cleanup() { || true done - # Stop Docker storage and answer server containers - if [[ "$requested" == "all" ]]; then - log "Stopping storage and answer server containers..." - docker rm -f pvetest-iscsi pvetest-nfs pvetest-answer-server 2>/dev/null || true - docker volume rm pvetest-iscsi-data pvetest-nfs-data 2>/dev/null || true - fi + # Always stop Docker containers in force mode — leaving them causes + # Terraform to fail on next provision (container already exists). + log "Stopping storage and answer server containers..." + docker rm -f pvetest-iscsi pvetest-nfs pvetest-answer-server 2>/dev/null || true + docker volume rm pvetest-iscsi-data pvetest-nfs-data 2>/dev/null || true # Remove Terraform state so next provision starts clean log "Removing Terraform state..." From a27863f4efd727bc285d7f2c71ececa6c058650f Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:39:27 -0500 Subject: [PATCH 17/26] fix: prevent -Force -Cleanup with -Version filter Force cleanup wipes all Terraform state, so filtering by version would leave an inconsistent state where subsequent provisions fail. Error early with a clear message instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 19cfa32..e8e743f 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -266,7 +266,10 @@ if ($Integration) { if ($Cleanup) { Start-InfraContainer if ($Force) { - docker exec $InfraContainer bash $RunIntegration force-cleanup $Version + if ($Version -ne 'all') { + throw "-Force cannot be combined with -Version. Force cleanup destroys all resources and wipes Terraform state." + } + docker exec $InfraContainer bash $RunIntegration force-cleanup } else { docker exec $InfraContainer bash $RunIntegration cleanup $Version } From 53e567f38bd958d253c3e81f445a8ae06d763a8a Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:43:15 -0500 Subject: [PATCH 18/26] fix: upload one ISO per PVE version, not per node Changed Terraform ISO resource from for_each=pve_instances (per-node) to for_each=pve_isos (per-version). With HTTP answer server, both nodes of the same version share the same generic ISO. New pve_isos variable maps version to ISO path. pve_instances now has pve_version field instead of iso_local_path. VMs reference their version's ISO via auto_iso[each.value.pve_version]. Updated run-integration.sh tfvars generation and -target flags for both provision and cleanup to use the new schema. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/main.tf | 7 +-- .../infrastructure/scripts/run-integration.sh | 48 +++++++++++-------- tests/infrastructure/variables.tf | 16 +++++-- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/tests/infrastructure/main.tf b/tests/infrastructure/main.tf index 26f90b5..df66f34 100644 --- a/tests/infrastructure/main.tf +++ b/tests/infrastructure/main.tf @@ -24,13 +24,14 @@ provider "docker" { } resource "proxmox_virtual_environment_file" "auto_iso" { - for_each = var.pve_instances + for_each = var.pve_isos content_type = "iso" datastore_id = var.iso_storage node_name = var.target_node + overwrite = true source_file { - path = each.value.iso_local_path + path = each.value } } @@ -69,7 +70,7 @@ resource "proxmox_virtual_environment_vm" "nested_pve" { } cdrom { - file_id = proxmox_virtual_environment_file.auto_iso[each.key].id + file_id = proxmox_virtual_environment_file.auto_iso[each.value.pve_version].id interface = "ide2" } diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index f87159f..a70c86e 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -228,30 +228,37 @@ cmd_provision() { # When provisioning a subset, we use -target to limit the apply. log "Building Terraform vars..." local tfvars="$WORK_DIR/instances.tfvars.json" - local instances='{}' - for node in $ALL_NODES; do - local v="${node%[ab]}" # Extract version: "9a" -> "9" + + # Build pve_isos map: version -> ISO path (one per version) + local isos='{}' + for v in $PVE_VERSIONS; do local iso_name iso_name="$(pve_iso "$v")" local iso_path="$WORK_DIR/${iso_name%.iso}-http-auto.iso" - local vm_id + isos="$(jq --arg key "$v" --arg path "$iso_path" \ + '. + {($key): $path}' <<<"$isos")" + done + + # Build pve_instances map: node -> VM config (references version, not ISO path) + local instances='{}' + for node in $ALL_NODES; do + local v="${node%[ab]}" + local vm_id vm_name mac vm_id="$(pve_vmid "$node")" - local vm_name vm_name="$(pve_vmname "$node")" - local mac mac="$(pve_mac "$node")" instances="$(jq \ --arg key "$node" \ - --arg iso_local_path "$iso_path" \ + --arg pve_version "$v" \ --arg vm_name "$vm_name" \ --argjson vm_id "$vm_id" \ --arg mac_address "$mac" \ - '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ + '. + {($key): {pve_version: $pve_version, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ <<<"$instances")" done - jq -n --argjson pve_instances "$instances" \ - '{pve_instances: $pve_instances}' > "$tfvars" + jq -n --argjson pve_instances "$instances" --argjson pve_isos "$isos" \ + '{pve_instances: $pve_instances, pve_isos: $pve_isos}' > "$tfvars" log "Running Terraform apply (PVE nodes)..." # Build -target flags when provisioning a subset of versions. @@ -259,8 +266,10 @@ cmd_provision() { # that exist in state but aren't in the filtered tfvars. local tf_targets="" if [[ "$requested" != "all" ]]; then + for v in $provision_versions; do + tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$v\"]" + done for node in $provision_nodes; do - tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" done # Always include shared Docker storage and answer server resources @@ -519,25 +528,26 @@ cmd_cleanup() { local tfvars="$WORK_DIR/instances.tfvars.json" if [[ ! -f "$tfvars" ]]; then # Generate minimal tfvars if none exist (cleanup without prior provision) - local instances='{}' + local instances='{}' isos='{}' for node in $ALL_NODES; do - local vm_id vm_name + local v="${node%[ab]}" vm_id vm_name mac vm_id="$(pve_vmid "$node")" vm_name="$(pve_vmname "$node")" - local mac mac="$(pve_mac "$node")" instances="$(jq \ --arg key "$node" \ + --arg pve_version "$v" \ --arg vm_name "$vm_name" \ --argjson vm_id "$vm_id" \ - --arg iso_local_path "/dev/null" \ --arg mac_address "$mac" \ - '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ + '. + {($key): {pve_version: $pve_version, vm_id: $vm_id, vm_name: $vm_name, mac_address: $mac_address}}' \ <<<"$instances")" + isos="$(jq --arg key "$v" --arg path "/dev/null" \ + '. + {($key): $path}' <<<"$isos")" done mkdir -p "$WORK_DIR" - jq -n --argjson pve_instances "$instances" \ - '{pve_instances: $pve_instances}' > "$tfvars" + jq -n --argjson pve_instances "$instances" --argjson pve_isos "$isos" \ + '{pve_instances: $pve_instances, pve_isos: $pve_isos}' > "$tfvars" fi (cd "$INFRA_DIR" && terraform init -input=false 2>/dev/null) @@ -548,10 +558,10 @@ cmd_cleanup() { local cleanup_nodes="" for v in $requested; do cleanup_nodes="$cleanup_nodes ${v}a ${v}b" + tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$v\"]" done for node in $cleanup_nodes; do tf_targets="$tf_targets -target=proxmox_virtual_environment_vm.nested_pve[\"$node\"]" - tf_targets="$tf_targets -target=proxmox_virtual_environment_file.auto_iso[\"$node\"]" done log "Destroying PVE $requested nodes only..." else diff --git a/tests/infrastructure/variables.tf b/tests/infrastructure/variables.tf index cbffc84..d08fbc7 100644 --- a/tests/infrastructure/variables.tf +++ b/tests/infrastructure/variables.tf @@ -21,15 +21,21 @@ variable "target_node" { } variable "pve_instances" { - description = "Map of PVE instances to provision. Key is a label (e.g. 'pve9'), value defines the VM." + description = "Map of PVE instances to provision. Key is a node label (e.g. '9a'), value defines the VM." type = map(object({ - iso_local_path = string - vm_id = number - vm_name = string - mac_address = string + pve_version = string + vm_id = number + vm_name = string + mac_address = string })) } +variable "pve_isos" { + description = "Map of PVE version to the local path of the generic HTTP auto-install ISO." + type = map(string) + default = {} +} + variable "cores" { description = "Number of CPU cores to allocate to each nested PVE VM" type = number From 589181e77cbc3c93775119c6f9f8badbafc116ea Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 14:49:59 -0500 Subject: [PATCH 19/26] fix: use shared host mount for answer files + rename to pve-integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WORK_DIR now defaults to /opt/pve-integration/work (under the shared host mount) instead of /tmp/pve-integration (container-local). This fixes the answer server seeing empty answer files — the dev-infra container writes answer files to WORK_DIR, and the answer server container (a sibling) needs to read them from the same host path. Also: - Renamed mount from /opt/pve-isos to /opt/pve-integration - Added must_run=true, start=true to answer server container - Updated docker-compose.test.yml and dev.ps1 remote override Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/dev.ps1 | 4 ++-- tests/docker-compose.test.yml | 2 +- tests/infrastructure/scripts/run-integration.sh | 4 ++-- tests/infrastructure/storage.tf | 8 +++++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/dev.ps1 b/tests/dev.ps1 index e8e743f..895fe72 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -163,7 +163,7 @@ services: dev-infra: volumes: - ${RemoteRepoPath}:/repo - - /opt/pve-isos:/opt/pve-isos + - /opt/pve-integration:/opt/pve-integration "@ | Set-Content -Path $OverrideFile -Encoding utf8 $ComposeArgs = @('-f', $ComposeFile, '-f', $OverrideFile) @@ -254,7 +254,7 @@ if ($Integration) { Start-InfraContainer # Verify environment is ready (config file exists from provisioning) - $configCheck = docker exec $InfraContainer bash -c 'test -f "${CONFIG_FILE:-/tmp/pve-integration/config.json}" && echo OK || echo MISSING' + $configCheck = docker exec $InfraContainer bash -c 'test -f "${CONFIG_FILE:-/opt/pve-integration/work/config.json}" && echo OK || echo MISSING' if ($configCheck.Trim() -eq 'MISSING' -and -not $Provision) { throw "Integration environment not ready. Run with -Provision first, or use -Provision -Integration together." } diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml index 196780c..11168c7 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -37,7 +37,7 @@ services: profiles: [infra] volumes: - ..:/repo - - /opt/pve-isos:/opt/pve-isos + - /opt/pve-integration:/opt/pve-integration # WARNING: Mounting the Docker socket grants this container root-equivalent # access to the host's Docker daemon. Only use on trusted, isolated machines. - /var/run/docker.sock:/var/run/docker.sock diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index a70c86e..ae28d9c 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -46,8 +46,8 @@ INFRA_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$INFRA_DIR/../.." && pwd)" # ── Defaults ──────────────────────────────────────────────────────── -CACHE_DIR="${CACHE_DIR:-/opt/pve-isos}" -WORK_DIR="${WORK_DIR:-${RUNNER_TEMP:-/tmp/pve-integration}}" +CACHE_DIR="${CACHE_DIR:-/opt/pve-integration}" +WORK_DIR="${WORK_DIR:-${RUNNER_TEMP:-$CACHE_DIR/work}}" CONFIG_FILE="${CONFIG_FILE:-$WORK_DIR/config.json}" MODULE_ARTIFACT="${MODULE_ARTIFACT:-$REPO_ROOT/publish/netstandard2.0}" PVE_VERSIONS="${PVE_VERSIONS:-9 8}" diff --git a/tests/infrastructure/storage.tf b/tests/infrastructure/storage.tf index 44ccfda..be34495 100644 --- a/tests/infrastructure/storage.tf +++ b/tests/infrastructure/storage.tf @@ -1,9 +1,11 @@ # ── Answer server container ────────────────────────────────────────── resource "docker_container" "answer_server" { - name = "pvetest-answer-server" - image = "slothcroissant/proxmox-auto-installer-server:latest" - restart = "unless-stopped" + name = "pvetest-answer-server" + image = "slothcroissant/proxmox-auto-installer-server:latest" + restart = "unless-stopped" + must_run = true + start = true network_mode = "host" From 61dca7acb1c11225ccdddc222f69e060b5263557 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 15:05:24 -0500 Subject: [PATCH 20/26] chore: add apt-get upgrade to first-boot script Ensures nested PVE nodes are fully patched before integration tests run. Adds ~5-10min to first provision but gives more realistic test results against current PVE releases. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/scripts/first-boot.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/infrastructure/scripts/first-boot.sh b/tests/infrastructure/scripts/first-boot.sh index 2083737..020d970 100755 --- a/tests/infrastructure/scripts/first-boot.sh +++ b/tests/infrastructure/scripts/first-boot.sh @@ -19,6 +19,7 @@ fi echo "deb http://download.proxmox.com/debian/pve ${SUITE} pve-no-subscription" > /etc/apt/sources.list.d/pve-no-subscription.list apt-get update -qq +apt-get upgrade -y -qq apt-get install -y -qq qemu-guest-agent open-iscsi systemctl start qemu-guest-agent systemctl enable open-iscsi From 5cfd33d9211bad7f5454f4379341770c57bd2f82 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 15:27:48 -0500 Subject: [PATCH 21/26] fix: update GitHub workflow for credential auth + pve-integration paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Volume mounts: /opt/pve-isos → /opt/pve-integration - CACHE_DIR env: /opt/pve-isos → /opt/pve-integration - Test step: PVETEST_APITOKEN → PVETEST_PASSWORD (credential auth) - Cleanup step: add PVE_PASSWORD (needed by terraform destroy) - Remove PVETEST_APITOKEN from optional secrets docs Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/integration-tests.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index db09468..714b97d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -16,14 +16,13 @@ name: Integration Tests # Required repository secrets (for provisioning): # PVE_ENDPOINT - Parent PVE API URL (e.g. https://pve.example.com:8006) # PVE_API_TOKEN - Parent PVE API token (for Terraform + uploads) -# PVE_TEST_PASSWORD - Root password for nested PVE instances +# PVE_TEST_PASSWORD - Root password for nested PVE instances (used for both provision and tests) # # Required repository variables: # PVE_TARGET_NODE - Parent PVE node name (variable, not secret, to avoid log masking) # # Optional secrets (for skip_provision mode): # PVETEST_HOST - Hostname or IP of a pre-existing nested PVE -# PVETEST_APITOKEN - API token for the pre-existing nested PVE # # WARNING: Integration tests create and destroy real resources on the target # node. Never run against production. @@ -54,7 +53,7 @@ permissions: env: SCRIPTS_DIR: tests/infrastructure/scripts TEST_IMAGE: ghcr.io/goodolclint/psproxmoxve-integration - CACHE_DIR: /opt/pve-isos + CACHE_DIR: /opt/pve-integration jobs: # ── Build module artifact (GitHub-hosted) ──────────────────────────── @@ -118,7 +117,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} volumes: - - /opt/pve-isos:/opt/pve-isos + - /opt/pve-integration:/opt/pve-integration # WARNING: The Docker socket is mounted to allow storage containers to be # managed from within the job container. This grants root-equivalent access # to the runner's Docker daemon. Only use on dedicated, isolated self-hosted @@ -148,7 +147,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} volumes: - - /opt/pve-isos:/opt/pve-isos + - /opt/pve-integration:/opt/pve-integration strategy: fail-fast: false max-parallel: 2 @@ -171,7 +170,7 @@ jobs: MODULE_ARTIFACT: ./publish/netstandard2.0 SKIP_PROVISION: ${{ inputs.skip_provision || 'false' }} PVETEST_HOST: ${{ secrets.PVETEST_HOST }} - PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} + PVETEST_PASSWORD: ${{ secrets.PVE_TEST_PASSWORD }} run: bash ${SCRIPTS_DIR}/run-integration.sh test ${{ matrix.pve_version }} - name: Upload test results @@ -193,7 +192,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} volumes: - - /opt/pve-isos:/opt/pve-isos + - /opt/pve-integration:/opt/pve-integration # WARNING: The Docker socket is mounted to allow storage containers to be # stopped from within the job container. This grants root-equivalent access # to the runner's Docker daemon. Only use on dedicated, isolated self-hosted @@ -209,6 +208,7 @@ jobs: PVE_ENDPOINT: ${{ secrets.PVE_ENDPOINT }} PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} PVE_TARGET_NODE: ${{ vars.PVE_TARGET_NODE }} + PVE_PASSWORD: ${{ secrets.PVE_TEST_PASSWORD }} run: bash ${SCRIPTS_DIR}/run-integration.sh cleanup # ── Clean up old container images from GHCR ────────────────────────── From a241714125531f415a4141dd13b6e86d56d2f010 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 15:32:25 -0500 Subject: [PATCH 22/26] fix: update CloudInit regenerate test for PUT endpoint Test was still expecting GetAsync on cloudinit/dump but the service now calls PutAsync on cloudinit (regenerate endpoint). Updated mock setup and assertions to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/CloudInitServiceTests.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/PSProxmoxVE.Core.Tests/Services/CloudInitServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/CloudInitServiceTests.cs index 50e2c76..fd898bc 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/CloudInitServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/CloudInitServiceTests.cs @@ -100,12 +100,12 @@ namespace PSProxmoxVE.Core.Tests.Services } [Fact] - public void RegenerateCloudInitImage_CallsGetAsyncAndReturnsData() + public void RegenerateCloudInitImage_CallsPutAsyncAndReturnsUpid() { // Arrange - var json = @"{""data"": ""#cloud-config\nuser: ubuntu\npassword: secret\n""}"; + var json = @"{""data"": ""UPID:pve1:00001234:00005678:12345678:cloudinit:100:root@pam:""}"; var mockClient = new Mock(); - mockClient.Setup(c => c.GetAsync("nodes/pve1/qemu/100/cloudinit/dump?type=user")) + mockClient.Setup(c => c.PutAsync("nodes/pve1/qemu/100/cloudinit", null)) .ReturnsAsync(json); var service = new CloudInitService(mockClient.Object); @@ -113,9 +113,8 @@ namespace PSProxmoxVE.Core.Tests.Services var result = service.RegenerateCloudInitImage(CreateSession(), "pve1", 100); // Assert - Assert.Contains("#cloud-config", result); - Assert.Contains("ubuntu", result); - mockClient.Verify(c => c.GetAsync("nodes/pve1/qemu/100/cloudinit/dump?type=user"), Times.Once); + Assert.Contains("UPID:", result); + mockClient.Verify(c => c.PutAsync("nodes/pve1/qemu/100/cloudinit", null), Times.Once); } [Fact] From 7cb64811b7f13e8d18ebf7165855a402d3eabafb Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 15:38:21 -0500 Subject: [PATCH 23/26] fix: pin answer server image digest + fix taint key mismatch Copilot review fixes: - Pin answer server Docker image to SHA256 digest instead of :latest for reproducible builds - Fix cmd_taint: ISO resources are keyed by version ("9") not node ("9a"), so taint was no-op. Now taints ISOs by version and VMs by node separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/scripts/run-integration.sh | 14 +++++++++++--- tests/infrastructure/storage.tf | 4 +++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index ae28d9c..e6d8037 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -639,10 +639,12 @@ cmd_force_cleanup() { cmd_taint() { local requested="${1:-all}" + local taint_versions="$PVE_VERSIONS" local taint_nodes="$ALL_NODES" if [[ "$requested" != "all" ]]; then + taint_versions="$requested" taint_nodes="" - for v in $requested; do + for v in $taint_versions; do taint_nodes="$taint_nodes ${v}a ${v}b" done fi @@ -650,12 +652,18 @@ cmd_taint() { log "Tainting PVE VMs for reprovisioning..." (cd "$INFRA_DIR" && terraform init -input=false 2>/dev/null) + # Taint ISOs (keyed by version, e.g. "9") + for v in $taint_versions; do + log " Tainting ISO: PVE $v" + (cd "$INFRA_DIR" && \ + terraform taint "proxmox_virtual_environment_file.auto_iso[\"$v\"]") 2>/dev/null || true + done + + # Taint VMs (keyed by node, e.g. "9a") for node in $taint_nodes; do log " Tainting VM: $node" (cd "$INFRA_DIR" && \ terraform taint "proxmox_virtual_environment_vm.nested_pve[\"$node\"]") 2>/dev/null || true - (cd "$INFRA_DIR" && \ - terraform taint "proxmox_virtual_environment_file.auto_iso[\"$node\"]") 2>/dev/null || true done log "Taint complete. Next 'provision' will recreate these VMs." diff --git a/tests/infrastructure/storage.tf b/tests/infrastructure/storage.tf index be34495..fc5b214 100644 --- a/tests/infrastructure/storage.tf +++ b/tests/infrastructure/storage.tf @@ -2,7 +2,9 @@ resource "docker_container" "answer_server" { name = "pvetest-answer-server" - image = "slothcroissant/proxmox-auto-installer-server:latest" + # Pin to digest for reproducibility. Update by pulling latest and running: + # docker inspect slothcroissant/proxmox-auto-installer-server:latest --format '{{index .RepoDigests 0}}' + image = "slothcroissant/proxmox-auto-installer-server@sha256:0f45d7bfe6e3cc76aa00fc578e40b80b9054e377db18a79122866fe5522bc7ed" restart = "unless-stopped" must_run = true start = true From 7ffc57e16f7a602ba09d68a6b5c8d70359128375 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 15:50:07 -0500 Subject: [PATCH 24/26] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20validation,=20error=20handling,=20MAC=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ValidateSet('8','9','all') to dev.ps1 -Version parameter - Fix Shell warning to reference $DevContainer not $InfraContainer - Fix Skip-IfNoNodeB to check $PasswordB not $Password - Pass PVE_TARGET_NODE to wait-for-pve.sh instead of auto-discovering - Add error default cases to all pve_* helper functions - Lowercase MAC addresses for answer server matching - Create answer file paths before terraform destroy in cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integration/_IntegrationHelper.ps1 | 2 +- tests/dev.ps1 | 3 ++- .../infrastructure/scripts/run-integration.sh | 18 +++++++++++++----- tests/infrastructure/scripts/wait-for-pve.sh | 12 +++++------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 index 5eca74f..c77e069 100644 --- a/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 @@ -81,7 +81,7 @@ function script:Skip-IfNoOva { function script:Skip-IfNoNodeB { if (Skip-IfNoTarget) { return $true } - if (-not $script:HostB -or -not $script:Password) { + if (-not $script:HostB -or -not $script:PasswordB) { Set-ItResult -Skipped -Because 'Multi-node env vars not set (PVETEST_HOST_B, PVETEST_PASSWORD)' return $true } diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 895fe72..075fd12 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -103,6 +103,7 @@ param( [string[]] $Tests, [Alias('PveVersion')] + [ValidateSet('8', '9', 'all')] [string] $Version = 'all', [string] $DockerHost, @@ -214,7 +215,7 @@ if ($Rebuild) { if ($Shell) { if ($DockerHost) { - Write-Warning "Interactive shell over remote Docker is not supported. Use: ssh $DockerHost 'docker exec -it $InfraContainer pwsh -NoProfile'" + Write-Warning "Interactive shell over remote Docker is not supported. Use: ssh $DockerHost 'docker exec -it $DevContainer pwsh -NoProfile'" return } Start-DevContainer diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index e6d8037..1a56f75 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -64,6 +64,7 @@ pve_iso() { case "$ver" in 9) echo "${PVE9_ISO:-proxmox-ve_9.1-1.iso}" ;; 8) echo "${PVE8_ISO:-proxmox-ve_8.4-1.iso}" ;; + *) echo "ERROR: unknown PVE version '$ver'" >&2; exit 1 ;; esac } @@ -71,6 +72,7 @@ pve_vmid() { case "$1" in 9a) echo "${PVE9A_VMID:-99091}" ;; 9b) echo "${PVE9B_VMID:-99092}" ;; 8a) echo "${PVE8A_VMID:-99081}" ;; 8b) echo "${PVE8B_VMID:-99082}" ;; + *) echo "ERROR: unknown node '$1'" >&2; exit 1 ;; esac } @@ -78,6 +80,7 @@ pve_vmname() { case "$1" in 9a) echo "pve-test-9a" ;; 9b) echo "pve-test-9b" ;; 8a) echo "pve-test-8a" ;; 8b) echo "pve-test-8b" ;; + *) echo "ERROR: unknown node '$1'" >&2; exit 1 ;; esac } @@ -85,15 +88,16 @@ pve_fqdn() { case "$1" in 9a) echo "pve9a.test.local" ;; 9b) echo "pve9b.test.local" ;; 8a) echo "pve8a.test.local" ;; 8b) echo "pve8b.test.local" ;; + *) echo "ERROR: unknown node '$1'" >&2; exit 1 ;; esac } -# Deterministic MAC addresses for each node. -# Scheme: AA:BB:CC:00:: +# Deterministic MAC addresses for each node (lowercase for answer server matching). pve_mac() { case "$1" in - 9a) echo "AA:BB:CC:00:09:1A" ;; 9b) echo "AA:BB:CC:00:09:1B" ;; - 8a) echo "AA:BB:CC:00:08:1A" ;; 8b) echo "AA:BB:CC:00:08:1B" ;; + 9a) echo "aa:bb:cc:00:09:1a" ;; 9b) echo "aa:bb:cc:00:09:1b" ;; + 8a) echo "aa:bb:cc:00:08:1a" ;; 8b) echo "aa:bb:cc:00:08:1b" ;; + *) echo "ERROR: unknown node '$1'" >&2; exit 1 ;; esac } @@ -299,7 +303,7 @@ cmd_provision() { log "Waiting for $node to boot..." local output output=$(bash "$SCRIPT_DIR/wait-for-pve.sh" \ - "$PVE_ENDPOINT" "$PVE_API_TOKEN" \ + "$PVE_ENDPOINT" "$PVE_API_TOKEN" "$PVE_TARGET_NODE" \ "$(pve_vmid "$node")" "$PVE_PASSWORD" 900) local ip node_name ip=$(echo "$output" | grep "^IP=" | cut -d= -f2) @@ -552,6 +556,10 @@ cmd_cleanup() { (cd "$INFRA_DIR" && terraform init -input=false 2>/dev/null) + # Ensure answer file paths exist (terraform destroy validates host_path mounts) + mkdir -p "$WORK_DIR/answers" + touch "$WORK_DIR/default-answer.toml" + # Build -target flags when destroying a subset local tf_targets="" if [[ "$requested" != "all" ]]; then diff --git a/tests/infrastructure/scripts/wait-for-pve.sh b/tests/infrastructure/scripts/wait-for-pve.sh index 0b65f3a..288c86b 100755 --- a/tests/infrastructure/scripts/wait-for-pve.sh +++ b/tests/infrastructure/scripts/wait-for-pve.sh @@ -2,7 +2,7 @@ # Wait for a fresh nested PVE instance to boot, discover its IP via the QEMU guest agent, # then wait for the PVE API to become responsive. # -# Usage: wait-for-pve.sh [max-wait-seconds] +# Usage: wait-for-pve.sh [max-wait-seconds] # Outputs: # IP= # NODE= @@ -10,14 +10,12 @@ set -euo pipefail PARENT_ENDPOINT="${1%/}" PARENT_TOKEN="$2" -VM_ID="$3" -ROOT_PASSWORD="$4" -MAX_WAIT="${5:-600}" +PARENT_NODE="$3" +VM_ID="$4" +ROOT_PASSWORD="$5" +MAX_WAIT="${6:-600}" INTERVAL=10 PARENT_API="${PARENT_ENDPOINT}/api2/json" -NODES_JSON=$(curl -sk -H "Authorization: PVEAPIToken=${PARENT_TOKEN}" \ - "${PARENT_API}/nodes") -PARENT_NODE=$(echo "$NODES_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['node'])") # --- Phase 1: Discover IP via QEMU guest agent --- echo "Waiting for guest agent on VM ${VM_ID} (node: ${PARENT_NODE})..." From 03f6cc86eba19c2f9c1ec4b84a579ed6a0a137bb Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 16:00:16 -0500 Subject: [PATCH 25/26] fix: address Copilot review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update header comments: PVETEST_APITOKEN → PVETEST_PASSWORD, CACHE_DIR default → /opt/pve-integration - Pin ubuntu and NFS server Docker images to SHA256 digests - Fix cmd_all to pass version to provision and cleanup - Keep .terraform.lock.hcl in force cleanup (provider reproducibility) - Remove || true from terraform destroy in cleanup (propagate errors; use -Force for best-effort recovery) Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infrastructure/scripts/run-integration.sh | 16 ++++++++-------- tests/infrastructure/storage.tf | 8 ++++++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 1a56f75..837948f 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -27,12 +27,12 @@ # # Required env vars (test with pre-existing PVE): # PVETEST_HOST PVE host IP (node A) -# PVETEST_APITOKEN PVE API token (node A) +# PVETEST_PASSWORD Root password for the PVE instances # Set SKIP_PROVISION=true # # Optional env vars: -# CACHE_DIR ISO/image cache (default: /opt/pve-isos) -# WORK_DIR Temp dir for build artifacts (default: $RUNNER_TEMP or /tmp/pve-integration) +# CACHE_DIR ISO/image cache (default: /opt/pve-integration) +# WORK_DIR Temp dir for build artifacts (default: $CACHE_DIR/work) # CONFIG_FILE Test config JSON path (default: $WORK_DIR/config.json) # MODULE_ARTIFACT Path to built module DLLs (default: ./publish/netstandard2.0) # PVE_VERSIONS Space-separated versions to provision (default: "9 8") @@ -584,7 +584,7 @@ cmd_cleanup() { TF_VAR_docker_host_ip="${storage_ip:-127.0.0.1}" \ TF_VAR_answer_files_dir="${WORK_DIR}/answers" \ TF_VAR_default_answer_file="${WORK_DIR}/default-answer.toml" \ - terraform destroy -auto-approve -input=false -var-file="$tfvars" $tf_targets) || true + terraform destroy -auto-approve -input=false -var-file="$tfvars" $tf_targets) # Clean up work directory when destroying all if [[ "$requested" == "all" ]]; then @@ -633,10 +633,10 @@ cmd_force_cleanup() { docker rm -f pvetest-iscsi pvetest-nfs pvetest-answer-server 2>/dev/null || true docker volume rm pvetest-iscsi-data pvetest-nfs-data 2>/dev/null || true - # Remove Terraform state so next provision starts clean + # Remove Terraform state so next provision starts clean. + # Keep .terraform.lock.hcl (provider version lock) for reproducibility. log "Removing Terraform state..." rm -f "$INFRA_DIR/terraform.tfstate" "$INFRA_DIR/terraform.tfstate.backup" - rm -f "$INFRA_DIR/.terraform.lock.hcl" rm -rf "$INFRA_DIR/.terraform" # Remove work artifacts @@ -681,9 +681,9 @@ cmd_all() { local test_versions="${1:-all}" local test_exit=0 - trap 'log "Running cleanup after test run..."; cmd_cleanup || true' EXIT + trap 'log "Running cleanup after test run..."; cmd_cleanup "$test_versions" || true' EXIT - cmd_provision + cmd_provision "$test_versions" cmd_test "$test_versions" || test_exit=$? if [[ $test_exit -ne 0 ]]; then diff --git a/tests/infrastructure/storage.tf b/tests/infrastructure/storage.tf index fc5b214..ba96439 100644 --- a/tests/infrastructure/storage.tf +++ b/tests/infrastructure/storage.tf @@ -25,7 +25,9 @@ resource "docker_container" "answer_server" { # ── Docker images & volumes ────────────────────────────────────────── resource "docker_image" "ubuntu" { - name = "ubuntu:24.04" + # Pin to digest for reproducibility. Update: + # docker pull ubuntu:24.04 && docker inspect ubuntu:24.04 --format '{{index .RepoDigests 0}}' + name = "ubuntu@sha256:186072bba1b2f436cbb91ef2567abca677337cfc786c86e107d25b7072feef0c" } resource "docker_volume" "iscsi_data" { @@ -85,7 +87,9 @@ resource "docker_container" "iscsi_target" { resource "docker_container" "nfs_server" { name = "pvetest-nfs" - image = "erichough/nfs-server:2.2.1" + # Pin to digest for reproducibility. Update: + # docker pull erichough/nfs-server:2.2.1 && docker inspect erichough/nfs-server:2.2.1 --format '{{index .RepoDigests 0}}' + image = "erichough/nfs-server@sha256:1efd4ece380c5ba27479417585224ef857006daa46ab84560a28c1224bc71e9e" privileged = true restart = "unless-stopped" From 94bc1a9d73873e4350ab7d48d1a23cf81e1469d2 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 25 Mar 2026 16:27:04 -0500 Subject: [PATCH 26/26] fix: address Copilot review round 3 - Fix bare Skip-IfNoTarget calls in 13_Firewall and 14_Backup (missing if/return pattern caused tests to run when they should skip) - Validate modifier-only switches in dev.ps1 (-Force/-Reprovision without an action switch now errors instead of defaulting to -Shell) - Add force-cleanup to usage text in run-integration.sh - Add --connect-timeout/--max-time to guest agent curl in wait-for-pve.sh Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Integration/13_Firewall.Tests.ps1 | 26 +++++++++---------- .../Integration/14_Backup.Tests.ps1 | 10 +++---- tests/dev.ps1 | 5 +++- .../infrastructure/scripts/run-integration.sh | 3 ++- tests/infrastructure/scripts/wait-for-pve.sh | 2 +- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 index 14c052f..64d41bd 100644 --- a/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/13_Firewall.Tests.ps1 @@ -35,12 +35,12 @@ Describe 'Firewall — Integration' -Tag 'Integration' { Context 'Firewall — Cluster Rules' { It 'Should create a firewall rule' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { New-PveFirewallRule -Level Cluster -Type in -Action ACCEPT -Proto tcp -Dport '8080' -Comment 'pester-test-rule' -Enable -ErrorAction Stop } | Should -Not -Throw } It 'Should list firewall rules and find the test rule' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } $rules = Get-PveFirewallRule -Level Cluster $rules | Should -Not -BeNullOrEmpty $testRule = $rules | Where-Object { $_.Comment -eq 'pester-test-rule' } @@ -49,7 +49,7 @@ Describe 'Firewall — Integration' -Tag 'Integration' { } It 'Should update the firewall rule' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } if ($null -eq $script:FirewallTestRulePos) { Set-ItResult -Skipped -Because 'No test rule was created' } @@ -57,7 +57,7 @@ Describe 'Firewall — Integration' -Tag 'Integration' { } It 'Should remove the firewall rule' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } if ($null -eq $script:FirewallTestRulePos) { Set-ItResult -Skipped -Because 'No test rule was created' } @@ -65,7 +65,7 @@ Describe 'Firewall — Integration' -Tag 'Integration' { } It 'Should get firewall options' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } $opts = Get-PveFirewallOptions -Level Cluster $opts | Should -Not -BeNullOrEmpty } @@ -73,45 +73,45 @@ Describe 'Firewall — Integration' -Tag 'Integration' { Context 'Firewall — Aliases and IP Sets' { It 'Should create a firewall alias' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { New-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Cidr '192.168.99.0/24' -Comment 'test alias' -ErrorAction Stop } | Should -Not -Throw } It 'Should list and find the alias' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } $aliases = Get-PveFirewallAlias -Level Cluster $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty } It 'Should remove the alias' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw } It 'Should create an IP set' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { New-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Comment 'test ipset' -ErrorAction Stop } | Should -Not -Throw } It 'Should add an entry to the IP set' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { New-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Comment 'test entry' -ErrorAction Stop } | Should -Not -Throw } It 'Should list IP set entries' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } $entries = Get-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' $entries | Should -Not -BeNullOrEmpty ($entries | Where-Object { $_.Cidr -like '10.99.0.0*' }) | Should -Not -BeNullOrEmpty } It 'Should remove the IP set entry' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { Remove-PveFirewallIpSetEntry -Level Cluster -Name 'pester-ipset' -Cidr '10.99.0.0/16' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw } It 'Should remove the IP set' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { Remove-PveFirewallIpSet -Level Cluster -Name 'pester-ipset' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw } } diff --git a/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 index 26c7bcc..212ea6d 100644 --- a/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/14_Backup.Tests.ps1 @@ -22,12 +22,12 @@ Describe 'Backup Jobs — Integration' -Tag 'Integration' { Context 'Backup Jobs' { It 'Should create a backup job' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } { New-PveBackupJob -Schedule 'sat 03:00' -Storage $script:Storage -Mode snapshot -All -Comment 'pester-test-backup' -ErrorAction Stop } | Should -Not -Throw } It 'Should list backup jobs and find the test job' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } $jobs = Get-PveBackupJob $testJob = $jobs | Where-Object { $_.Comment -eq 'pester-test-backup' } $testJob | Should -Not -BeNullOrEmpty @@ -35,7 +35,7 @@ Describe 'Backup Jobs — Integration' -Tag 'Integration' { } It 'Should update the backup job' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } if (-not $script:BackupTestJobId) { Set-ItResult -Skipped -Because 'No test backup job was created' } @@ -43,7 +43,7 @@ Describe 'Backup Jobs — Integration' -Tag 'Integration' { } It 'Should remove the backup job' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } if (-not $script:BackupTestJobId) { Set-ItResult -Skipped -Because 'No test backup job was created' } @@ -51,7 +51,7 @@ Describe 'Backup Jobs — Integration' -Tag 'Integration' { } It 'Should verify the backup job was removed' { - Skip-IfNoTarget + if (Skip-IfNoTarget) { return } if (-not $script:BackupTestJobId) { Set-ItResult -Skipped -Because 'No test backup job was created' } diff --git a/tests/dev.ps1 b/tests/dev.ps1 index 075fd12..de004df 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -114,9 +114,12 @@ param( $ErrorActionPreference = 'Stop' -# If no switches specified, default to -Shell +# If no action switches specified, default to -Shell $anySwitchSet = $Shell -or $Build -or $Test -or $Provision -or $Integration -or $Cleanup -or $Stop -or $Rebuild if (-not $anySwitchSet) { + if ($Force -or $Reprovision) { + throw "-Force and -Reprovision are modifiers — combine with an action switch (e.g. -Cleanup -Force, -Provision -Reprovision)." + } $Shell = $true } diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 837948f..9d84554 100755 --- a/tests/infrastructure/scripts/run-integration.sh +++ b/tests/infrastructure/scripts/run-integration.sh @@ -707,12 +707,13 @@ main() { taint) cmd_taint "$@" ;; all) cmd_all "$@" ;; *) - echo "Usage: $(basename "$0") {provision|test|cleanup|taint|all} [8|9|all] [test-filter]" + echo "Usage: $(basename "$0") {provision|test|cleanup|force-cleanup|taint|all} [8|9|all] [test-filter]" echo "" echo "Subcommands:" echo " provision [8|9|all] Provision nested PVE VMs + storage containers" echo " test [8|9|all] [filter] Run integration tests (default: all versions, no filter)" echo " cleanup [8|9|all] Destroy resources via terraform destroy (default: all)" + echo " force-cleanup [8|9|all] Bypass Terraform — destroy via API + wipe state (recovery)" echo " taint [8|9|all] Mark VMs for recreation on next provision" echo " all [8|9|all] Full lifecycle: provision → test → cleanup" echo "" diff --git a/tests/infrastructure/scripts/wait-for-pve.sh b/tests/infrastructure/scripts/wait-for-pve.sh index 288c86b..e8e4078 100755 --- a/tests/infrastructure/scripts/wait-for-pve.sh +++ b/tests/infrastructure/scripts/wait-for-pve.sh @@ -22,7 +22,7 @@ echo "Waiting for guest agent on VM ${VM_ID} (node: ${PARENT_NODE})..." VM_IP="" elapsed=0 while [ $elapsed -lt $MAX_WAIT ]; do - AGENT_RESPONSE=$(curl -sk \ + AGENT_RESPONSE=$(curl -sk --connect-timeout 5 --max-time 10 \ -H "Authorization: PVEAPIToken=${PARENT_TOKEN}" \ "${PARENT_API}/nodes/${PARENT_NODE}/qemu/${VM_ID}/agent/network-get-interfaces" 2>/dev/null || true)