diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 137f500..484725a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -96,8 +96,9 @@ jobs: - name: Build and push uses: docker/build-push-action@v7 with: - context: tests/infrastructure - file: tests/infrastructure/Dockerfile + context: . + file: tests/Dockerfile.test + target: dev-infra push: true tags: ${{ env.TEST_IMAGE }}:${{ github.sha }},${{ env.TEST_IMAGE }}:latest diff --git a/.gitignore b/.gitignore index 50cd147..4c1e090 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ BenchmarkDotNet.Artifacts/ .env.* !.env.*.example tests/.env.test +tests/infrastructure/.terraform/ +tests/infrastructure/.terraform.lock.hcl +tests/infrastructure/terraform.tfstate* +tests/infrastructure/*.tfvars.json diff --git a/CLAUDE.md b/CLAUDE.md index 967a053..fe05397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,15 +32,15 @@ gh pr create 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) +```powershell +./tests/dev.ps1 # Open pwsh shell in dev container +./tests/dev.ps1 build # Build the module +./tests/dev.ps1 test # Run unit tests (ARM + x86) +./tests/dev.ps1 integration # Provision nested PVE, run tests, cleanup (x86 only) +./tests/dev.ps1 provision # Provision nested PVE only, no tests (x86 only) ``` -Configure integration test targets by copying `tests/.env.test.example` to `tests/.env.test`. +Configure parent PVE credentials by copying `tests/.env.test.example` to `tests/.env.test`. ### Build & test without container @@ -54,8 +54,8 @@ dotnet test tests/PSProxmoxVE.Core.Tests/ # Pester tests (requires pwsh) pwsh -Command "Invoke-Pester tests/PSProxmoxVE.Tests/ -Output Detailed" -# Run all tests via helper -pwsh -File tools/Invoke-Tests.ps1 +# Run all tests via dev container +./tests/dev.ps1 test ``` ## Key Conventions diff --git a/README.md b/README.md index 200ca0f..0529a76 100644 --- a/README.md +++ b/README.md @@ -483,8 +483,8 @@ SDN management requires Proxmox VE 8.0 or later. Connected server is version 7.4 1. Clone the repository 2. Open `PSProxmoxVE.sln` in your IDE 3. Build: `dotnet build` -4. Run unit tests: `./tools/Invoke-Tests.ps1` -5. Run integration tests (requires live PVE node): `./tools/Invoke-Tests.ps1 -Tier Integration` +4. Run unit tests: `./tests/dev.ps1 test` +5. Run integration tests (provisions nested PVE, x86 only): `./tests/dev.ps1 integration` ### Commit Convention diff --git a/tests/.env.test.example b/tests/.env.test.example index a56a714..ec9883e 100644 --- a/tests/.env.test.example +++ b/tests/.env.test.example @@ -1,21 +1,17 @@ # PSProxmoxVE integration test configuration. # Copy to .env.test and fill in values. This file is gitignored. +# +# These credentials point to the PARENT PVE host where nested test VMs +# will be provisioned. Integration tests run against the nested VMs, +# not against this host directly. -# ── 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= +# ── Required: parent PVE for provisioning ───────────────────────────── +PVE_ENDPOINT=https://pve.example.com:8006 +PVE_API_TOKEN=user@realm!tokenid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +PVE_TARGET_NODE=pve1 +PVE_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 ────────────────────────────────────────────────── +# ── 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 796f7de..e64e8e6 100644 --- a/tests/Dockerfile.test +++ b/tests/Dockerfile.test @@ -16,16 +16,28 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS dev ENV DEBIAN_FRONTEND=noninteractive +# Install base packages 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 \ + && rm -rf /var/lib/apt/lists/* + +# Install PowerShell: APT package on amd64, dotnet global tool on arm64. +# The Microsoft APT repo does not publish arm64 packages for PowerShell. +RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \ + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /usr/share/keyrings/microsoft-archive-keyring.gpg \ + && echo "deb [arch=amd64 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/*; \ + else \ + dotnet tool install --global PowerShell \ + && ln -s /root/.dotnet/tools/pwsh /usr/local/bin/pwsh; \ + fi + +# Install Pester and prepare module directory +RUN 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 diff --git a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 index 9175f63..b92e378 100644 --- a/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 +++ b/tests/PSProxmoxVE.Tests/Integration/Integration.Tests.ps1 @@ -24,8 +24,8 @@ To run: Invoke-Pester -Path ./tests/PSProxmoxVE.Tests -Tag Integration - # or use the project's helper script: - ./Invoke-Tests.ps1 -Tier Integration + # or use the dev container: + ./tests/dev.ps1 integration #> BeforeAll { diff --git a/tests/PSProxmoxVE.Tests/Integration/README.md b/tests/PSProxmoxVE.Tests/Integration/README.md index ed3dcd1..9dc05a9 100644 --- a/tests/PSProxmoxVE.Tests/Integration/README.md +++ b/tests/PSProxmoxVE.Tests/Integration/README.md @@ -119,10 +119,10 @@ env: The integration suite is tagged `Integration`. Use the `-Tag` filter so that the unit tests and integration tests can be run independently. -### Via the project helper script (recommended) +### Via the dev container (recommended) ```powershell -./Invoke-Tests.ps1 -Tier Integration +./tests/dev.ps1 integration ``` ### Directly with Invoke-Pester diff --git a/tests/dev.ps1 b/tests/dev.ps1 new file mode 100644 index 0000000..d08c13a --- /dev/null +++ b/tests/dev.ps1 @@ -0,0 +1,251 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Helper script for the dev/test containers. + +.DESCRIPTION + Manages Docker-based dev containers for building, testing, and running + integration tests for PSProxmoxVE. Works on Windows, macOS, and Linux. + + For x86-only commands (integration, provision, cleanup), use -DockerHost + to run containers on a remote Docker host via SSH. The script syncs the + repo to the remote host automatically. + +.PARAMETER Command + The action to perform: + shell - Open pwsh in the dev container (default) + build - Build the module inside the container + test - Run unit tests (ARM + x86) + integration - Provision nested PVE VMs, run integration tests, cleanup (x86 only) + provision - Provision nested PVE VMs only, no tests (x86 only) + cleanup - Destroy provisioned VMs (x86 only) + stop - Stop all containers + rebuild - Rebuild container image(s) + +.PARAMETER PveVersion + PVE version to test against (8, 9, or all). Default: all. + Only used with 'integration' and 'provision' commands. + +.PARAMETER NoCleanup + When used with 'integration', skips cleanup after tests complete. + Nested PVE VMs are left running for inspection or re-testing. + Run './tests/dev.ps1 cleanup' to destroy them later. + +.PARAMETER DockerHost + SSH destination for a remote Docker host (e.g. user@runner-vm). + When set, the repo is rsynced to the remote host and all Docker + commands run against the remote daemon. Useful for running x86 + containers from an ARM Mac. + + Prerequisites on the remote host: + - Docker installed + - SSH user in the 'docker' group (sudo usermod -aG docker $USER) + - SSH key-based auth from the local machine + +.EXAMPLE + ./tests/dev.ps1 + # Opens a pwsh shell in the dev container + +.EXAMPLE + ./tests/dev.ps1 test + # Builds the module and runs unit tests + +.EXAMPLE + ./tests/dev.ps1 integration -DockerHost user@runner-vm + # Syncs repo to runner-vm, provisions nested PVE, runs tests, cleans up + +.EXAMPLE + ./tests/dev.ps1 integration 9 -DockerHost user@runner-vm + # Same but only for PVE 9 +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('shell', 'build', 'test', 'integration', 'provision', 'cleanup', 'stop', 'rebuild')] + [string] $Command = 'shell', + + [Parameter(Position = 1)] + [string] $PveVersion = 'all', + + [Parameter()] + [string] $DockerHost, + + [Parameter()] + [Alias('k')] + [switch] $NoCleanup +) + +$ErrorActionPreference = 'Stop' + +# Resolve repo root (parent of tests/) +$RepoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $RepoRoot +try { + +# ── Remote Docker host support ──────────────────────────────────────── +# When -DockerHost is specified, we rsync the repo (including .env.test) +# to the remote host and set DOCKER_HOST so all docker/compose commands +# execute on the remote daemon. The compose file's volume mount (..:/repo) +# and build context reference the remote copy. + +$RemoteRepoPath = $null + +if ($DockerHost) { + $RemoteRepoPath = "/tmp/psproxmoxve-dev" + $env:DOCKER_HOST = "ssh://$DockerHost" + + Write-Host "Syncing repo to ${DockerHost}:${RemoteRepoPath}..." + # Create remote directory + ssh $DockerHost "mkdir -p $RemoteRepoPath" + if ($LASTEXITCODE -ne 0) { throw "Failed to create remote directory" } + + # Rsync repo to remote host, excluding build artifacts + rsync -az --delete ` + --exclude 'bin/' ` + --exclude 'obj/' ` + --exclude 'publish/' ` + --exclude 'TestResults/' ` + --exclude '.terraform/' ` + --exclude 'terraform.tfstate*' ` + --include '.env.test' ` + ./ "${DockerHost}:${RemoteRepoPath}/" + if ($LASTEXITCODE -ne 0) { throw "Failed to sync repo to remote host" } + + Write-Host "Using remote Docker host: $DockerHost" +} + +# When running remotely, docker compose reads the compose file locally but +# volume mounts resolve on the remote host. We generate a temporary override +# file that remaps volume mounts to the rsynced remote path. +$ComposeFile = 'tests/docker-compose.test.yml' +$ComposeArgs = @('-f', $ComposeFile) +$OverrideFile = $null + +if ($RemoteRepoPath) { + $OverrideFile = Join-Path ([System.IO.Path]::GetTempPath()) 'docker-compose.remote-override.yml' + + # Override volume mounts to point at the rsynced remote repo path. + # env_file is NOT overridden — compose reads it locally and injects + # the values as container env vars, which is what we want. + @" +services: + dev: + volumes: + - ${RemoteRepoPath}:/repo + dev-infra: + volumes: + - ${RemoteRepoPath}:/repo + - /opt/pve-isos:/opt/pve-isos +"@ | Set-Content -Path $OverrideFile -Encoding utf8 + + $ComposeArgs = @('-f', $ComposeFile, '-f', $OverrideFile) +} + +$DevContainer = 'psproxmoxve-dev' +$InfraContainer = 'psproxmoxve-dev-infra' +$RunIntegration = 'tests/infrastructure/scripts/run-integration.sh' + +function Start-DevContainer { + # 'up -d' is idempotent: starts if stopped, recreates if config/env changed, no-ops if current + docker compose @ComposeArgs up -d dev + if ($LASTEXITCODE -ne 0) { throw 'Failed to start dev container' } +} + +function Start-InfraContainer { + docker compose @ComposeArgs --profile infra up -d dev-infra + if ($LASTEXITCODE -ne 0) { throw 'Failed to start infra container (x86 only)' } +} + +function Invoke-BuildModule { + param([string] $Container) + 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/* /usr/local/share/powershell/Modules/PSProxmoxVE/ && \ +echo 'Module installed to /usr/local/share/powershell/Modules/PSProxmoxVE' +"@ + if ($LASTEXITCODE -ne 0) { throw "Module build failed (exit code $LASTEXITCODE)" } +} + +switch ($Command) { + 'shell' { + if ($DockerHost) { + Write-Warning "Interactive shell over remote Docker is not supported. Use: ssh $DockerHost 'docker exec -it $DevContainer pwsh -NoProfile'" + return + } + Start-DevContainer + docker exec -it $DevContainer pwsh -NoProfile + } + + 'build' { + Start-DevContainer + Invoke-BuildModule $DevContainer + } + + 'test' { + Start-DevContainer + Invoke-BuildModule $DevContainer + docker exec $DevContainer 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 +'@ + if ($LASTEXITCODE -ne 0) { throw "Unit tests failed (exit code $LASTEXITCODE)" } + } + + 'integration' { + # Full lifecycle: provision nested PVE VMs -> test -> cleanup (x86 only) + # Requires PVE_ENDPOINT, PVE_API_TOKEN, PVE_TARGET_NODE, PVE_PASSWORD in .env.test + # run-integration.sh handles module build if no pre-built artifact exists + Start-InfraContainer + if ($NoCleanup) { + # Provision and test separately — leave VMs running for inspection + docker exec $InfraContainer bash $RunIntegration provision + if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } + docker exec $InfraContainer bash $RunIntegration test $PveVersion + if ($LASTEXITCODE -ne 0) { throw "Integration tests failed (exit code $LASTEXITCODE)" } + Write-Host 'VMs left running (-NoCleanup). Run ./tests/dev.ps1 cleanup to destroy them.' + } else { + docker exec $InfraContainer bash $RunIntegration all $PveVersion + if ($LASTEXITCODE -ne 0) { throw "Integration tests failed (exit code $LASTEXITCODE)" } + } + } + + 'provision' { + # Provision nested PVE VMs only, without running tests (x86 only) + # Useful for iterating: provision once, then shell in and run tests manually + Start-InfraContainer + docker exec $InfraContainer bash $RunIntegration provision + if ($LASTEXITCODE -ne 0) { throw "Provisioning failed (exit code $LASTEXITCODE)" } + } + + 'cleanup' { + # Destroy provisioned VMs + Start-InfraContainer + docker exec $InfraContainer bash $RunIntegration cleanup + if ($LASTEXITCODE -ne 0) { throw "Cleanup failed (exit code $LASTEXITCODE)" } + } + + 'stop' { + docker compose @ComposeArgs --profile infra down + } + + 'rebuild' { + docker compose @ComposeArgs --profile infra down + docker compose @ComposeArgs build --no-cache dev + docker compose @ComposeArgs --profile infra build --no-cache dev-infra + docker compose @ComposeArgs up -d dev + } +} + +} finally { + if ($OverrideFile -and (Test-Path $OverrideFile)) { + Remove-Item $OverrideFile -Force -ErrorAction SilentlyContinue + } + if ($DockerHost) { + Remove-Item Env:\DOCKER_HOST -ErrorAction SilentlyContinue + } + Pop-Location +} diff --git a/tests/dev.sh b/tests/dev.sh deleted file mode 100755 index e362bcb..0000000 --- a/tests/dev.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/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 index e143dbc..b891467 100644 --- a/tests/docker-compose.test.yml +++ b/tests/docker-compose.test.yml @@ -37,7 +37,7 @@ services: profiles: [infra] volumes: - ..:/repo - - pve-isos:/opt/pve-isos + - /opt/pve-isos:/opt/pve-isos working_dir: /repo stdin_open: true tty: true @@ -45,6 +45,3 @@ services: env_file: - path: .env.test required: false - -volumes: - pve-isos: diff --git a/tests/infrastructure/Dockerfile b/tests/infrastructure/Dockerfile deleted file mode 100644 index 1c8ab9f..0000000 --- a/tests/infrastructure/Dockerfile +++ /dev/null @@ -1,67 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive - -# ── Base packages ────────────────────────────────────────────────────── -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - jq \ - dosfstools \ - mtools \ - openssh-client \ - sshpass \ - apt-transport-https \ - gnupg \ - lsb-release \ - ca-certificates \ - python3 \ - xorriso \ - && rm -rf /var/lib/apt/lists/* - -# ── Microsoft repo (PowerShell) ─────────────────────────────────────── -RUN curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \ - | gpg --dearmor -o /usr/share/keyrings/microsoft-archive-keyring.gpg && \ - echo "deb [arch=amd64 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 - -# ── HashiCorp repo (Terraform) ──────────────────────────────────────── -RUN 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 - -# ── Proxmox repo (proxmox-auto-install-assistant) ───────────────────── -RUN 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 - -# ── Install tooling ─────────────────────────────────────────────────── -RUN apt-get update && apt-get install -y --no-install-recommends \ - powershell \ - terraform \ - proxmox-auto-install-assistant \ - qemu-utils \ - && rm -rf /var/lib/apt/lists/* - -# ── Install Pester ──────────────────────────────────────────────────── -RUN pwsh -NoProfile -Command \ - 'Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; \ - Install-Module -Name Pester -MinimumVersion 5.0 -Scope AllUsers -Force' - -# ── Verify all tools are present ────────────────────────────────────── -RUN echo "=== Tool verification ===" && \ - pwsh --version && \ - terraform --version && \ - proxmox-auto-install-assistant --version && \ - sshpass -V | head -1 && \ - xorriso --version 2>&1 | head -1 && \ - qemu-img --version | head -1 && \ - curl --version | head -1 && \ - jq --version && \ - python3 --version && \ - ssh -V 2>&1 && \ - echo "=== All tools verified ===" diff --git a/tools/Invoke-Tests.ps1 b/tools/Invoke-Tests.ps1 deleted file mode 100644 index 96af64a..0000000 --- a/tools/Invoke-Tests.ps1 +++ /dev/null @@ -1,390 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Test runner for the PSProxmoxVE project. - -.DESCRIPTION - Runs unit and/or integration tests for PSProxmoxVE. Unit tests include both - xUnit (.NET) and Pester (PowerShell) suites. Integration tests require a live - or mocked PVE endpoint and are executed via Pester with the Integration tag. - -.PARAMETER Tier - Which tier of tests to run. Valid values: Unit, Integration, All. Default: Unit. - -.PARAMETER Framework - Dotnet target framework moniker to pass to 'dotnet test' (e.g. net8.0, net9.0). - When omitted the framework is auto-detected from the first *.csproj found under - tests/PSProxmoxVE.Core.Tests. - -.PARAMETER FromTerraform - Read PVE connection details from 'terraform output -json' in - tests/infrastructure/. Requires Terraform to be installed and - 'terraform apply' to have been run. Implies -Tier Integration. - -.PARAMETER PveHost - Hostname or IP of the PVE test node. Sets PVETEST_HOST. - -.PARAMETER PvePort - API port. Default 8006. Sets PVETEST_PORT. - -.PARAMETER PveApiToken - API token in USER@REALM!TOKENID=UUID format. Sets PVETEST_APITOKEN. - -.PARAMETER PveNode - PVE node name (e.g. pve). Sets PVETEST_NODE. - -.PARAMETER PveStorage - Storage pool for disk/ISO operations (e.g. local). Sets PVETEST_STORAGE. - -.PARAMETER PveIsoPath - Local filesystem path to a small .iso for upload tests. Sets PVETEST_ISO_PATH. - -.EXAMPLE - ./tools/Invoke-Tests.ps1 - -.EXAMPLE - ./tools/Invoke-Tests.ps1 -Tier All - -.EXAMPLE - ./tools/Invoke-Tests.ps1 -FromTerraform - -.EXAMPLE - ./tools/Invoke-Tests.ps1 -Tier Integration ` - -PveHost 192.168.1.200 -PveApiToken "root@pam!integration=abc123..." ` - -PveNode pve -PveStorage local -PveIsoPath /tmp/test.iso - -.EXAMPLE - ./tools/Invoke-Tests.ps1 -Tier Unit -Framework net9.0 -#> -[CmdletBinding(DefaultParameterSetName = 'Explicit')] -param( - [Parameter()] - [ValidateSet('Unit', 'Integration', 'All')] - [string] $Tier = 'Unit', - - [Parameter()] - [string] $Framework, - - # --- Terraform-sourced connection --- - [Parameter(ParameterSetName = 'Terraform')] - [switch] $FromTerraform, - - # --- Explicit connection params --- - [Parameter(ParameterSetName = 'Explicit')] - [string] $PveHost, - - [Parameter(ParameterSetName = 'Explicit')] - [int] $PvePort = 8006, - - [Parameter(ParameterSetName = 'Explicit')] - [string] $PveApiToken, - - [Parameter(ParameterSetName = 'Explicit')] - [string] $PveNode, - - [Parameter(ParameterSetName = 'Explicit')] - [string] $PveStorage, - - [Parameter(ParameterSetName = 'Explicit')] - [string] $PveIsoPath -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# --------------------------------------------------------------------------- -# Integration connection setup -# --------------------------------------------------------------------------- - -if ($FromTerraform) { - $Tier = 'Integration' - $infraDir = Join-Path $PSScriptRoot '../tests/infrastructure' - - if (-not (Get-Command terraform -ErrorAction SilentlyContinue)) { - throw 'terraform not found on PATH. Install Terraform >= 1.5 first.' - } - if (-not (Test-Path $infraDir)) { - throw "Infrastructure directory not found: $infraDir" - } - - Write-Host 'Reading connection details from terraform output...' -ForegroundColor Cyan - $tfOutputJson = terraform -chdir:$infraDir output -json 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "terraform output failed. Have you run 'terraform apply' in $infraDir?`n$tfOutputJson" - } - - $tfOutput = $tfOutputJson | ConvertFrom-Json - $env:PVETEST_HOST = $tfOutput.pve_test_host.value - $env:PVETEST_PORT = $tfOutput.pve_test_port.value - $env:PVETEST_NODE = $tfOutput.pve_test_node_name.value - $env:PVETEST_APITOKEN = terraform -chdir:$infraDir output -raw pve_test_api_token 2>&1 - - # Storage and ISO path are not provisioned by Terraform; require env vars or defaults - if (-not $env:PVETEST_STORAGE) { $env:PVETEST_STORAGE = 'local' } - if (-not $env:PVETEST_ISO_PATH) { - Write-Warning 'PVETEST_ISO_PATH not set — ISO upload tests will be skipped.' - } - - Write-Host " PVE host : $($env:PVETEST_HOST):$($env:PVETEST_PORT)" -ForegroundColor DarkGray - Write-Host " PVE node : $($env:PVETEST_NODE)" -ForegroundColor DarkGray - Write-Host " Storage : $($env:PVETEST_STORAGE)" -ForegroundColor DarkGray -} -elseif ($PSBoundParameters.ContainsKey('PveHost') -or $PSBoundParameters.ContainsKey('PveApiToken')) { - # Explicit params override env vars - if ($PveHost) { $env:PVETEST_HOST = $PveHost } - if ($PvePort) { $env:PVETEST_PORT = $PvePort } - if ($PveApiToken) { $env:PVETEST_APITOKEN = $PveApiToken } - if ($PveNode) { $env:PVETEST_NODE = $PveNode } - if ($PveStorage) { $env:PVETEST_STORAGE = $PveStorage } - if ($PveIsoPath) { $env:PVETEST_ISO_PATH = $PveIsoPath } - - if ($Tier -eq 'Unit') { $Tier = 'Integration' } -} - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -function Write-Header { - param([string] $Text) - $line = '-' * 70 - Write-Host '' - Write-Host $line -ForegroundColor Cyan - Write-Host " $Text" -ForegroundColor Cyan - Write-Host $line -ForegroundColor Cyan -} - -function Assert-Command { - param([string] $Name) - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Required command '$Name' was not found on PATH." - } -} - -# --------------------------------------------------------------------------- -# Path resolution -# --------------------------------------------------------------------------- - -$repoRoot = Split-Path -Parent $PSScriptRoot - -$coreTestProject = Join-Path $repoRoot 'tests' 'PSProxmoxVE.Core.Tests' -$pesterTestDir = Join-Path $repoRoot 'tests' 'PSProxmoxVE.Tests' -$integrationDir = Join-Path $pesterTestDir 'Integration' - -# --------------------------------------------------------------------------- -# Auto-detect framework when not supplied -# --------------------------------------------------------------------------- - -if (-not $Framework) { - $csproj = Get-ChildItem -Path $coreTestProject -Filter '*.csproj' -ErrorAction SilentlyContinue | - Select-Object -First 1 - - if ($csproj) { - # Try singular TargetFramework first, then first entry from TargetFrameworks - $tfmNode = Select-Xml -Path $csproj.FullName -XPath '//*[local-name()="TargetFramework" or local-name()="TargetFrameworks"]' | - Select-Object -First 1 - - if ($tfmNode) { - # TargetFrameworks may be semicolon-separated; pick the first .NET Core/5+ TFM - # On non-Windows, skip net48 since .NET Framework is not available - $allTfms = $tfmNode.Node.InnerText.Trim() -split ';' | ForEach-Object { $_.Trim() } - $onWindows = $PSVersionTable.Platform -eq 'Win32NT' -or $PSVersionTable.PSEdition -eq 'Desktop' - $Framework = $allTfms | Where-Object { $_ -notmatch 'net4' -or $onWindows } | Select-Object -First 1 - Write-Verbose "Auto-detected target framework: $Framework" - } - } -} - -# --------------------------------------------------------------------------- -# Result tracking -# --------------------------------------------------------------------------- - -$results = [ordered] @{} - -# --------------------------------------------------------------------------- -# xUnit tests -# --------------------------------------------------------------------------- - -function Invoke-XUnitTests { - Write-Header 'xUnit Tests (PSProxmoxVE.Core.Tests)' - - Assert-Command 'dotnet' - - if (-not (Test-Path $coreTestProject)) { - Write-Warning "xUnit test project not found at: $coreTestProject -- skipping." - $script:results['xUnit'] = 'Skipped' - return - } - - $dotnetArgs = @( - 'test' - $coreTestProject - '--logger' - 'console;verbosity=normal' - '--nologo' - ) - - if ($Framework) { - $dotnetArgs += '--framework' - $dotnetArgs += $Framework - } - - Write-Host "Running: dotnet $($dotnetArgs -join ' ')" -ForegroundColor DarkGray - - & dotnet @dotnetArgs - $exitCode = $LASTEXITCODE - - if ($exitCode -eq 0) { - Write-Host 'xUnit tests PASSED.' -ForegroundColor Green - $script:results['xUnit'] = 'Passed' - } - else { - Write-Host "xUnit tests FAILED (exit code $exitCode)." -ForegroundColor Red - $script:results['xUnit'] = "Failed (exit $exitCode)" - } -} - -# --------------------------------------------------------------------------- -# Pester unit tests -# --------------------------------------------------------------------------- - -function Invoke-PesterUnitTests { - Write-Header 'Pester Unit Tests (PSProxmoxVE.Tests)' - - if (-not (Test-Path $pesterTestDir)) { - Write-Warning "Pester test directory not found at: $pesterTestDir -- skipping." - $script:results['Pester-Unit'] = 'Skipped' - return - } - - try { - $pesterModule = Get-Module -Name Pester -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 - - if (-not $pesterModule) { - throw 'Pester module is not installed. Run: Install-Module Pester -Force' - } - - Import-Module -Name Pester -RequiredVersion $pesterModule.Version -Force - - $config = New-PesterConfiguration - $config.Run.Path = $pesterTestDir - $config.Filter.ExcludeTag = @('Integration') - $config.Output.Verbosity = 'Detailed' - $config.Run.PassThru = $true - - $pesterResult = Invoke-Pester -Configuration $config - - if ($pesterResult.FailedCount -gt 0) { - Write-Host "Pester unit tests FAILED ($($pesterResult.FailedCount) failed, $($pesterResult.PassedCount) passed)." -ForegroundColor Red - $script:results['Pester-Unit'] = "Failed ($($pesterResult.FailedCount) failed)" - } - else { - Write-Host "Pester unit tests PASSED ($($pesterResult.PassedCount) passed)." -ForegroundColor Green - $script:results['Pester-Unit'] = "Passed ($($pesterResult.PassedCount) passed)" - } - } - catch { - Write-Host "Pester unit tests ERROR: $_" -ForegroundColor Red - $script:results['Pester-Unit'] = "Error: $_" - } -} - -# --------------------------------------------------------------------------- -# Pester integration tests -# --------------------------------------------------------------------------- - -function Invoke-PesterIntegrationTests { - Write-Header 'Pester Integration Tests (PSProxmoxVE.Tests/Integration)' - - if (-not (Test-Path $integrationDir)) { - Write-Warning "Integration test directory not found at: $integrationDir -- skipping." - $script:results['Pester-Integration'] = 'Skipped' - return - } - - try { - $pesterModule = Get-Module -Name Pester -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 - - if (-not $pesterModule) { - throw 'Pester module is not installed. Run: Install-Module Pester -Force' - } - - Import-Module -Name Pester -RequiredVersion $pesterModule.Version -Force - - $config = New-PesterConfiguration - $config.Run.Path = $integrationDir - $config.Filter.Tag = @('Integration') - $config.Output.Verbosity = 'Detailed' - $config.Run.PassThru = $true - - $pesterResult = Invoke-Pester -Configuration $config - - if ($pesterResult.FailedCount -gt 0) { - Write-Host "Integration tests FAILED ($($pesterResult.FailedCount) failed, $($pesterResult.PassedCount) passed)." -ForegroundColor Red - $script:results['Pester-Integration'] = "Failed ($($pesterResult.FailedCount) failed)" - } - else { - Write-Host "Integration tests PASSED ($($pesterResult.PassedCount) passed)." -ForegroundColor Green - $script:results['Pester-Integration'] = "Passed ($($pesterResult.PassedCount) passed)" - } - } - catch { - Write-Host "Integration tests ERROR: $_" -ForegroundColor Red - $script:results['Pester-Integration'] = "Error: $_" - } -} - -# --------------------------------------------------------------------------- -# Dispatch -# --------------------------------------------------------------------------- - -switch ($Tier) { - 'Unit' { - Invoke-XUnitTests - Invoke-PesterUnitTests - } - 'Integration' { - Invoke-PesterIntegrationTests - } - 'All' { - Invoke-XUnitTests - Invoke-PesterUnitTests - Invoke-PesterIntegrationTests - } -} - -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- - -Write-Header 'Test Run Summary' - -$anyFailure = $false - -foreach ($suite in $results.Keys) { - $status = $results[$suite] - $color = switch -Wildcard ($status) { - 'Passed*' { 'Green' } - 'Skipped' { 'Yellow' } - default { 'Red' } - } - - if ($color -eq 'Red') { $anyFailure = $true } - - Write-Host (' {0,-30} {1}' -f "${suite}:", $status) -ForegroundColor $color -} - -Write-Host '' - -if ($anyFailure) { - Write-Host 'One or more test suites FAILED.' -ForegroundColor Red - exit 1 -} -else { - Write-Host 'All test suites completed successfully.' -ForegroundColor Green - exit 0 -}