mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-07-26 07:58:14 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-45
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+94
@@ -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}"
|
||||
@@ -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)",
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user