mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-08-10 14:26:52 +00:00
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:
+25
@@ -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
@@ -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}"
|
||||
Executable
+10
@@ -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
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user