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 ────────────────────────── 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)) { 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] 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..d719a30 --- /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) { + 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 +} + +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 + } + + 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..7af1567 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/07_Snapshots.Tests.ps1 @@ -0,0 +1,93 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $script:SnapVmId = $null + + function script:Skip-IfNoSnapVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:SnapVmId) { + Set-ItResult -Skipped -Because 'Snapshot test VM was not created' + return $true + } + return $false + } +} + +AfterAll { + 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-IfNoSnapVm) { return } + + $snapName = 'pester-snap' + + # Create + $createTask = New-PveSnapshot ` + -Node $script:Node ` + -VmId $script:SnapVmId ` + -Name $snapName ` + -Description 'Created by Pester integration test' ` + -Wait + + $createTask | Should -Not -BeNullOrEmpty + + # List and verify + $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:SnapVmId ` + -Name $snapName ` + -Confirm:$false ` + -Wait } | Should -Not -Throw + + # Remove + $removeTask = Remove-PveSnapshot ` + -Node $script:Node ` + -VmId $script:SnapVmId ` + -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..83efd2a --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/09_CloudInit.Tests.ps1 @@ -0,0 +1,85 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $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 { + 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-IfNoCiVm) { return } + + { Get-PveCloudInitConfig -Node $script:Node -VmId $script:CiVmId -ErrorAction Stop } | + Should -Not -Throw + } + + It 'Should set cloud-init config' { + if (Skip-IfNoCiVm) { return } + + { 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/10_Containers.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 new file mode 100644 index 0000000..f5cf4d3 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/10_Containers.Tests.ps1 @@ -0,0 +1,217 @@ +#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 -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 { } + } + } + 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..e72d5b4 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/11_LinuxVM.Tests.ps1 @@ -0,0 +1,274 @@ +#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) + } + + 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..64d41bd --- /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' { + 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' { + if (Skip-IfNoTarget) { return } + $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' { + if (Skip-IfNoTarget) { return } + 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' { + if (Skip-IfNoTarget) { return } + 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' { + if (Skip-IfNoTarget) { return } + $opts = Get-PveFirewallOptions -Level Cluster + $opts | Should -Not -BeNullOrEmpty + } + } + + Context 'Firewall — Aliases and IP Sets' { + It 'Should create a firewall alias' { + 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' { + if (Skip-IfNoTarget) { return } + $aliases = Get-PveFirewallAlias -Level Cluster + $aliases | Where-Object { $_.Name -eq 'pester-alias' } | Should -Not -BeNullOrEmpty + } + + It 'Should remove the alias' { + if (Skip-IfNoTarget) { return } + { Remove-PveFirewallAlias -Level Cluster -Name 'pester-alias' -Confirm:$false -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should create an IP set' { + 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' { + 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' { + 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' { + 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' { + 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 new file mode 100644 index 0000000..212ea6d --- /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' { + 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' { + if (Skip-IfNoTarget) { return } + $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' { + if (Skip-IfNoTarget) { return } + 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' { + if (Skip-IfNoTarget) { return } + 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' { + if (Skip-IfNoTarget) { return } + 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..695cd4f --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/15_Tasks.Tests.ps1 @@ -0,0 +1,79 @@ +#Requires -Module Pester + +BeforeAll { + . $PSScriptRoot/_IntegrationHelper.ps1 + Connect-TestPve + + $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 { + 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-IfNoTaskVm) { return } + + # Start the VM to get a task object + $startResult = Start-PveVm -Node $script:Node -VmId $script:TaskVmId + $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 + + # 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/99_Cleanup.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 new file mode 100644 index 0000000..247324b --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/99_Cleanup.Tests.ps1 @@ -0,0 +1,198 @@ +#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 -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 + } catch { + try { + Remove-PveVm -Node $script:Node -VmId $vm.VmId -Force -Purge -Confirm:$false -ErrorAction SilentlyContinue + } catch { } + } + } + # Wait for removals to complete + Start-Sleep -Seconds 3 + + # 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 -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 | + 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..c77e069 --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Integration/_IntegrationHelper.ps1 @@ -0,0 +1,147 @@ +<# +.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:PasswordB) { + 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 { } +} diff --git a/tests/dev.ps1 b/tests/dev.ps1 index d08c13a..de004df 100644 --- a/tests/dev.ps1 +++ b/tests/dev.ps1 @@ -7,87 +7,128 @@ 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 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 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: + -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, + [switch] $Reprovision, + [switch] $Force, - [Parameter(Position = 1)] - [string] $PveVersion = 'all', + [string[]] $Tests, + + [Alias('PveVersion')] + [ValidateSet('8', '9', 'all')] + [string] $Version = 'all', - [Parameter()] [string] $DockerHost, - [Parameter()] [Alias('k')] [switch] $NoCleanup ) $ErrorActionPreference = 'Stop' +# 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 +} + # 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 +136,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 +153,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: @@ -135,7 +167,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) @@ -146,7 +178,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 +197,87 @@ 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 $DevContainer 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 + 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)" } +} + +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-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." } - '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)" } +} + +if ($Cleanup) { + Start-InfraContainer + if ($Force) { + 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 } - - '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 ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } } } finally { 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/main.tf b/tests/infrastructure/main.tf index 2199061..df66f34 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,14 +18,20 @@ 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 + 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 } } @@ -60,13 +70,14 @@ 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" } 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/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/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 diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh index 6a152c4..9d84554 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 [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 [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. +# 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) @@ -21,13 +27,13 @@ # # 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) -# CONFIG_FILE Test config JSON path (default: $CACHE_DIR/test-config.json) +# 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") # STORAGE_ISCSI_IQN iSCSI IQN for storage target (default: iqn.2024-01.local.test:storage) @@ -40,9 +46,9 @@ 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}}" -CONFIG_FILE="${CONFIG_FILE:-$CACHE_DIR/test-config.json}" +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}" SKIP_PROVISION="${SKIP_PROVISION:-false}" @@ -58,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 } @@ -65,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 } @@ -72,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 } @@ -79,6 +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 (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" ;; + *) echo "ERROR: unknown node '$1'" >&2; exit 1 ;; esac } @@ -110,9 +129,20 @@ require_env() { # ── Subcommands ───────────────────────────────────────────────────── 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 + 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 @@ -123,7 +153,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 @@ -135,29 +165,61 @@ 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) + # 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 + 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" + + # 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') - for node in $ALL_NODES; do - local fqdn + 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/answer-${node}.toml" + "$INFRA_DIR/answer.toml.tftpl" > "$WORK_DIR/answers/${mac}.toml" done - # Prepare auto-install ISOs (one per node — each has unique answer file) - for node in $ALL_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" + # 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 @@ -166,30 +228,64 @@ 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" + + # 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" + 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 iso_name - iso_name="$(pve_iso "$node")" - local iso_path="$WORK_DIR/${iso_name%.iso}-${node}-auto.iso" - local vm_id + local v="${node%[ab]}" + local vm_id vm_name mac vm_id="$(pve_vmid "$node")" - local vm_name vm_name="$(pve_vmname "$node")" + 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" \ - '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name}}' \ + --arg mac_address "$mac" \ + '. + {($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. + # 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 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_vm.nested_pve[\"$node\"]" + done + # 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" + fi + # TMPDIR: use work dir to avoid filling the container's /tmp with multi-GB ISO uploads. (cd "$INFRA_DIR" && \ TMPDIR="$WORK_DIR" \ @@ -197,59 +293,43 @@ 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") + 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) - # 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) - for node in $ALL_NODES; do - log "Waiting for $node to boot and creating API token..." + # Wait for PVE instances to boot and discover IPs + for node in $provision_nodes; do + log "Waiting for $node to boot..." local output - output=$(bash "$SCRIPT_DIR/create-api-token.sh" \ - "$PVE_ENDPOINT" "$PVE_API_TOKEN" \ + output=$(bash "$SCRIPT_DIR/wait-for-pve.sh" \ + "$PVE_ENDPOINT" "$PVE_API_TOKEN" "$PVE_TARGET_NODE" \ "$(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 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 @@ -280,6 +360,7 @@ cmd_provision() { cmd_test() { local requested="${1:-all}" + local test_filter="${2:-}" local versions_to_test if [[ "$requested" == "all" ]]; then @@ -318,15 +399,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:-}" @@ -337,7 +416,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 @@ -345,7 +423,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") @@ -356,12 +433,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 @@ -370,9 +447,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 @@ -380,17 +457,50 @@ cmd_test() { # Run Pester local test_exit=0 - pwsh -NoProfile -Command ' + local pester_filter_arg="" + if [[ -n "$test_filter" ]]; then + pester_filter_arg="$test_filter" + log "Test filter: $test_filter" + fi + + pwsh -NoProfile -Command " + \$PveVersion = '$v' + \$TestFilter = '$pester_filter_arg' Import-Module Pester -MinimumVersion 5.0 - $config = New-PesterConfiguration - $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" - Invoke-Pester -Configuration $config - ' || test_exit=$? + \$config = New-PesterConfiguration + + if (\$TestFilter) { + # Build list of matching test files + \$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\" + } + } + 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\${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)" @@ -404,33 +514,176 @@ cmd_test() { } cmd_cleanup() { + local requested="${1:-all}" log "Starting cleanup..." - # Clean up PVE nodes - for node in $ALL_NODES; do - local iso_name - iso_name="$(pve_iso "$node")" - log "Cleaning up $node (VMID $(pve_vmid "$node"))..." + 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='{}' isos='{}' + for node in $ALL_NODES; do + local v="${node%[ab]}" vm_id vm_name mac + vm_id="$(pve_vmid "$node")" + vm_name="$(pve_vmname "$node")" + mac="$(pve_mac "$node")" + instances="$(jq \ + --arg key "$node" \ + --arg pve_version "$v" \ + --arg vm_name "$vm_name" \ + --argjson vm_id "$vm_id" \ + --arg mac_address "$mac" \ + '. + {($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" --argjson pve_isos "$isos" \ + '{pve_instances: $pve_instances, pve_isos: $pve_isos}' > "$tfvars" + fi + + (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 + 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\"]" + done + log "Destroying PVE $requested nodes only..." + else + log "Destroying all resources..." + fi + + (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}" \ + 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) + + # Clean up work directory when destroying all + if [[ "$requested" == "all" ]]; then + rm -f "$CONFIG_FILE" "$WORK_DIR"/instances.tfvars.json + fi + + 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) + # 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 v iso_name iso_file + vm_id="$(pve_vmid "$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:-}" \ - "$(pve_vmid "$node")" "${iso_name%.iso}-${node}-auto.iso" "$INFRA_DIR" \ + "$vm_id" "$iso_file" "$INFRA_DIR" \ || true done - # Stop storage containers - log "Stopping storage containers..." - docker compose -f "$STORAGE_COMPOSE" down -v 2>/dev/null || true + # 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 - log "Cleanup complete." + # 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 -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_versions="$PVE_VERSIONS" + local taint_nodes="$ALL_NODES" + if [[ "$requested" != "all" ]]; then + taint_versions="$requested" + taint_nodes="" + for v in $taint_versions; 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) + + # 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 + done + + log "Taint complete. Next 'provision' will recreate these VMs." } 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 @@ -450,15 +703,22 @@ main() { provision) cmd_provision "$@" ;; test) cmd_test "$@" ;; cleanup) cmd_cleanup "$@" ;; + force-cleanup) cmd_force_cleanup "$@" ;; + taint) cmd_taint "$@" ;; all) cmd_all "$@" ;; *) - echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all]" + echo "Usage: $(basename "$0") {provision|test|cleanup|force-cleanup|taint|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 [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 "" + echo "Test filter: comma-separated area names matching test filenames." + echo " Examples: Connection,VMs Cluster Storage,Network" exit 1 ;; esac diff --git a/tests/infrastructure/scripts/wait-for-pve.sh b/tests/infrastructure/scripts/wait-for-pve.sh new file mode 100755 index 0000000..e8e4078 --- /dev/null +++ b/tests/infrastructure/scripts/wait-for-pve.sh @@ -0,0 +1,94 @@ +#!/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" +PARENT_NODE="$3" +VM_ID="$4" +ROOT_PASSWORD="$5" +MAX_WAIT="${6:-600}" +INTERVAL=10 +PARENT_API="${PARENT_ENDPOINT}/api2/json" + +# --- 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 --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) + + 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..ba96439 --- /dev/null +++ b/tests/infrastructure/storage.tf @@ -0,0 +1,112 @@ +# ── Answer server container ────────────────────────────────────────── + +resource "docker_container" "answer_server" { + name = "pvetest-answer-server" + # 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 + + 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" { + # 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" { + 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" + # 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" + + 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..d08fbc7 100644 --- a/tests/infrastructure/variables.tf +++ b/tests/infrastructure/variables.tf @@ -21,14 +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 + 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 @@ -71,3 +78,30 @@ 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 +} + +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 +} +