Merge pull request #31 from GoodOlClint/refactor/test-orchestration

refactor: redesign dev.ps1 with switch-based CLI and split integration tests
This commit is contained in:
GoodOlClint
2026-03-25 16:28:20 -05:00
committed by GitHub
33 changed files with 2919 additions and 1860 deletions
+7 -7
View File
@@ -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 ──────────────────────────
@@ -105,13 +105,10 @@ namespace PSProxmoxVE.Core.Services
}
/// <summary>
/// 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).
/// </summary>
/// <returns>
/// The raw Cloud-Init dump (user-data) string as reported by the PVE API.
/// </returns>
/// <returns>The UPID of the regeneration task.</returns>
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
@@ -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))
{
@@ -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<IPveHttpClient>();
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]
@@ -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)
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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' {
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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 { }
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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 { }
}
+159 -119
View File
@@ -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 {
+1 -1
View File
@@ -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
+16 -5
View File
@@ -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 {
+15
View File
@@ -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"
}
@@ -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
+374 -114
View File
@@ -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
+94
View File
@@ -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 <parent-pve-endpoint> <parent-api-token> <parent-node> <vm-id> <root-password> [max-wait-seconds]
# Outputs:
# IP=<discovered-ip>
# NODE=<pve-hostname>
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}"
+112
View File
@@ -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)",
]
}
+38 -4
View File
@@ -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
}