From ddecafee91106b773ae8a820fd5697a46c13d985 Mon Sep 17 00:00:00 2001 From: Clint Branham Date: Wed, 18 Mar 2026 14:13:41 -0500 Subject: [PATCH] feat(ci): automated nested PVE provisioning for integration tests Workflow now provisions a throwaway nested PVE VM via Terraform, runs integration tests against it, then destroys it. Uses proxmox-auto-install-assistant to bake the answer file and a first-boot script (installs qemu-guest-agent) directly into the ISO. IP is discovered via the QEMU guest agent on the parent PVE, eliminating the need for static IP configuration. Supports both PVE 8.x and 9.x via workflow_dispatch version selector. Skip provisioning with skip_provision=true to test against a pre-existing PVE. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/integration-tests.yml | 232 ++++++++++++++---- tests/infrastructure/answer.toml.tftpl | 9 +- tests/infrastructure/main.tf | 9 +- tests/infrastructure/outputs.tf | 17 -- .../scripts/cleanup-pve-storage.sh | 25 ++ .../scripts/create-api-token.sh | 104 ++++++++ tests/infrastructure/scripts/first-boot.sh | 10 + .../scripts/prepare-auto-iso.sh | 38 +++ tests/infrastructure/scripts/upload-to-pve.sh | 69 ++++++ tests/infrastructure/terraform.tfvars.example | 10 +- tests/infrastructure/variables.tf | 26 +- 11 files changed, 437 insertions(+), 112 deletions(-) create mode 100755 tests/infrastructure/scripts/cleanup-pve-storage.sh create mode 100755 tests/infrastructure/scripts/create-api-token.sh create mode 100755 tests/infrastructure/scripts/first-boot.sh create mode 100755 tests/infrastructure/scripts/prepare-auto-iso.sh create mode 100755 tests/infrastructure/scripts/upload-to-pve.sh diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 215346a..4450a2a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -1,23 +1,17 @@ name: Integration Tests # Real integration tests against a live Proxmox VE instance. -# Runs on a self-hosted runner with network access to the PVE API. +# By default, provisions a throwaway nested PVE VM via Terraform, runs tests, +# then destroys it. Can also run against a pre-existing PVE with skip_provision. # -# Triggers: -# - push to main (if self-hosted runner is available) -# - manual dispatch with optional host override +# 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_TARGET_NODE - Parent PVE node name # -# Runner setup: -# See tests/infrastructure/runner/README.md for self-hosted runner -# installation instructions. -# -# Required repository secrets: -# PVETEST_HOST - Hostname or IP of the PVE node -# PVETEST_PORT - API port (default 8006) -# PVETEST_APITOKEN - API token in USER@REALM!TOKENID=UUID format -# PVETEST_NODE - Node name to run tests against -# PVETEST_STORAGE - Storage name for upload tests -# PVETEST_ISO_PATH - Path to a small test ISO on the runner +# 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 VMs, upload files, # and modify network configuration. Never run against production. @@ -29,85 +23,191 @@ on: branches: [ main ] workflow_dispatch: inputs: - pve_host: - description: 'Proxmox VE host (overrides secret)' + pve_version: + description: 'PVE version to test (8 or 9)' required: false - type: string - skip_terraform: - description: 'Skip Terraform provisioning (use existing PVE)' + type: choice + options: + - '9' + - '8' + default: '9' + skip_provision: + description: 'Skip provisioning (use existing PVE from PVETEST_HOST secret)' required: false type: boolean - default: true + default: false + +env: + INFRA_DIR: tests/infrastructure + SCRIPTS_DIR: tests/infrastructure/scripts + PVE_PASSWORD: "Testpass123!" jobs: - # Gate job: only proceed if a self-hosted runner with the right labels - # picks up the job AND secrets are configured integration: runs-on: [self-hosted, proxmox, integration] - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - name: Check required secrets - id: check-secrets + # ── Provision nested PVE ─────────────────────────────────────────── + + - name: Select PVE ISO + if: inputs.skip_provision != true + id: iso shell: bash run: | - if [ -z "${{ secrets.PVETEST_HOST }}" ] && [ -z "${{ inputs.pve_host }}" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "::warning::Integration tests skipped - PVETEST_HOST not configured" - elif [ -z "${{ secrets.PVETEST_APITOKEN }}" ]; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "::warning::Integration tests skipped - PVETEST_APITOKEN not configured" + VERSION="${{ inputs.pve_version || '9' }}" + if [ "$VERSION" = "9" ]; then + echo "base=/opt/pve-isos/proxmox-ve_9.1-1.iso" >> "$GITHUB_OUTPUT" + echo "filename=proxmox-ve_9.1-1-auto.iso" >> "$GITHUB_OUTPUT" else - echo "skip=false" >> $GITHUB_OUTPUT + echo "base=/opt/pve-isos/proxmox-ve_8.4-1.iso" >> "$GITHUB_OUTPUT" + echo "filename=proxmox-ve_8.4-1-auto.iso" >> "$GITHUB_OUTPUT" + fi + + - name: Generate answer file + if: inputs.skip_provision != true + shell: bash + run: | + sed "s/\${root_password}/${PVE_PASSWORD}/" \ + ${INFRA_DIR}/answer.toml.tftpl > /tmp/answer.toml + echo "Generated answer file:" + cat /tmp/answer.toml + + - name: Prepare auto-install ISO + if: inputs.skip_provision != true + shell: bash + run: | + bash ${SCRIPTS_DIR}/prepare-auto-iso.sh \ + "${{ steps.iso.outputs.base }}" \ + /tmp/answer.toml \ + ${SCRIPTS_DIR}/first-boot.sh \ + "/tmp/${{ steps.iso.outputs.filename }}" + + - name: Upload ISO to PVE storage + if: inputs.skip_provision != true + id: upload + shell: bash + env: + PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} + run: | + bash ${SCRIPTS_DIR}/upload-to-pve.sh \ + "$(echo '${{ secrets.PVE_ENDPOINT }}' | sed -E 's|https?://||;s|:.*||')" \ + "${PVE_API_TOKEN}" \ + "${{ secrets.PVE_TARGET_NODE }}" \ + "local" \ + "/tmp/${{ steps.iso.outputs.filename }}" \ + "iso" | tee /tmp/upload-output.txt + ISO_VOLID=$(tail -1 /tmp/upload-output.txt) + echo "iso_volid=${ISO_VOLID}" >> "$GITHUB_OUTPUT" + + - name: Terraform init + if: inputs.skip_provision != true + shell: bash + working-directory: ${{ env.INFRA_DIR }} + run: terraform init -input=false + + - name: Terraform apply + if: inputs.skip_provision != true + id: terraform + shell: bash + working-directory: ${{ env.INFRA_DIR }} + env: + TF_VAR_proxmox_endpoint: ${{ secrets.PVE_ENDPOINT }} + TF_VAR_proxmox_api_token: ${{ secrets.PVE_API_TOKEN }} + TF_VAR_target_node: ${{ secrets.PVE_TARGET_NODE }} + TF_VAR_iso_file_id: ${{ steps.upload.outputs.iso_volid }} + TF_VAR_test_vm_password: ${{ env.PVE_PASSWORD }} + run: | + terraform apply -auto-approve -input=false + echo "vm_id=$(terraform output -raw pve_test_vm_id)" >> "$GITHUB_OUTPUT" + + - name: Wait for install and create API token + if: inputs.skip_provision != true + id: provision + shell: bash + env: + PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} + run: | + PARENT_HOST=$(echo '${{ secrets.PVE_ENDPOINT }}' | sed -E 's|https?://||;s|:.*||') + OUTPUT=$(bash ${SCRIPTS_DIR}/create-api-token.sh \ + "${PARENT_HOST}" \ + "${PVE_API_TOKEN}" \ + "${{ steps.terraform.outputs.vm_id }}" \ + "${PVE_PASSWORD}" \ + 900) + echo "$OUTPUT" + VM_IP=$(echo "$OUTPUT" | grep "^IP=" | cut -d= -f2) + VM_TOKEN=$(echo "$OUTPUT" | grep "^TOKEN=" | cut -d= -f2-) + echo "host=${VM_IP}" >> "$GITHUB_OUTPUT" + echo "token=${VM_TOKEN}" >> "$GITHUB_OUTPUT" + + # ── Resolve test target ──────────────────────────────────────────── + + - name: Set test target + id: target + shell: bash + run: | + if [ "${{ inputs.skip_provision }}" = "true" ]; then + echo "host=${{ secrets.PVETEST_HOST }}" >> "$GITHUB_OUTPUT" + echo "port=${{ secrets.PVETEST_PORT || '8006' }}" >> "$GITHUB_OUTPUT" + echo "token=${{ secrets.PVETEST_APITOKEN }}" >> "$GITHUB_OUTPUT" + echo "node=${{ secrets.PVETEST_NODE || 'pve' }}" >> "$GITHUB_OUTPUT" + echo "storage=${{ secrets.PVETEST_STORAGE || 'local' }}" >> "$GITHUB_OUTPUT" + else + echo "host=${{ steps.provision.outputs.host }}" >> "$GITHUB_OUTPUT" + echo "port=8006" >> "$GITHUB_OUTPUT" + echo "token=${{ steps.provision.outputs.token }}" >> "$GITHUB_OUTPUT" + echo "node=pve" >> "$GITHUB_OUTPUT" + echo "storage=local" >> "$GITHUB_OUTPUT" fi - name: Verify PVE API reachable - if: steps.check-secrets.outputs.skip != 'true' shell: bash - env: - PVETEST_HOST: ${{ inputs.pve_host || secrets.PVETEST_HOST }} - PVETEST_PORT: ${{ secrets.PVETEST_PORT || '8006' }} - PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} run: | - echo "Testing connectivity to PVE API at ${PVETEST_HOST}:${PVETEST_PORT}..." + HOST="${{ steps.target.outputs.host }}" + PORT="${{ steps.target.outputs.port }}" + TOKEN="${{ steps.target.outputs.token }}" + echo "Testing connectivity to PVE API at ${HOST}:${PORT}..." if curl -sk --connect-timeout 10 \ - -H "Authorization: PVEAPIToken=${PVETEST_APITOKEN}" \ - "https://${PVETEST_HOST}:${PVETEST_PORT}/api2/json/nodes" | grep -q '"node"'; then + -H "Authorization: PVEAPIToken=${TOKEN}" \ + "https://${HOST}:${PORT}/api2/json/nodes" | grep -q '"node"'; then echo "PVE API is responsive" else - echo "::error::Cannot reach PVE API at ${PVETEST_HOST}:${PVETEST_PORT}" + echo "::error::Cannot reach PVE API at ${HOST}:${PORT}" exit 1 fi + # ── Build and test ───────────────────────────────────────────────── + - name: Build module - if: steps.check-secrets.outputs.skip != 'true' run: dotnet publish src/PSProxmoxVE/PSProxmoxVE.csproj --configuration Release --framework net9.0 --output ./publish/net9.0 - name: Install Pester 5 - if: steps.check-secrets.outputs.skip != 'true' shell: pwsh run: Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser - name: Copy module to module path - if: steps.check-secrets.outputs.skip != 'true' shell: pwsh run: | $modulePath = "$HOME/.local/share/powershell/Modules/PSProxmoxVE" New-Item -ItemType Directory -Path $modulePath -Force | Out-Null Copy-Item -Path ./publish/net9.0/* -Destination $modulePath -Recurse -Force + - name: Create test ISO + shell: bash + run: dd if=/dev/urandom of=/tmp/pvetest.iso bs=1M count=1 2>/dev/null + - name: Run integration tests - if: steps.check-secrets.outputs.skip != 'true' shell: pwsh env: - PVETEST_HOST: ${{ inputs.pve_host || secrets.PVETEST_HOST }} - PVETEST_PORT: ${{ secrets.PVETEST_PORT || '8006' }} - PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} - PVETEST_NODE: ${{ secrets.PVETEST_NODE }} - PVETEST_STORAGE: ${{ secrets.PVETEST_STORAGE }} - PVETEST_ISO_PATH: ${{ secrets.PVETEST_ISO_PATH }} + PVETEST_HOST: ${{ steps.target.outputs.host }} + PVETEST_PORT: ${{ steps.target.outputs.port }} + PVETEST_APITOKEN: ${{ steps.target.outputs.token }} + PVETEST_NODE: ${{ steps.target.outputs.node }} + PVETEST_STORAGE: ${{ steps.target.outputs.storage }} + PVETEST_ISO_PATH: /tmp/pvetest.iso run: | Import-Module Pester -MinimumVersion 5.0 $config = New-PesterConfiguration @@ -120,8 +220,34 @@ jobs: Invoke-Pester -Configuration $config - name: Upload test results - if: always() && steps.check-secrets.outputs.skip != 'true' + if: always() uses: actions/upload-artifact@v4 with: name: integration-test-results path: TestResults/ + + # ── Teardown ─────────────────────────────────────────────────────── + + - name: Terraform destroy + if: always() && inputs.skip_provision != true + shell: bash + working-directory: ${{ env.INFRA_DIR }} + env: + TF_VAR_proxmox_endpoint: ${{ secrets.PVE_ENDPOINT }} + TF_VAR_proxmox_api_token: ${{ secrets.PVE_API_TOKEN }} + TF_VAR_target_node: ${{ secrets.PVE_TARGET_NODE }} + TF_VAR_iso_file_id: ${{ steps.upload.outputs.iso_volid }} + TF_VAR_test_vm_password: ${{ env.PVE_PASSWORD }} + run: terraform destroy -auto-approve -input=false + + - name: Clean up uploaded ISO + if: always() && inputs.skip_provision != true + shell: bash + env: + PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} + run: | + PARENT_HOST=$(echo '${{ secrets.PVE_ENDPOINT }}' | sed -E 's|https?://||;s|:.*||') + bash ${SCRIPTS_DIR}/cleanup-pve-storage.sh \ + "${PARENT_HOST}" \ + "${PVE_API_TOKEN}" \ + "${{ steps.upload.outputs.iso_volid }}" || true diff --git a/tests/infrastructure/answer.toml.tftpl b/tests/infrastructure/answer.toml.tftpl index 7415025..4ccce1e 100644 --- a/tests/infrastructure/answer.toml.tftpl +++ b/tests/infrastructure/answer.toml.tftpl @@ -5,13 +5,12 @@ fqdn = "pve-test.local" mailto = "test@test.local" timezone = "UTC" root_password = "${root_password}" +reboot_on_error = true +root_ssh_keys = [] +on_first_boot = "first-boot.sh" [network] -source = "from-answer" -cidr = "${cidr}" -dns = "${dns}" -gateway = "${gateway}" -filter.ID_NET_NAME = "*" +source = "from-dhcp" [disk-setup] filesystem = "ext4" diff --git a/tests/infrastructure/main.tf b/tests/infrastructure/main.tf index 2070784..1dce7f8 100644 --- a/tests/infrastructure/main.tf +++ b/tests/infrastructure/main.tf @@ -12,11 +12,6 @@ provider "proxmox" { endpoint = var.proxmox_endpoint api_token = var.proxmox_api_token insecure = var.proxmox_insecure - - ssh { - agent = true - username = "root" - } } resource "proxmox_virtual_environment_vm" "nested_pve" { @@ -63,6 +58,10 @@ resource "proxmox_virtual_environment_vm" "nested_pve" { type = "l26" } + agent { + enabled = true + } + started = true lifecycle { diff --git a/tests/infrastructure/outputs.tf b/tests/infrastructure/outputs.tf index a972799..4fcec7b 100644 --- a/tests/infrastructure/outputs.tf +++ b/tests/infrastructure/outputs.tf @@ -1,15 +1,3 @@ -output "pve_test_host" { - value = var.test_vm_ip -} - -output "pve_test_port" { - value = 8006 -} - -output "pve_test_url" { - value = "https://${var.test_vm_ip}:8006" -} - output "pve_test_vm_id" { value = var.vm_id } @@ -18,8 +6,3 @@ output "pve_test_node_name" { value = "pve" description = "Default node name inside a fresh PVE install" } - -output "pve_test_api_token" { - value = fileexists("${path.module}/.api-token") ? trimspace(file("${path.module}/.api-token")) : "not-yet-created" - sensitive = true -} diff --git a/tests/infrastructure/scripts/cleanup-pve-storage.sh b/tests/infrastructure/scripts/cleanup-pve-storage.sh new file mode 100755 index 0000000..6766bff --- /dev/null +++ b/tests/infrastructure/scripts/cleanup-pve-storage.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Remove an uploaded ISO from PVE storage. +# +# Usage: cleanup-pve-storage.sh +# e.g.: cleanup-pve-storage.sh 172.16.100.150 "root@pam!tok=secret" "local:iso/proxmox-ve_9.1-1-auto.iso" +set -euo pipefail + +PVE_HOST="$1" +API_TOKEN="$2" +VOLUME_ID="$3" + +# Extract node name from API +NODE=$(curl -sk -H "Authorization: PVEAPIToken=${API_TOKEN}" \ + "https://${PVE_HOST}/api2/json/nodes" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['node'])") + +echo "Deleting ${VOLUME_ID} from node ${NODE}..." +ENCODED_VOLID=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${VOLUME_ID}', safe=''))") + +RESPONSE=$(curl -sk -X DELETE \ + -H "Authorization: PVEAPIToken=${API_TOKEN}" \ + "https://${PVE_HOST}/api2/json/nodes/${NODE}/storage/local/content/${ENCODED_VOLID}") + +echo "Response: $RESPONSE" +echo "Cleanup complete." diff --git a/tests/infrastructure/scripts/create-api-token.sh b/tests/infrastructure/scripts/create-api-token.sh new file mode 100755 index 0000000..0d409a6 --- /dev/null +++ b/tests/infrastructure/scripts/create-api-token.sh @@ -0,0 +1,104 @@ +#!/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 and create an API token. +# +# Usage: create-api-token.sh [max-wait-seconds] +# Outputs two lines: +# IP= +# TOKEN=root@pam!integration= +set -euo pipefail + +PARENT_HOST="$1" +PARENT_TOKEN="$2" +VM_ID="$3" +ROOT_PASSWORD="$4" +MAX_WAIT="${5:-600}" +INTERVAL=10 +PARENT_API="https://${PARENT_HOST}/api2/json" +PARENT_NODE=$(curl -sk -H "Authorization: PVEAPIToken=${PARENT_TOKEN}" \ + "${PARENT_API}/nodes" | python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['node'])") + +# --- 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 \ + -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}/version" 2>/dev/null | grep -q '"version"'; 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: Authenticate and create API token --- +echo "Authenticating as root@pam on nested PVE..." +AUTH_RESPONSE=$(curl -sk -d "username=root@pam&password=${ROOT_PASSWORD}" \ + "${NESTED_API}/access/ticket") +TICKET=$(echo "$AUTH_RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['ticket'])" 2>/dev/null || true) +CSRF=$(echo "$AUTH_RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['CSRFPreventionToken'])" 2>/dev/null || true) + +if [ -z "$TICKET" ] || [ -z "$CSRF" ]; then + echo "ERROR: Authentication failed. Response: $AUTH_RESPONSE" >&2 + exit 1 +fi + +echo "Creating API token root@pam!integration..." +TOKEN_RESPONSE=$(curl -sk \ + -b "PVEAuthCookie=${TICKET}" \ + -H "CSRFPreventionToken: ${CSRF}" \ + -d "privsep=0" \ + "${NESTED_API}/access/users/root@pam/token/integration") + +TOKEN_VALUE=$(echo "$TOKEN_RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['value'])" 2>/dev/null || true) + +if [ -z "$TOKEN_VALUE" ]; then + echo "ERROR: Token creation failed. Response: $TOKEN_RESPONSE" >&2 + exit 1 +fi + +echo "IP=${VM_IP}" +echo "TOKEN=root@pam!integration=${TOKEN_VALUE}" diff --git a/tests/infrastructure/scripts/first-boot.sh b/tests/infrastructure/scripts/first-boot.sh new file mode 100755 index 0000000..0a3934e --- /dev/null +++ b/tests/infrastructure/scripts/first-boot.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# First-boot script for nested PVE test instances. +# Runs once after auto-install completes and the system reboots. +# Installs qemu-guest-agent so the parent PVE can discover the VM's IP via the guest agent API. + +set -e + +apt-get update -qq +apt-get install -y -qq qemu-guest-agent +systemctl enable --now qemu-guest-agent diff --git a/tests/infrastructure/scripts/prepare-auto-iso.sh b/tests/infrastructure/scripts/prepare-auto-iso.sh new file mode 100755 index 0000000..3ab2b14 --- /dev/null +++ b/tests/infrastructure/scripts/prepare-auto-iso.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Prepare a PVE auto-install ISO with the answer file and first-boot script baked in. +# Uses --fetch-from iso mode so only a single CD-ROM is needed, no HTTP server required. +# +# Usage: prepare-auto-iso.sh [first-boot-script] [output-iso] +set -euo pipefail + +BASE_ISO="$1" +ANSWER_FILE="$2" +FIRST_BOOT="${3:-}" +OUTPUT_ISO="${4:-${BASE_ISO%.iso}-auto.iso}" + +for f in "$BASE_ISO" "$ANSWER_FILE"; do + if [ ! -f "$f" ]; then + echo "ERROR: File not found: $f" >&2 + exit 1 + fi +done + +FIRST_BOOT_ARGS=() +if [ -n "$FIRST_BOOT" ] && [ -f "$FIRST_BOOT" ]; then + FIRST_BOOT_ARGS=(--on-first-boot "$FIRST_BOOT") +fi + +echo "Preparing auto-install ISO..." +echo " Base ISO: $BASE_ISO" +echo " Answer file: $ANSWER_FILE" +echo " First boot: ${FIRST_BOOT:-none}" +echo " Output: $OUTPUT_ISO" + +proxmox-auto-install-assistant prepare-iso \ + --fetch-from iso \ + --answer-file "$ANSWER_FILE" \ + "${FIRST_BOOT_ARGS[@]}" \ + --output "$OUTPUT_ISO" \ + "$BASE_ISO" + +echo "Created: $OUTPUT_ISO" diff --git a/tests/infrastructure/scripts/upload-to-pve.sh b/tests/infrastructure/scripts/upload-to-pve.sh new file mode 100755 index 0000000..82aa4b6 --- /dev/null +++ b/tests/infrastructure/scripts/upload-to-pve.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Upload a file to PVE storage via the REST API. +# +# Usage: upload-to-pve.sh +# content-type: "iso" or "vztmpl" +set -euo pipefail + +PVE_HOST="$1" +API_TOKEN="$2" +NODE="$3" +STORAGE="$4" +FILE_PATH="$5" +CONTENT_TYPE="${6:-iso}" + +FILENAME=$(basename "$FILE_PATH") +API_URL="https://${PVE_HOST}/api2/json/nodes/${NODE}/storage/${STORAGE}/upload" + +# Check if file already exists on storage +echo "Checking if ${FILENAME} already exists on ${STORAGE}..." +EXISTING=$(curl -sk \ + -H "Authorization: PVEAPIToken=${API_TOKEN}" \ + "https://${PVE_HOST}/api2/json/nodes/${NODE}/storage/${STORAGE}/content" \ + | python3 -c " +import json, sys +data = json.load(sys.stdin).get('data', []) +for item in data: + if item.get('volid', '').endswith('/${FILENAME}'): + print(item['volid']) + break +" 2>/dev/null || true) + +if [ -n "$EXISTING" ]; then + echo "Already exists: $EXISTING (skipping upload)" + echo "$EXISTING" + exit 0 +fi + +echo "Uploading ${FILENAME} ($(du -h "$FILE_PATH" | cut -f1)) to ${STORAGE}:${CONTENT_TYPE}/..." +RESPONSE=$(curl -sk --progress-bar \ + -H "Authorization: PVEAPIToken=${API_TOKEN}" \ + -F "content=${CONTENT_TYPE}" \ + -F "filename=@${FILE_PATH}" \ + "$API_URL") + +UPID=$(echo "$RESPONSE" | python3 -c "import json,sys; print(json.load(sys.stdin)['data'])" 2>/dev/null || true) +if [ -z "$UPID" ]; then + echo "ERROR: Upload failed. Response: $RESPONSE" >&2 + exit 1 +fi + +echo "Upload started: $UPID" + +# Wait for upload task to complete +echo "Waiting for upload task..." +for i in $(seq 1 60); do + STATUS=$(curl -sk \ + -H "Authorization: PVEAPIToken=${API_TOKEN}" \ + "https://${PVE_HOST}/api2/json/nodes/${NODE}/tasks/${UPID}/status" \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(d.get('status','unknown'))" 2>/dev/null || echo "unknown") + if [ "$STATUS" = "stopped" ]; then + echo "Upload complete: ${STORAGE}:${CONTENT_TYPE}/${FILENAME}" + echo "${STORAGE}:${CONTENT_TYPE}/${FILENAME}" + exit 0 + fi + sleep 2 +done + +echo "ERROR: Upload task did not complete within timeout" >&2 +exit 1 diff --git a/tests/infrastructure/terraform.tfvars.example b/tests/infrastructure/terraform.tfvars.example index f5d11f2..7c0e6ae 100644 --- a/tests/infrastructure/terraform.tfvars.example +++ b/tests/infrastructure/terraform.tfvars.example @@ -3,16 +3,12 @@ proxmox_endpoint = "https://pve.example.com:8006" proxmox_api_token = "root@pam!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" target_node = "pve" -# Path to PVE ISO (download from proxmox.com/en/downloads) -iso_file = "/path/to/proxmox-ve_9.1-1.iso" - -# Network config for nested PVE (must be routable from CI runner) -test_vm_ip = "192.168.1.200" -test_vm_gateway = "192.168.1.1" +# Auto-install ISO (prepared by scripts/prepare-auto-iso.sh, then uploaded) +iso_file_id = "local:iso/proxmox-ve_9.1-1-auto.iso" +disk_storage = "local-lvm" # Optional overrides # cores = 4 # memory = 8192 # disk_size = 64 -# disk_storage = "local-lvm" # network_bridge = "vmbr0" diff --git a/tests/infrastructure/variables.tf b/tests/infrastructure/variables.tf index 1e8fde7..6a95b44 100644 --- a/tests/infrastructure/variables.tf +++ b/tests/infrastructure/variables.tf @@ -57,9 +57,8 @@ variable "disk_storage" { } variable "iso_file_id" { - description = "Proxmox file ID of the PVE installation ISO (e.g. nas-nfs:iso/proxmox-ve_9.1-1.iso)" + description = "Proxmox file ID of the auto-install PVE ISO (e.g. local:iso/proxmox-ve_9.1-1-auto.iso)" type = string - default = "nas-nfs:iso/proxmox-ve_9.1-1.iso" } variable "network_bridge" { @@ -68,32 +67,9 @@ variable "network_bridge" { default = "Core" } -variable "test_vm_ip" { - description = "Static IP address to assign to the nested PVE instance" - type = string -} - -variable "test_vm_gateway" { - description = "Default gateway for the nested PVE instance" - type = string -} - -variable "test_vm_netmask_bits" { - description = "CIDR prefix length for the nested PVE network (e.g. 24 for /24)" - type = string - default = "24" -} - -variable "test_vm_dns" { - description = "DNS server for the nested PVE instance" - type = string - default = "1.1.1.1" -} - variable "test_vm_password" { description = "Root password for the nested PVE instance" type = string sensitive = true default = "Testpass123!" } -