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
+}
+