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) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-18 14:13:41 -05:00
parent cac3e705e4
commit ddecafee91
11 changed files with 437 additions and 112 deletions
+179 -53
View File
@@ -1,23 +1,17 @@
name: Integration Tests name: Integration Tests
# Real integration tests against a live Proxmox VE instance. # 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: # Required repository secrets (for provisioning):
# - push to main (if self-hosted runner is available) # PVE_ENDPOINT - Parent PVE API URL (e.g. https://pve.example.com:8006)
# - manual dispatch with optional host override # PVE_API_TOKEN - Parent PVE API token (for Terraform + uploads)
# PVE_TARGET_NODE - Parent PVE node name
# #
# Runner setup: # Optional secrets (for skip_provision mode):
# See tests/infrastructure/runner/README.md for self-hosted runner # PVETEST_HOST - Hostname or IP of a pre-existing nested PVE
# installation instructions. # PVETEST_APITOKEN - API token for the pre-existing nested PVE
#
# 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
# #
# WARNING: Integration tests create and destroy real VMs, upload files, # WARNING: Integration tests create and destroy real VMs, upload files,
# and modify network configuration. Never run against production. # and modify network configuration. Never run against production.
@@ -29,85 +23,191 @@ on:
branches: [ main ] branches: [ main ]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
pve_host: pve_version:
description: 'Proxmox VE host (overrides secret)' description: 'PVE version to test (8 or 9)'
required: false required: false
type: string type: choice
skip_terraform: options:
description: 'Skip Terraform provisioning (use existing PVE)' - '9'
- '8'
default: '9'
skip_provision:
description: 'Skip provisioning (use existing PVE from PVETEST_HOST secret)'
required: false required: false
type: boolean type: boolean
default: true default: false
env:
INFRA_DIR: tests/infrastructure
SCRIPTS_DIR: tests/infrastructure/scripts
PVE_PASSWORD: "Testpass123!"
jobs: jobs:
# Gate job: only proceed if a self-hosted runner with the right labels
# picks up the job AND secrets are configured
integration: integration:
runs-on: [self-hosted, proxmox, integration] runs-on: [self-hosted, proxmox, integration]
timeout-minutes: 30 timeout-minutes: 45
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Check required secrets # ── Provision nested PVE ───────────────────────────────────────────
id: check-secrets
- name: Select PVE ISO
if: inputs.skip_provision != true
id: iso
shell: bash shell: bash
run: | run: |
if [ -z "${{ secrets.PVETEST_HOST }}" ] && [ -z "${{ inputs.pve_host }}" ]; then VERSION="${{ inputs.pve_version || '9' }}"
echo "skip=true" >> $GITHUB_OUTPUT if [ "$VERSION" = "9" ]; then
echo "::warning::Integration tests skipped - PVETEST_HOST not configured" echo "base=/opt/pve-isos/proxmox-ve_9.1-1.iso" >> "$GITHUB_OUTPUT"
elif [ -z "${{ secrets.PVETEST_APITOKEN }}" ]; then echo "filename=proxmox-ve_9.1-1-auto.iso" >> "$GITHUB_OUTPUT"
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Integration tests skipped - PVETEST_APITOKEN not configured"
else 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 fi
- name: Verify PVE API reachable - name: Verify PVE API reachable
if: steps.check-secrets.outputs.skip != 'true'
shell: bash shell: bash
env:
PVETEST_HOST: ${{ inputs.pve_host || secrets.PVETEST_HOST }}
PVETEST_PORT: ${{ secrets.PVETEST_PORT || '8006' }}
PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }}
run: | 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 \ if curl -sk --connect-timeout 10 \
-H "Authorization: PVEAPIToken=${PVETEST_APITOKEN}" \ -H "Authorization: PVEAPIToken=${TOKEN}" \
"https://${PVETEST_HOST}:${PVETEST_PORT}/api2/json/nodes" | grep -q '"node"'; then "https://${HOST}:${PORT}/api2/json/nodes" | grep -q '"node"'; then
echo "PVE API is responsive" echo "PVE API is responsive"
else else
echo "::error::Cannot reach PVE API at ${PVETEST_HOST}:${PVETEST_PORT}" echo "::error::Cannot reach PVE API at ${HOST}:${PORT}"
exit 1 exit 1
fi fi
# ── Build and test ─────────────────────────────────────────────────
- name: Build module - 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 run: dotnet publish src/PSProxmoxVE/PSProxmoxVE.csproj --configuration Release --framework net9.0 --output ./publish/net9.0
- name: Install Pester 5 - name: Install Pester 5
if: steps.check-secrets.outputs.skip != 'true'
shell: pwsh shell: pwsh
run: Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser run: Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope CurrentUser
- name: Copy module to module path - name: Copy module to module path
if: steps.check-secrets.outputs.skip != 'true'
shell: pwsh shell: pwsh
run: | run: |
$modulePath = "$HOME/.local/share/powershell/Modules/PSProxmoxVE" $modulePath = "$HOME/.local/share/powershell/Modules/PSProxmoxVE"
New-Item -ItemType Directory -Path $modulePath -Force | Out-Null New-Item -ItemType Directory -Path $modulePath -Force | Out-Null
Copy-Item -Path ./publish/net9.0/* -Destination $modulePath -Recurse -Force 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 - name: Run integration tests
if: steps.check-secrets.outputs.skip != 'true'
shell: pwsh shell: pwsh
env: env:
PVETEST_HOST: ${{ inputs.pve_host || secrets.PVETEST_HOST }} PVETEST_HOST: ${{ steps.target.outputs.host }}
PVETEST_PORT: ${{ secrets.PVETEST_PORT || '8006' }} PVETEST_PORT: ${{ steps.target.outputs.port }}
PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} PVETEST_APITOKEN: ${{ steps.target.outputs.token }}
PVETEST_NODE: ${{ secrets.PVETEST_NODE }} PVETEST_NODE: ${{ steps.target.outputs.node }}
PVETEST_STORAGE: ${{ secrets.PVETEST_STORAGE }} PVETEST_STORAGE: ${{ steps.target.outputs.storage }}
PVETEST_ISO_PATH: ${{ secrets.PVETEST_ISO_PATH }} PVETEST_ISO_PATH: /tmp/pvetest.iso
run: | run: |
Import-Module Pester -MinimumVersion 5.0 Import-Module Pester -MinimumVersion 5.0
$config = New-PesterConfiguration $config = New-PesterConfiguration
@@ -120,8 +220,34 @@ jobs:
Invoke-Pester -Configuration $config Invoke-Pester -Configuration $config
- name: Upload test results - name: Upload test results
if: always() && steps.check-secrets.outputs.skip != 'true' if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: integration-test-results name: integration-test-results
path: TestResults/ 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
+4 -5
View File
@@ -5,13 +5,12 @@ fqdn = "pve-test.local"
mailto = "test@test.local" mailto = "test@test.local"
timezone = "UTC" timezone = "UTC"
root_password = "${root_password}" root_password = "${root_password}"
reboot_on_error = true
root_ssh_keys = []
on_first_boot = "first-boot.sh"
[network] [network]
source = "from-answer" source = "from-dhcp"
cidr = "${cidr}"
dns = "${dns}"
gateway = "${gateway}"
filter.ID_NET_NAME = "*"
[disk-setup] [disk-setup]
filesystem = "ext4" filesystem = "ext4"
+4 -5
View File
@@ -12,11 +12,6 @@ provider "proxmox" {
endpoint = var.proxmox_endpoint endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token api_token = var.proxmox_api_token
insecure = var.proxmox_insecure insecure = var.proxmox_insecure
ssh {
agent = true
username = "root"
}
} }
resource "proxmox_virtual_environment_vm" "nested_pve" { resource "proxmox_virtual_environment_vm" "nested_pve" {
@@ -63,6 +58,10 @@ resource "proxmox_virtual_environment_vm" "nested_pve" {
type = "l26" type = "l26"
} }
agent {
enabled = true
}
started = true started = true
lifecycle { lifecycle {
-17
View File
@@ -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" { output "pve_test_vm_id" {
value = var.vm_id value = var.vm_id
} }
@@ -18,8 +6,3 @@ output "pve_test_node_name" {
value = "pve" value = "pve"
description = "Default node name inside a fresh PVE install" 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
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Remove an uploaded ISO from PVE storage.
#
# Usage: cleanup-pve-storage.sh <pve-host> <api-token> <volume-id>
# 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."
+104
View File
@@ -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 <parent-pve-host> <parent-api-token> <vm-id> <root-password> [max-wait-seconds]
# Outputs two lines:
# IP=<discovered-ip>
# TOKEN=root@pam!integration=<secret>
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}"
+10
View File
@@ -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
+38
View File
@@ -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 <base-iso> <answer-file> [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"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Upload a file to PVE storage via the REST API.
#
# Usage: upload-to-pve.sh <pve-host> <api-token> <node> <storage> <file-path> <content-type>
# 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
@@ -3,16 +3,12 @@ proxmox_endpoint = "https://pve.example.com:8006"
proxmox_api_token = "root@pam!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" proxmox_api_token = "root@pam!terraform=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
target_node = "pve" target_node = "pve"
# Path to PVE ISO (download from proxmox.com/en/downloads) # Auto-install ISO (prepared by scripts/prepare-auto-iso.sh, then uploaded)
iso_file = "/path/to/proxmox-ve_9.1-1.iso" iso_file_id = "local:iso/proxmox-ve_9.1-1-auto.iso"
disk_storage = "local-lvm"
# 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"
# Optional overrides # Optional overrides
# cores = 4 # cores = 4
# memory = 8192 # memory = 8192
# disk_size = 64 # disk_size = 64
# disk_storage = "local-lvm"
# network_bridge = "vmbr0" # network_bridge = "vmbr0"
+1 -25
View File
@@ -57,9 +57,8 @@ variable "disk_storage" {
} }
variable "iso_file_id" { 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 type = string
default = "nas-nfs:iso/proxmox-ve_9.1-1.iso"
} }
variable "network_bridge" { variable "network_bridge" {
@@ -68,32 +67,9 @@ variable "network_bridge" {
default = "Core" 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" { variable "test_vm_password" {
description = "Root password for the nested PVE instance" description = "Root password for the nested PVE instance"
type = string type = string
sensitive = true sensitive = true
default = "Testpass123!" default = "Testpass123!"
} }