From a9f1c066e068732fb89649f86da9d5b43c783b9e Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Thu, 19 Mar 2026 11:01:05 -0500 Subject: [PATCH] feat(test): add Alpine guest-agent VM for ACPI and template tests Infrastructure: - build-test-image.sh: Downloads Alpine cloud image, uses virt-customize to install qemu-guest-agent, caches at /opt/pve-images/ - prepare-test-vm.sh: SCPs image to nested PVE, creates VM via qm importdisk with cloud-init and agent enabled, waits for agent Workflow: - Build Alpine image step (cached on runner, only built once) - Deploy test VM step after provisioning, passes LINUX_VMID to tests Tests: - Guest Agent VM lifecycle: verify running, ACPI restart, ACPI stop - Templates: convert Linux VM to template, clone from template (New-PveTemplate, New-PveVmFromTemplate) - Fix Get-PveTask: use .Upid property instead of whole PveTask object Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/integration-tests.yml | 22 ++++ .../Integration/Integration.Tests.ps1 | 104 +++++++++++++++++- .../scripts/build-test-image.sh | 50 +++++++++ .../infrastructure/scripts/prepare-test-vm.sh | 89 +++++++++++++++ 4 files changed, 260 insertions(+), 5 deletions(-) create mode 100755 tests/infrastructure/scripts/build-test-image.sh create mode 100755 tests/infrastructure/scripts/prepare-test-vm.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d9c8945..9c1b7c8 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -176,6 +176,27 @@ jobs: exit 1 fi + # ── Prepare test Linux VM ────────────────────────────────────────── + + - name: Build Alpine test image (cached) + if: inputs.skip_provision != true + shell: bash + run: | + bash ${SCRIPTS_DIR}/build-test-image.sh /opt/pve-images/alpine-guest-agent.qcow2 + + - name: Deploy test Linux VM to nested PVE + if: inputs.skip_provision != true + id: linux_vm + shell: bash + run: | + OUTPUT=$(bash ${SCRIPTS_DIR}/prepare-test-vm.sh \ + "${{ steps.provision.outputs.host }}" \ + "${PVE_PASSWORD}" \ + "/opt/pve-images/alpine-guest-agent.qcow2" \ + 200) + LINUX_VMID=$(echo "$OUTPUT" | grep "^LINUX_VMID=" | cut -d= -f2) + echo "linux_vmid=${LINUX_VMID}" >> "$GITHUB_OUTPUT" + # ── Build and test ───────────────────────────────────────────────── - name: Build module @@ -209,6 +230,7 @@ jobs: PVETEST_STORAGE: ${{ steps.target.outputs.storage }} PVETEST_ISO_PATH: ${{ runner.temp }}/pvetest.iso PVETEST_PVE_VERSION: ${{ matrix.pve_version }} + PVETEST_LINUX_VMID: ${{ steps.linux_vm.outputs.linux_vmid }} run: | Import-Module Pester -MinimumVersion 5.0 $config = New-PesterConfiguration diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 index 3f94171..b69fa4f 100644 --- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 @@ -64,6 +64,8 @@ BeforeAll { $script:Storage = [System.Environment]::GetEnvironmentVariable('PVETEST_STORAGE') $script:IsoPath = [System.Environment]::GetEnvironmentVariable('PVETEST_ISO_PATH') $script:ExpectedPveVersion = [System.Environment]::GetEnvironmentVariable('PVETEST_PVE_VERSION') + $linuxVmEnv = [System.Environment]::GetEnvironmentVariable('PVETEST_LINUX_VMID') + $script:LinuxVmId = if ($linuxVmEnv) { [int]$linuxVmEnv } else { $null } # Track resources created during the run so AfterAll can clean up. $script:CreatedVmIds = [System.Collections.Generic.List[int]]::new() @@ -88,6 +90,15 @@ BeforeAll { } return $false } + + function script:Skip-IfNoLinuxVm { + if (Skip-IfNoTarget) { return $true } + if ($null -eq $script:LinuxVmId) { + Set-ItResult -Skipped -Because 'No Linux VM with guest agent available (PVETEST_LINUX_VMID not set)' + return $true + } + return $false + } } AfterAll { @@ -500,16 +511,18 @@ Describe 'Integration Tests' -Tag 'Integration' { It 'Should get a task by UPID and wait for completion' { if (Skip-IfNoTestVm) { return } - # Start the VM to get a task UPID - $upid = Start-PveVm -Node $script:Node -VmId $script:TestVmId - $upid | Should -Not -BeNullOrEmpty + # Start the VM to get a task object + $startResult = Start-PveVm -Node $script:Node -VmId $script:TestVmId + $startResult | Should -Not -BeNullOrEmpty + $startResult.Upid | Should -Not -BeNullOrEmpty # Wait for the task to complete - { Wait-PveTask -Node $script:Node -Upid $upid } | Should -Not -Throw + { Wait-PveTask -Node $script:Node -Upid $startResult.Upid } | Should -Not -Throw # Get the task status - $task = Get-PveTask -Node $script:Node -Upid $upid + $task = Get-PveTask -Node $script:Node -Upid $startResult.Upid $task | Should -Not -BeNullOrEmpty + $task.IsSuccessful | Should -BeTrue # Clean up Stop-PveVm -Node $script:Node -VmId $script:TestVmId -Wait | Out-Null @@ -547,6 +560,87 @@ Describe 'Integration Tests' -Tag 'Integration' { } } + # ----------------------------------------------------------------------- + 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 gracefully restart a VM via ACPI (Restart-PveVm)' { + if (Skip-IfNoLinuxVm) { return } + + $task = Restart-PveVm -Node $script:Node -VmId $script:LinuxVmId -Wait + $task | Should -Not -BeNullOrEmpty + + # Wait a moment for the VM to come back up + Start-Sleep -Seconds 10 + $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 + $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 | Out-Null + } + + { New-PveTemplate -Node $script:Node -VmId $script:LinuxVmId -ErrorAction Stop } | + Should -Not -Throw + + # 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) + } + } + # ----------------------------------------------------------------------- Context 'VM Cleanup' { It 'Should remove a VM' { diff --git a/tests/infrastructure/scripts/build-test-image.sh b/tests/infrastructure/scripts/build-test-image.sh new file mode 100755 index 0000000..ffb16e7 --- /dev/null +++ b/tests/infrastructure/scripts/build-test-image.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Builds a small Alpine Linux cloud image with qemu-guest-agent pre-installed. +# The image is cached on the runner so it only needs to be built once. +# +# Prerequisites: libguestfs-tools (virt-customize), curl, qemu-utils +# Install on Debian/Ubuntu: apt-get install -y libguestfs-tools curl qemu-utils +# +# Usage: build-test-image.sh +# e.g.: build-test-image.sh /opt/pve-images/alpine-guest-agent.qcow2 + +set -euo pipefail + +OUTPUT="${1:?Usage: build-test-image.sh }" + +# Skip if image already exists +if [ -f "$OUTPUT" ]; then + echo "Image already exists at $OUTPUT, skipping build" + exit 0 +fi + +ALPINE_VERSION="3.21" +ALPINE_RELEASE="3.21.3" +IMAGE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION}/releases/cloud/nocloud_alpine-${ALPINE_RELEASE}-x86_64-bios-cloudinit-r0.qcow2" + +TEMP_DIR=$(mktemp -d) +TEMP_IMAGE="${TEMP_DIR}/alpine-cloud.qcow2" + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +echo "=== Building Alpine test image ===" +echo "Downloading Alpine ${ALPINE_RELEASE} cloud image..." +curl -sL -o "$TEMP_IMAGE" "$IMAGE_URL" + +echo "Customizing image (installing qemu-guest-agent)..." +# virt-customize modifies the image in place: +# - Install qemu-guest-agent package +# - Enable the service on boot (OpenRC) +# - Ensure the virtio serial device is available for guest agent communication +virt-customize -a "$TEMP_IMAGE" \ + --install qemu-guest-agent \ + --run-command 'rc-update add qemu-guest-agent default' \ + --run-command 'echo "GA_PATH=/dev/vport2p1" >> /etc/conf.d/qemu-guest-agent' + +# Move to final location +mkdir -p "$(dirname "$OUTPUT")" +mv "$TEMP_IMAGE" "$OUTPUT" +echo "Image ready at $OUTPUT ($(du -h "$OUTPUT" | cut -f1))" diff --git a/tests/infrastructure/scripts/prepare-test-vm.sh b/tests/infrastructure/scripts/prepare-test-vm.sh new file mode 100755 index 0000000..b2269a0 --- /dev/null +++ b/tests/infrastructure/scripts/prepare-test-vm.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Deploys the pre-built Alpine test image to a nested PVE instance and creates +# a VM with guest agent enabled. Waits for the guest agent to respond. +# +# The VM gets: +# - The Alpine cloud image imported as its boot disk +# - Cloud-init drive for initial configuration +# - Guest agent enabled (agent=1) +# - DHCP networking on vmbr0 +# +# Usage: prepare-test-vm.sh +# +# Outputs (to stdout, for capture by caller): +# LINUX_VMID= + +set -euo pipefail + +NESTED_IP="${1:?Usage: prepare-test-vm.sh }" +ROOT_PASS="$2" +IMAGE_PATH="$3" +VMID="$4" + +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" +SSH_CMD="sshpass -p ${ROOT_PASS} ssh ${SSH_OPTS} root@${NESTED_IP}" +SCP_CMD="sshpass -p ${ROOT_PASS} scp ${SSH_OPTS}" + +echo "=== Preparing test Linux VM (VMID ${VMID}) on ${NESTED_IP} ===" + +# Copy image to nested PVE +echo "Uploading Alpine image to nested PVE..." +${SCP_CMD} "${IMAGE_PATH}" "root@${NESTED_IP}:/tmp/alpine-test.qcow2" + +# Create VM and import disk +echo "Creating VM and importing disk..." +${SSH_CMD} bash <&1 | tail -1 + +# Attach the imported disk and configure boot +qm set ${VMID} \ + --scsi0 local-lvm:vm-${VMID}-disk-0 \ + --boot order=scsi0 \ + --serial0 socket + +# Add cloud-init drive +qm set ${VMID} --ide2 local-lvm:cloudinit + +# Set cloud-init config (root user with password) +qm set ${VMID} \ + --ciuser root \ + --cipassword "${ROOT_PASS}" \ + --ipconfig0 ip=dhcp + +# Start the VM +qm start ${VMID} + +# Clean up uploaded image +rm -f /tmp/alpine-test.qcow2 +REMOTE + +# Wait for guest agent to respond +echo "Waiting for guest agent on VM ${VMID}..." +TIMEOUT=120 +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + if ${SSH_CMD} "qm agent ${VMID} ping" 2>/dev/null; then + echo "Guest agent responding on VM ${VMID}" + echo "LINUX_VMID=${VMID}" + exit 0 + fi + sleep 5 + ELAPSED=$((ELAPSED + 5)) + echo " Waiting... (${ELAPSED}s / ${TIMEOUT}s)" +done + +echo "ERROR: Timeout waiting for guest agent on VM ${VMID}" >&2 +exit 1