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) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-19 11:01:05 -05:00
parent 84029d8871
commit a9f1c066e0
4 changed files with 260 additions and 5 deletions
+22
View File
@@ -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
@@ -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' {
+50
View File
@@ -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 <output-path>
# e.g.: build-test-image.sh /opt/pve-images/alpine-guest-agent.qcow2
set -euo pipefail
OUTPUT="${1:?Usage: build-test-image.sh <output-path>}"
# 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))"
+89
View File
@@ -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 <nested-pve-ip> <root-password> <image-path> <vm-id>
#
# Outputs (to stdout, for capture by caller):
# LINUX_VMID=<vm-id>
set -euo pipefail
NESTED_IP="${1:?Usage: prepare-test-vm.sh <ip> <password> <image-path> <vm-id>}"
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 <<REMOTE
set -e
# Create the VM shell
qm create ${VMID} \
--name alpine-test \
--memory 256 \
--cores 1 \
--net0 virtio,bridge=vmbr0 \
--agent 1 \
--ostype l26 \
--scsihw virtio-scsi-single
# Import the disk image to local-lvm
qm importdisk ${VMID} /tmp/alpine-test.qcow2 local-lvm 2>&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