diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 5e1ac32..e99d50c 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,15 +5,19 @@ name: Integration Tests # Architecture: # build → GitHub-hosted, dotnet publish → upload artifact # container-image → GitHub-hosted, build+push Docker image to GHCR -# provision → self-hosted, provisions ALL nested PVE VMs in parallel via Terraform -# test → self-hosted, runs Pester tests against pre-provisioned VMs -# cleanup → self-hosted, tears down all VMs (always runs) +# provision → self-hosted, provisions ALL nested PVE VMs via run-integration.sh +# test → self-hosted, runs Pester tests via run-integration.sh +# cleanup → self-hosted, tears down all VMs via run-integration.sh (always runs) # cleanup-images → GitHub-hosted, prunes old GHCR container images # +# The provision/test/cleanup logic lives in tests/infrastructure/scripts/run-integration.sh +# which is the single source of truth shared between CI and local dev containers. +# # 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 +# PVE_TEST_PASSWORD - Root password for nested PVE instances # # Optional secrets (for skip_provision mode): # PVETEST_HOST - Hostname or IP of a pre-existing nested PVE @@ -42,9 +46,7 @@ permissions: packages: write env: - INFRA_DIR: tests/infrastructure SCRIPTS_DIR: tests/infrastructure/scripts - PVE_PASSWORD: ${{ secrets.PVE_TEST_PASSWORD }} TEST_IMAGE: ghcr.io/goodolclint/psproxmoxve-integration CACHE_DIR: /opt/pve-isos @@ -110,181 +112,18 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} volumes: - /opt/pve-isos:/opt/pve-isos - # Connection details are written to /opt/pve-isos/test-config.json - # instead of job outputs, because GitHub Actions redacts masked/secret - # values in outputs making them empty in downstream jobs. steps: - - name: Mask test password - run: echo "::add-mask::${PVE_PASSWORD}" - - uses: actions/checkout@v6 - # ── Cache: ensure base ISOs ────────────────────────────────────── - - - name: Ensure PVE 9 base ISO - shell: bash - run: bash ${SCRIPTS_DIR}/ensure-base-iso.sh "proxmox-ve_9.1-1.iso" "${CACHE_DIR}" - - - name: Ensure PVE 8 base ISO - shell: bash - run: bash ${SCRIPTS_DIR}/ensure-base-iso.sh "proxmox-ve_8.4-1.iso" "${CACHE_DIR}" - - # ── Cache: ensure cloud images ─────────────────────────────────── - - - name: Ensure cloud images cached - id: cloud_images - shell: bash - run: | - OUTPUT=$(bash ${SCRIPTS_DIR}/ensure-cloud-images.sh "${CACHE_DIR}") - CLOUD_IMAGE_PATH=$(echo "$OUTPUT" | grep "^CLOUD_IMAGE_PATH=" | cut -d= -f2) - OVA_PATH=$(echo "$OUTPUT" | grep "^OVA_PATH=" | cut -d= -f2) - echo "cloud_image_path=${CLOUD_IMAGE_PATH}" >> "$GITHUB_OUTPUT" - echo "ova_path=${OVA_PATH}" >> "$GITHUB_OUTPUT" - - # ── Pre-flight cleanup for ALL VMs ─────────────────────────────── - - - name: Pre-flight cleanup (PVE 9) + - name: Provision PVE instances shell: bash env: + PVE_ENDPOINT: ${{ secrets.PVE_ENDPOINT }} PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} - run: | - bash ${SCRIPTS_DIR}/preflight-cleanup.sh \ - "${{ secrets.PVE_ENDPOINT }}" "${PVE_API_TOKEN}" \ - 99909 "proxmox-ve_9.1-1-auto.iso" "${INFRA_DIR}" - - - name: Pre-flight cleanup (PVE 8) - shell: bash - env: - PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} - run: | - bash ${SCRIPTS_DIR}/preflight-cleanup.sh \ - "${{ secrets.PVE_ENDPOINT }}" "${PVE_API_TOKEN}" \ - 99908 "proxmox-ve_8.4-1-auto.iso" "${INFRA_DIR}" - - # ── Generate answer files ──────────────────────────────────────── - - - name: Generate answer file - shell: bash - run: | - sed "s/\${root_password}/${PVE_PASSWORD}/" \ - ${INFRA_DIR}/answer.toml.tftpl > ${RUNNER_TEMP}/answer.toml - - # ── Prepare auto-install ISOs (cached) ─────────────────────────── - - - name: Prepare PVE 9 auto-install ISO - shell: bash - run: | - bash ${SCRIPTS_DIR}/prepare-auto-iso.sh \ - "${CACHE_DIR}/proxmox-ve_9.1-1.iso" \ - ${RUNNER_TEMP}/answer.toml \ - ${SCRIPTS_DIR}/first-boot.sh \ - "${RUNNER_TEMP}/proxmox-ve_9.1-1-auto.iso" \ - --cache-dir "${CACHE_DIR}" - - - name: Prepare PVE 8 auto-install ISO - shell: bash - run: | - bash ${SCRIPTS_DIR}/prepare-auto-iso.sh \ - "${CACHE_DIR}/proxmox-ve_8.4-1.iso" \ - ${RUNNER_TEMP}/answer.toml \ - ${SCRIPTS_DIR}/first-boot.sh \ - "${RUNNER_TEMP}/proxmox-ve_8.4-1-auto.iso" \ - --cache-dir "${CACHE_DIR}" - - # ── Terraform: provision both VMs in parallel ──────────────────── - - - name: Terraform init - shell: bash - working-directory: ${{ env.INFRA_DIR }} - run: terraform init -input=false - - - name: Terraform apply (both VMs) - 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_test_vm_password: ${{ env.PVE_PASSWORD }} - run: | - cat > /tmp/instances.tfvars.json < /tmp/pve9.json - - - name: Wait for PVE 8 and create API token - shell: bash - env: - PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} - run: | - OUTPUT=$(bash ${SCRIPTS_DIR}/create-api-token.sh \ - "${{ secrets.PVE_ENDPOINT }}" "${PVE_API_TOKEN}" \ - 99908 "${PVE_PASSWORD}" 900) - PVE8_IP=$(echo "$OUTPUT" | grep "^IP=" | cut -d= -f2) - PVE8_TOKEN=$(echo "$OUTPUT" | grep "^TOKEN=" | cut -d= -f2-) - echo "PVE 8 ready at ${PVE8_IP}" - echo "{\"host\":\"${PVE8_IP}\",\"token\":\"${PVE8_TOKEN}\"}" > /tmp/pve8.json - - # ── Prepare test environments on both nested PVEs ──────────────── - - - name: Prepare PVE 9 test environment - shell: bash - run: | - PVE9_IP=$(jq -r .host /tmp/pve9.json) - bash ${SCRIPTS_DIR}/prepare-test-environment.sh "${PVE9_IP}" "${PVE_PASSWORD}" - - - name: Prepare PVE 8 test environment - shell: bash - run: | - PVE8_IP=$(jq -r .host /tmp/pve8.json) - bash ${SCRIPTS_DIR}/prepare-test-environment.sh "${PVE8_IP}" "${PVE_PASSWORD}" - - # ── Write test config to shared volume ─────────────────────────── - - - name: Write test config to shared volume - shell: bash - run: | - CLOUD_IMG="${CACHE_DIR}/noble-server-cloudimg-amd64.qcow2" - OVA="${CACHE_DIR}/ubuntu-24.04-server-cloudimg-amd64.ova" - jq -n \ - --argjson pve9 "$(cat /tmp/pve9.json)" \ - --argjson pve8 "$(cat /tmp/pve8.json)" \ - --arg cloud_image "$CLOUD_IMG" \ - --arg ova "$OVA" \ - '{pve9: $pve9, pve8: $pve8, cloud_image_path: $cloud_image, ova_path: $ova}' \ - > "${CACHE_DIR}/test-config.json" - echo "Test config written:" - jq . "${CACHE_DIR}/test-config.json" + PVE_TARGET_NODE: ${{ secrets.PVE_TARGET_NODE }} + PVE_PASSWORD: ${{ secrets.PVE_TEST_PASSWORD }} + run: bash ${SCRIPTS_DIR}/run-integration.sh provision # ── Integration tests (self-hosted, per PVE version) ──────────────── test: @@ -305,9 +144,6 @@ jobs: pve_version: ['9', '8'] steps: - - name: Mask test password - run: echo "::add-mask::${PVE_PASSWORD}" - - uses: actions/checkout@v6 - name: Download module artifact @@ -316,86 +152,15 @@ jobs: name: module-integration path: ./publish/netstandard2.0/ - # ── Load test config from shared volume ────────────────────────── - - - name: Load test config - id: target + - name: Run integration tests (PVE ${{ matrix.pve_version }}) shell: bash - run: | - CONFIG="${CACHE_DIR}/test-config.json" - 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" - echo "cloud_image_path=" >> "$GITHUB_OUTPUT" - echo "ova_path=" >> "$GITHUB_OUTPUT" - else - PVE_KEY="pve${{ matrix.pve_version }}" - echo "host=$(jq -r ".${PVE_KEY}.host" "$CONFIG")" >> "$GITHUB_OUTPUT" - echo "port=8006" >> "$GITHUB_OUTPUT" - echo "token=$(jq -r ".${PVE_KEY}.token" "$CONFIG")" >> "$GITHUB_OUTPUT" - echo "node=pve" >> "$GITHUB_OUTPUT" - echo "storage=local" >> "$GITHUB_OUTPUT" - echo "cloud_image_path=$(jq -r '.cloud_image_path' "$CONFIG")" >> "$GITHUB_OUTPUT" - echo "ova_path=$(jq -r '.ova_path' "$CONFIG")" >> "$GITHUB_OUTPUT" - fi - - - name: Verify PVE API reachable - shell: bash - run: | - HOST="${{ steps.target.outputs.host }}" - PORT="${{ steps.target.outputs.port }}" - TOKEN="${{ steps.target.outputs.token }}" - echo "Testing connectivity to PVE ${{ matrix.pve_version }} API at ${HOST}:${PORT}..." - if curl -sk --connect-timeout 10 \ - -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 ${HOST}:${PORT}" - exit 1 - fi - - # ── Install module ──────────────────────────────────────────────── - - - name: Copy module to module path - shell: pwsh - run: | - $modulePath = "$HOME/.local/share/powershell/Modules/PSProxmoxVE" - New-Item -ItemType Directory -Path $modulePath -Force | Out-Null - Copy-Item -Path ./publish/netstandard2.0/* -Destination $modulePath -Recurse -Force - - # ── Run tests ───────────────────────────────────────────────────── - - - name: Create test ISO - shell: bash - run: dd if=/dev/urandom of=${RUNNER_TEMP}/pvetest.iso bs=1M count=1 2>/dev/null - - - name: Run integration tests - shell: pwsh env: - 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_PVE_VERSION: ${{ matrix.pve_version }} - PVETEST_PASSWORD: ${{ env.PVE_PASSWORD }} - PVETEST_CLOUD_IMAGE_PATH: ${{ steps.target.outputs.cloud_image_path }} - PVETEST_OVA_PATH: ${{ steps.target.outputs.ova_path }} - run: | - $env:PVETEST_ISO_PATH = Join-Path $env:RUNNER_TEMP "pvetest.iso" - Import-Module Pester -MinimumVersion 5.0 - $config = New-PesterConfiguration - $config.Run.Path = "tests/PSProxmoxVE.Tests/Integration" - $config.Filter.Tag = "Integration" - $config.Output.Verbosity = "Detailed" - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = "NUnitXml" - $config.TestResult.OutputPath = "TestResults/integration-results.xml" - Invoke-Pester -Configuration $config + PVE_PASSWORD: ${{ secrets.PVE_TEST_PASSWORD }} + MODULE_ARTIFACT: ./publish/netstandard2.0 + SKIP_PROVISION: ${{ inputs.skip_provision || 'false' }} + PVETEST_HOST: ${{ secrets.PVETEST_HOST }} + PVETEST_APITOKEN: ${{ secrets.PVETEST_APITOKEN }} + run: bash ${SCRIPTS_DIR}/run-integration.sh test ${{ matrix.pve_version }} - name: Upload test results if: always() @@ -421,23 +186,13 @@ jobs: steps: - uses: actions/checkout@v6 - - name: API-based cleanup (PVE 9) + - name: Cleanup PVE instances shell: bash env: + PVE_ENDPOINT: ${{ secrets.PVE_ENDPOINT }} PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} - run: | - bash ${SCRIPTS_DIR}/preflight-cleanup.sh \ - "${{ secrets.PVE_ENDPOINT }}" "${PVE_API_TOKEN}" \ - 99909 "proxmox-ve_9.1-1-auto.iso" "${INFRA_DIR}" || true - - - name: API-based cleanup (PVE 8) - shell: bash - env: - PVE_API_TOKEN: ${{ secrets.PVE_API_TOKEN }} - run: | - bash ${SCRIPTS_DIR}/preflight-cleanup.sh \ - "${{ secrets.PVE_ENDPOINT }}" "${PVE_API_TOKEN}" \ - 99908 "proxmox-ve_8.4-1-auto.iso" "${INFRA_DIR}" || true + PVE_TARGET_NODE: ${{ secrets.PVE_TARGET_NODE }} + run: bash ${SCRIPTS_DIR}/run-integration.sh cleanup # ── Clean up old container images from GHCR ────────────────────────── cleanup-images: diff --git a/.gitignore b/.gitignore index f19e575..50cd147 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ nupkg/ BenchmarkDotNet.Artifacts/ .env .env.* +!.env.*.example +tests/.env.test diff --git a/CLAUDE.md b/CLAUDE.md index 09558e8..967a053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,41 @@ C# binary PowerShell module for managing Proxmox VE (PVE) infrastructure. Two pr Tests: xUnit (`tests/PSProxmoxVE.Core.Tests/`) and Pester 5 (`tests/PSProxmoxVE.Tests/`). -## Build & Test +## Development Workflow + +**All changes go through pull requests.** The `main` branch has branch protection enabled +(required build checks, required review, admin enforced). Never push directly to main. + +```bash +# Create a feature branch +git checkout -b feat/my-feature + +# ... make changes ... + +# Commit using conventional commits +git commit -m "feat: add new cmdlet" + +# Push and create PR +git push -u origin feat/my-feature +gh pr create +``` + +### Dev container (recommended) + +A Docker-based dev environment replicates the full CI setup locally. Works on ARM Macs +(build + test) and x86 (full provisioning flow). + +```bash +./tests/dev.sh # Open pwsh shell in dev container +./tests/dev.sh build # Build the module +./tests/dev.sh test # Run unit tests +./tests/dev.sh integration # Run integration tests against existing PVE +./tests/dev.sh provision # Full CI flow: provision → test → cleanup (x86 only) +``` + +Configure integration test targets by copying `tests/.env.test.example` to `tests/.env.test`. + +### Build & test without container ```bash # Build diff --git a/tests/.env.test.example b/tests/.env.test.example new file mode 100644 index 0000000..a56a714 --- /dev/null +++ b/tests/.env.test.example @@ -0,0 +1,21 @@ +# PSProxmoxVE integration test configuration. +# Copy to .env.test and fill in values. This file is gitignored. + +# ── For testing against a pre-existing PVE (./tests/dev.sh integration) ── +PVETEST_HOST=pve.example.com +PVETEST_PORT=8006 +PVETEST_APITOKEN=user@realm!tokenid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +PVETEST_NODE=pve1 +PVETEST_STORAGE=local +PVETEST_PASSWORD= + +# ── For full provisioning (./tests/dev.sh provision, x86 only) ─────────── +# PVE_ENDPOINT=https://pve.example.com:8006 +# PVE_API_TOKEN=user@realm!tokenid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# PVE_TARGET_NODE=pve1 +# PVE_PASSWORD= + +# ── Optional overrides ────────────────────────────────────────────────── +# CACHE_DIR=/opt/pve-isos +# WORK_DIR=/tmp/pve-integration +# PVE_VERSIONS=9 8 diff --git a/tests/Dockerfile.test b/tests/Dockerfile.test index da67d13..796f7de 100644 --- a/tests/Dockerfile.test +++ b/tests/Dockerfile.test @@ -1,36 +1,53 @@ -# Replicates the GitHub Actions CI environment for PS 7.x unit tests -FROM mcr.microsoft.com/powershell:7.5-ubuntu-24.04 +# PSProxmoxVE development and integration testing container +# +# Two targets: +# dev - .NET SDK + PowerShell + Pester (build & test, works on ARM/x86) +# dev-infra - Adds Terraform + PVE provisioning tools (x86 only) +# +# Usage: +# # For build + test (works on Mac M-series): +# docker compose -f tests/docker-compose.test.yml up -d +# +# # For full CI flow including provisioning (x86 only): +# docker compose -f tests/docker-compose.test.yml --profile infra up -d -# Install .NET SDK 9.0, build module, install Pester, copy to module path -RUN apt-get update && apt-get install -y wget \ - && wget https://dot.net/v1/dotnet-install.sh -O /tmp/dotnet-install.sh \ - && chmod +x /tmp/dotnet-install.sh \ - && /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \ - && ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet \ - && rm /tmp/dotnet-install.sh \ - && rm -rf /var/lib/apt/lists/* +# ── Base: .NET SDK + PowerShell + Pester ──────────────────────────── +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dev + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl jq openssh-client ca-certificates apt-transport-https gnupg \ + && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /usr/share/keyrings/microsoft-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] \ + https://packages.microsoft.com/ubuntu/24.04/prod noble main" \ + > /etc/apt/sources.list.d/microsoft-prod.list \ + && apt-get update && apt-get install -y --no-install-recommends powershell \ + && rm -rf /var/lib/apt/lists/* \ + && pwsh -NoProfile -Command \ + 'Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; \ + Install-Module -Name Pester -MinimumVersion 5.0 -Scope AllUsers -Force' \ + && mkdir -p /usr/local/share/powershell/Modules/PSProxmoxVE WORKDIR /repo -COPY . . +CMD ["pwsh", "-NoProfile"] -RUN dotnet publish src/PSProxmoxVE/PSProxmoxVE.csproj \ - --configuration Release --framework net9.0 --output ./publish/net9.0 \ - && rm -f ./publish/net9.0/PSProxmoxVE.deps.json \ - && pwsh -NoProfile -Command ' \ - Install-Module -Name Pester -MinimumVersion 5.0 -Force -Scope AllUsers; \ - $modulePath = "/usr/local/share/powershell/Modules/PSProxmoxVE"; \ - New-Item -ItemType Directory -Path $modulePath -Force | Out-Null; \ - Copy-Item -Path ./publish/net9.0/* -Destination $modulePath -Recurse -Force' +# ── Full: adds Terraform + PVE provisioning tools (x86 only) ─────── +FROM dev AS dev-infra -# Unset DOTNET_ROOT so PS uses its own bundled runtime -ENV DOTNET_ROOT="" -ENV DOTNET_MULTILEVEL_LOOKUP="" - -CMD ["pwsh", "-NoProfile", "-Command", " \ - Import-Module Pester -MinimumVersion 5.0; \ - $config = New-PesterConfiguration; \ - $config.Run.Path = 'tests/PSProxmoxVE.Tests'; \ - $config.Run.Exit = $true; \ - $config.Filter.ExcludeTag = @('Integration'); \ - $config.Output.Verbosity = 'Detailed'; \ - Invoke-Pester -Configuration $config"] +RUN apt-get update && apt-get install -y --no-install-recommends \ + dosfstools mtools sshpass python3 xorriso gnupg \ + && curl -fsSL https://apt.releases.hashicorp.com/gpg \ + | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \ + https://apt.releases.hashicorp.com noble main" \ + > /etc/apt/sources.list.d/hashicorp.list \ + && curl -fsSL https://enterprise.proxmox.com/debian/proxmox-release-bookworm.gpg \ + -o /usr/share/keyrings/proxmox-release-bookworm.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/proxmox-release-bookworm.gpg] \ + http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \ + > /etc/apt/sources.list.d/proxmox-pve.list \ + && apt-get update && apt-get install -y --no-install-recommends \ + terraform proxmox-auto-install-assistant qemu-utils \ + && rm -rf /var/lib/apt/lists/* diff --git a/tests/dev.sh b/tests/dev.sh new file mode 100755 index 0000000..e362bcb --- /dev/null +++ b/tests/dev.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Helper script for the dev/test containers. +# +# Usage: +# ./tests/dev.sh # Start dev container and open pwsh shell +# ./tests/dev.sh build # Build the module inside the container +# ./tests/dev.sh test # Run unit tests +# ./tests/dev.sh integration # Run integration tests against existing PVE (needs .env.test) +# ./tests/dev.sh provision # Full CI flow: provision → test → cleanup (x86 only) +# ./tests/dev.sh cleanup # Destroy provisioned VMs (x86 only) +# ./tests/dev.sh stop # Stop all containers +# ./tests/dev.sh rebuild # Rebuild container image(s) + +set -euo pipefail +cd "$(dirname "$0")/.." + +COMPOSE="docker compose -f tests/docker-compose.test.yml" +DEV_CONTAINER="psproxmoxve-dev" +INFRA_CONTAINER="psproxmoxve-dev-infra" +MODULE_PATH="/usr/local/share/powershell/Modules/PSProxmoxVE" +RUN_INTEGRATION="tests/infrastructure/scripts/run-integration.sh" + +ensure_dev() { + if ! docker inspect "$DEV_CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then + echo "Starting dev container..." + $COMPOSE up -d dev + fi +} + +ensure_infra() { + if ! docker inspect "$INFRA_CONTAINER" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then + echo "Starting infra container (x86 only)..." + $COMPOSE --profile infra up -d dev-infra + fi +} + +build_module() { + local container="$1" + docker exec "$container" bash -c " + dotnet publish src/PSProxmoxVE/PSProxmoxVE.csproj \ + -c Release -f netstandard2.0 -o /tmp/publish 2>&1 | tail -1 && \ + cp -r /tmp/publish/* $MODULE_PATH/ && \ + echo "Module installed to $MODULE_PATH" + " +} + +case "${1:-shell}" in + shell) + ensure_dev + docker exec -it "$DEV_CONTAINER" pwsh -NoProfile + ;; + + build) + ensure_dev + build_module "$DEV_CONTAINER" + ;; + + test) + ensure_dev + build_module "$DEV_CONTAINER" + docker exec "$DEV_CONTAINER" pwsh -NoProfile -Command " + \$config = New-PesterConfiguration + \$config.Run.Path = 'tests/PSProxmoxVE.Tests' + \$config.Run.Exit = \$true + \$config.Filter.ExcludeTag = @('Integration') + \$config.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration \$config + " + ;; + + integration) + # Run integration tests against a pre-existing PVE (set via .env.test) + ensure_dev + build_module "$DEV_CONTAINER" + docker exec "$DEV_CONTAINER" bash -c " + SKIP_PROVISION=true bash $RUN_INTEGRATION test ${2:-all} + " + ;; + + provision) + # Full CI lifecycle: provision → test → cleanup (x86 infra container) + ensure_infra + build_module "$INFRA_CONTAINER" + docker exec "$INFRA_CONTAINER" bash "$RUN_INTEGRATION" all "${2:-all}" + ;; + + cleanup) + # Destroy provisioned VMs + ensure_infra + docker exec "$INFRA_CONTAINER" bash "$RUN_INTEGRATION" cleanup + ;; + + stop) + $COMPOSE --profile infra down + ;; + + rebuild) + $COMPOSE --profile infra down + $COMPOSE build --no-cache dev + $COMPOSE --profile infra build --no-cache dev-infra + $COMPOSE up -d dev + ;; + + *) + echo "Usage: $0 {shell|build|test|integration|provision|cleanup|stop|rebuild}" + echo "" + echo " shell Open pwsh in the dev container" + echo " build Build the module" + echo " test Run unit tests" + echo " integration Run integration tests against existing PVE (.env.test)" + echo " provision Full CI flow: provision → test → cleanup (x86 only)" + echo " cleanup Destroy provisioned VMs (x86 only)" + echo " stop Stop all containers" + echo " rebuild Rebuild container images" + exit 1 + ;; +esac diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml new file mode 100644 index 0000000..e143dbc --- /dev/null +++ b/tests/docker-compose.test.yml @@ -0,0 +1,50 @@ +## PSProxmoxVE dev/test containers +## +## Default (works on ARM Macs + x86): +## docker compose -f tests/docker-compose.test.yml up -d +## docker exec -it psproxmoxve-dev pwsh +## +## Full CI infra (x86 only — adds Terraform + PVE provisioning tools): +## docker compose -f tests/docker-compose.test.yml --profile infra up -d +## docker exec -it psproxmoxve-dev-infra bash +## +## Stop: +## docker compose -f tests/docker-compose.test.yml down + +services: + dev: + build: + context: .. + dockerfile: tests/Dockerfile.test + target: dev + container_name: psproxmoxve-dev + volumes: + - ..:/repo + working_dir: /repo + stdin_open: true + tty: true + restart: unless-stopped + env_file: + - path: .env.test + required: false + + dev-infra: + build: + context: .. + dockerfile: tests/Dockerfile.test + target: dev-infra + container_name: psproxmoxve-dev-infra + profiles: [infra] + volumes: + - ..:/repo + - pve-isos:/opt/pve-isos + working_dir: /repo + stdin_open: true + tty: true + restart: unless-stopped + env_file: + - path: .env.test + required: false + +volumes: + pve-isos: diff --git a/tests/infrastructure/scripts/run-integration.sh b/tests/infrastructure/scripts/run-integration.sh new file mode 100755 index 0000000..3f3af32 --- /dev/null +++ b/tests/infrastructure/scripts/run-integration.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# PSProxmoxVE integration test orchestration script. +# +# Single source of truth for the provision → test → cleanup lifecycle. +# Called by both the GitHub Actions workflow and the local dev container. +# +# Usage: +# run-integration.sh provision Provision nested PVE VMs +# run-integration.sh test [8|9|all] Run integration tests (default: all) +# run-integration.sh cleanup Destroy provisioned VMs +# run-integration.sh all [8|9|all] Full lifecycle: provision → test → cleanup +# +# Required env vars (provision/cleanup): +# PVE_ENDPOINT Parent PVE API URL (e.g. https://pve.example.com:8006) +# PVE_API_TOKEN Parent PVE API token +# PVE_TARGET_NODE Parent PVE node name +# PVE_PASSWORD Root password for nested PVE instances +# +# Required env vars (test with pre-existing PVE): +# PVETEST_HOST PVE host IP +# PVETEST_APITOKEN PVE API token +# Set SKIP_PROVISION=true +# +# Optional env vars: +# CACHE_DIR ISO/image cache (default: /opt/pve-isos) +# WORK_DIR Temp dir for build artifacts (default: $RUNNER_TEMP or /tmp/pve-integration) +# CONFIG_FILE Test config JSON path (default: $CACHE_DIR/test-config.json) +# MODULE_ARTIFACT Path to built module DLLs (default: ./publish/netstandard2.0) +# PVE_VERSIONS Space-separated versions to provision (default: "9 8") + +set -euo pipefail + +# ── Paths ─────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INFRA_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$INFRA_DIR/../.." && pwd)" + +# ── Defaults ──────────────────────────────────────────────────────── +CACHE_DIR="${CACHE_DIR:-/opt/pve-isos}" +WORK_DIR="${WORK_DIR:-${RUNNER_TEMP:-/tmp/pve-integration}}" +CONFIG_FILE="${CONFIG_FILE:-$CACHE_DIR/test-config.json}" +MODULE_ARTIFACT="${MODULE_ARTIFACT:-$REPO_ROOT/publish/netstandard2.0}" +PVE_VERSIONS="${PVE_VERSIONS:-9 8}" +SKIP_PROVISION="${SKIP_PROVISION:-false}" + +# ── Version config ────────────────────────────────────────────────── +pve_iso() { case "$1" in 9) echo "${PVE9_ISO:-proxmox-ve_9.1-1.iso}";; 8) echo "${PVE8_ISO:-proxmox-ve_8.4-1.iso}";; esac; } +pve_vmid() { case "$1" in 9) echo "${PVE9_VMID:-99909}";; 8) echo "${PVE8_VMID:-99908}";; esac; } +pve_vmname() { case "$1" in 9) echo "pve-test-pve9";; 8) echo "pve-test-pve8";; esac; } + +# ── CI helpers ────────────────────────────────────────────────────── +ci_mask() { [[ "${GITHUB_ACTIONS:-}" == "true" ]] && echo "::add-mask::$1" || true; } +ci_error() { [[ "${GITHUB_ACTIONS:-}" == "true" ]] && echo "::error::$1" || echo "ERROR: $1" >&2; } + +log() { echo "==> $*"; } + +require_env() { + local var="$1" + if [[ -z "${!var:-}" ]]; then + ci_error "Required environment variable $var is not set" + exit 1 + fi +} + +# ── Subcommands ───────────────────────────────────────────────────── + +cmd_provision() { + log "Starting provisioning..." + require_env PVE_ENDPOINT + require_env PVE_API_TOKEN + require_env PVE_TARGET_NODE + require_env PVE_PASSWORD + + ci_mask "$PVE_PASSWORD" + mkdir -p "$WORK_DIR" "$CACHE_DIR" + + # Ensure base ISOs + for v in $PVE_VERSIONS; do + log "Ensuring base ISO for PVE $v..." + bash "$SCRIPT_DIR/ensure-base-iso.sh" "$(pve_iso "$v")" "$CACHE_DIR" + done + + # Ensure cloud images + log "Ensuring cloud images..." + local cloud_output + cloud_output=$(bash "$SCRIPT_DIR/ensure-cloud-images.sh" "$CACHE_DIR") + CLOUD_IMAGE_PATH=$(echo "$cloud_output" | grep "^CLOUD_IMAGE_PATH=" | cut -d= -f2) + OVA_PATH=$(echo "$cloud_output" | grep "^OVA_PATH=" | cut -d= -f2) + + # Pre-flight cleanup + for v in $PVE_VERSIONS; do + local iso_name + iso_name="$(pve_iso "$v")" + log "Pre-flight cleanup for PVE $v (VMID $(pve_vmid "$v"))..." + bash "$SCRIPT_DIR/preflight-cleanup.sh" \ + "$PVE_ENDPOINT" "$PVE_API_TOKEN" \ + "$(pve_vmid "$v")" "${iso_name%.iso}-auto.iso" "$INFRA_DIR" + done + + # Generate answer file + log "Generating answer file..." + local escaped_pve_password + escaped_pve_password=$(printf '%s' "$PVE_PASSWORD" | sed 's/[\/&\\]/\\&/g') + sed "s/\${root_password}/${escaped_pve_password}/" \ + "$INFRA_DIR/answer.toml.tftpl" > "$WORK_DIR/answer.toml" + + # Prepare auto-install ISOs + for v in $PVE_VERSIONS; do + local iso_name + iso_name="$(pve_iso "$v")" + log "Preparing auto-install ISO for PVE $v..." + bash "$SCRIPT_DIR/prepare-auto-iso.sh" \ + "$CACHE_DIR/$iso_name" \ + "$WORK_DIR/answer.toml" \ + "$SCRIPT_DIR/first-boot.sh" \ + "$WORK_DIR/${iso_name%.iso}-auto.iso" \ + --cache-dir "$CACHE_DIR" + done + + # Terraform + log "Running Terraform init..." + (cd "$INFRA_DIR" && terraform init -input=false) + + log "Building Terraform vars..." + local tfvars="$WORK_DIR/instances.tfvars.json" + local instances='{}' + for v in $PVE_VERSIONS; do + local iso_name + iso_name="$(pve_iso "$v")" + local iso_path="$WORK_DIR/${iso_name%.iso}-auto.iso" + local vm_id + vm_id="$(pve_vmid "$v")" + local vm_name + vm_name="$(pve_vmname "$v")" + instances="$(jq \ + --arg key "pve${v}" \ + --arg iso_local_path "$iso_path" \ + --arg vm_name "$vm_name" \ + --argjson vm_id "$vm_id" \ + '. + {($key): {iso_local_path: $iso_local_path, vm_id: $vm_id, vm_name: $vm_name}}' \ + <<<"$instances")" + done + jq -n --argjson pve_instances "$instances" '{pve_instances: $pve_instances}' > "$tfvars" + + log "Running Terraform apply..." + (cd "$INFRA_DIR" && \ + TF_VAR_proxmox_endpoint="$PVE_ENDPOINT" \ + TF_VAR_proxmox_api_token="$PVE_API_TOKEN" \ + TF_VAR_target_node="$PVE_TARGET_NODE" \ + TF_VAR_test_vm_password="$PVE_PASSWORD" \ + terraform apply -auto-approve -input=false -var-file="$tfvars") + + # Wait for PVE instances and create API tokens + for v in $PVE_VERSIONS; do + log "Waiting for PVE $v to boot and creating API token..." + local output + output=$(bash "$SCRIPT_DIR/create-api-token.sh" \ + "$PVE_ENDPOINT" "$PVE_API_TOKEN" \ + "$(pve_vmid "$v")" "$PVE_PASSWORD" 900) + local ip token + ip=$(echo "$output" | grep "^IP=" | cut -d= -f2) + token=$(echo "$output" | grep "^TOKEN=" | cut -d= -f2-) + log "PVE $v ready at $ip" + echo "{\"host\":\"$ip\",\"token\":\"$token\"}" > "$WORK_DIR/pve${v}.json" + done + + # Prepare test environments + for v in $PVE_VERSIONS; do + local ip + ip=$(jq -r .host "$WORK_DIR/pve${v}.json") + log "Preparing test environment on PVE $v ($ip)..." + bash "$SCRIPT_DIR/prepare-test-environment.sh" "$ip" "$PVE_PASSWORD" + done + + # Write test config + log "Writing test config to $CONFIG_FILE..." + local jq_args=() + for v in $PVE_VERSIONS; do + jq_args+=(--argjson "pve${v}" "$(cat "$WORK_DIR/pve${v}.json")") + done + jq_args+=(--arg cloud_image "${CLOUD_IMAGE_PATH:-}") + jq_args+=(--arg ova "${OVA_PATH:-}") + + local jq_expr="{" + local first=true + for v in $PVE_VERSIONS; do + $first || jq_expr+="," + first=false + jq_expr+="pve${v}: \$pve${v}" + done + jq_expr+=", cloud_image_path: \$cloud_image, ova_path: \$ova}" + + jq -n "${jq_args[@]}" "$jq_expr" > "$CONFIG_FILE" + log "Test config written to $CONFIG_FILE" + log "Provisioning complete." +} + +cmd_test() { + local requested="${1:-all}" + local versions_to_test + + if [[ "$requested" == "all" ]]; then + versions_to_test="$PVE_VERSIONS" + else + versions_to_test="$requested" + fi + + # Install module + local module_path="${MODULE_PATH:-$HOME/.local/share/powershell/Modules/PSProxmoxVE}" + if [[ -d "$MODULE_ARTIFACT" ]] && ls "$MODULE_ARTIFACT"/*.dll >/dev/null 2>&1; then + log "Installing module from $MODULE_ARTIFACT..." + mkdir -p "$module_path" + cp -r "$MODULE_ARTIFACT"/* "$module_path/" + else + # Try building it + log "Module artifact not found at $MODULE_ARTIFACT, building..." + (cd "$REPO_ROOT" && dotnet publish src/PSProxmoxVE/PSProxmoxVE.csproj \ + -c Release -f netstandard2.0 -o /tmp/pve-module-publish 2>&1 | tail -1) + mkdir -p "$module_path" + cp -r /tmp/pve-module-publish/* "$module_path/" + fi + + # Create test ISO + local iso_path="$WORK_DIR/pvetest.iso" + mkdir -p "$WORK_DIR" + if [[ ! -f "$iso_path" ]]; then + dd if=/dev/urandom of="$iso_path" bs=1M count=1 2>/dev/null + fi + + local overall_exit=0 + + for v in $versions_to_test; do + log "Running integration tests for PVE $v..." + + # Set env vars from config or from environment + if [[ "$SKIP_PROVISION" == "true" ]]; then + : "${PVETEST_HOST:?Set PVETEST_HOST when using SKIP_PROVISION}" + : "${PVETEST_APITOKEN:?Set PVETEST_APITOKEN when using SKIP_PROVISION}" + export PVETEST_PORT="${PVETEST_PORT:-8006}" + export PVETEST_NODE="${PVETEST_NODE:-pve}" + export PVETEST_STORAGE="${PVETEST_STORAGE:-local}" + export PVETEST_CLOUD_IMAGE_PATH="${PVETEST_CLOUD_IMAGE_PATH:-}" + export PVETEST_OVA_PATH="${PVETEST_OVA_PATH:-}" + else + if [[ ! -f "$CONFIG_FILE" ]]; then + ci_error "No test config found at $CONFIG_FILE — run 'provision' first or set SKIP_PROVISION=true" + exit 1 + fi + export PVETEST_HOST=$(jq -r ".pve${v}.host" "$CONFIG_FILE") + export PVETEST_APITOKEN=$(jq -r ".pve${v}.token" "$CONFIG_FILE") + export PVETEST_PORT=8006 + export PVETEST_NODE=pve + export PVETEST_STORAGE=local + export PVETEST_CLOUD_IMAGE_PATH=$(jq -r '.cloud_image_path' "$CONFIG_FILE") + export PVETEST_OVA_PATH=$(jq -r '.ova_path' "$CONFIG_FILE") + fi + + export PVETEST_ISO_PATH="$iso_path" + export PVETEST_PVE_VERSION="$v" + export PVETEST_PASSWORD="${PVETEST_PASSWORD:-${PVE_PASSWORD:-}}" + + # Verify API reachable + log "Verifying PVE $v API at $PVETEST_HOST:$PVETEST_PORT..." + if ! curl -sk --connect-timeout 10 \ + -H "Authorization: PVEAPIToken=${PVETEST_APITOKEN}" \ + "https://${PVETEST_HOST}:${PVETEST_PORT}/api2/json/nodes" | grep -q '"node"'; then + ci_error "Cannot reach PVE $v API at ${PVETEST_HOST}:${PVETEST_PORT}" + overall_exit=3 + continue + fi + + # Run Pester + local test_exit=0 + pwsh -NoProfile -Command ' + Import-Module Pester -MinimumVersion 5.0 + $config = New-PesterConfiguration + $config.Run.Path = "tests/PSProxmoxVE.Tests/Integration" + $config.Filter.Tag = "Integration" + $config.Output.Verbosity = "Detailed" + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = "NUnitXml" + $config.TestResult.OutputPath = "TestResults/integration-results-pve'"$v"'.xml" + Invoke-Pester -Configuration $config + ' || test_exit=$? + + if [[ $test_exit -ne 0 ]]; then + ci_error "PVE $v integration tests failed (exit code $test_exit)" + overall_exit=3 + else + log "PVE $v integration tests passed." + fi + done + + return $overall_exit +} + +cmd_cleanup() { + log "Starting cleanup..." + for v in $PVE_VERSIONS; do + local iso_name + iso_name="$(pve_iso "$v")" + log "Cleaning up PVE $v (VMID $(pve_vmid "$v"))..." + bash "$SCRIPT_DIR/preflight-cleanup.sh" \ + "${PVE_ENDPOINT:-}" "${PVE_API_TOKEN:-}" \ + "$(pve_vmid "$v")" "${iso_name%.iso}-auto.iso" "$INFRA_DIR" \ + || true + done + log "Cleanup complete." +} + +cmd_all() { + local test_versions="${1:-all}" + local test_exit=0 + + trap 'log "Running cleanup after test run..."; cmd_cleanup || true' EXIT + + cmd_provision + cmd_test "$test_versions" || test_exit=$? + + if [[ $test_exit -ne 0 ]]; then + log "Tests failed with exit code $test_exit. Cleanup will still run." + fi + + # Trap handles cleanup on exit + return $test_exit +} + +# ── Main ──────────────────────────────────────────────────────────── +main() { + local cmd="${1:-}" + shift || true + + case "$cmd" in + provision) cmd_provision "$@" ;; + test) cmd_test "$@" ;; + cleanup) cmd_cleanup "$@" ;; + all) cmd_all "$@" ;; + *) + echo "Usage: $(basename "$0") {provision|test|cleanup|all} [8|9|all]" + echo "" + echo "Subcommands:" + echo " provision Provision nested PVE VMs via Terraform" + echo " test [8|9|all] Run integration tests (default: all versions)" + echo " cleanup Destroy provisioned VMs" + echo " all [8|9|all] Full lifecycle: provision → test → cleanup" + exit 1 + ;; + esac +} + +main "$@"