diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 4838232..42253d5 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -8,13 +8,24 @@ on: workflow_dispatch: inputs: kubernetes_version: - description: 'Kubernetes version (optional, leave blank for latest)' + description: 'Kubernetes version (optional, leave blank to use var-file)' required: false default: '' windows_version: - description: 'Windows version (optional, leave blank for latest)' + description: 'Windows version (optional, leave blank to use var-file)' required: false default: '' + containerd_version: + description: 'Containerd version (optional, leave blank to use var-file)' + required: false + default: '' + +permissions: + contents: read + +concurrency: + group: windows-node-image-builder + cancel-in-progress: false jobs: build-upload-vhd: @@ -24,114 +35,68 @@ jobs: - windows - hyperv - env: - AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} - AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }} - AZURE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER }} - steps: - name: Checkout repository uses: actions/checkout@v4 - name: Display runner info - shell: pwsh + shell: powershell run: | Write-Host "Runner OS: $env:RUNNER_OS" Write-Host "Runner Labels: $env:RUNNER_LABELS" Write-Host "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" - - name: Ensure Hyper-V is enabled - shell: pwsh + - name: Ensure Packer and Azure CLI are installed + shell: powershell run: | - Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { - Write-Host "Checking Hyper-V feature..." - $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All - if ($hv.State -ne "Enabled") { - throw "Hyper-V is not enabled on this host!" - } - Write-Host "Hyper-V is enabled." - }' -Wait - - - name: Grant Hyper-V Administrators group membership - shell: pwsh - run: | - $runnerUser = (whoami).Trim() - Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." - # Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop - Write-Host "Current Hyper-V Administrators members:" - Get-LocalGroupMember -Group "Hyper-V Administrators" | ForEach-Object { Write-Host $_.Name } - - - name: Install Chocolatey, Packer & Azure CLI - shell: pwsh - run: | - Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { - Write-Host "Checking and installing dependencies…" - - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Host "Installing Chocolatey…" - Set-ExecutionPolicy Bypass -Scope Process -Force - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-Expression ((New-Object Net.WebClient).DownloadString("https://community.chocolatey.org/install.ps1")) - } else { - Write-Host "Chocolatey already present." - } - - if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { - Write-Host "Installing Packer…" - choco install packer -y - } else { - Write-Host "Packer already present." + $ErrorActionPreference = 'Stop' + foreach ($tool in @( + @{ Command = 'packer'; Package = 'packer' }, + @{ Command = 'az'; Package = 'azure-cli' } + )) { + if (-not (Get-Command $tool.Command -ErrorAction SilentlyContinue)) { + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + throw "Install Chocolatey or preinstall $($tool.Command) on this runner." + } + choco install $tool.Package -y --no-progress + if ($LASTEXITCODE -ne 0) { + throw "Installing $($tool.Package) failed with exit code $LASTEXITCODE." + } + $env:PATH = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [Environment]::GetEnvironmentVariable('PATH', 'User') + Get-Command $tool.Command -ErrorAction Stop | Out-Null } - - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Host "Installing Azure CLI…" - choco install azure-cli -y - } else { - Write-Host "Azure CLI already present." - } - }' -Wait - - - name: Initialize & Validate Packer - shell: pwsh - run: | - Write-Host "Initializing Packer…" - packer init windows.json.pkr.hcl - - Write-Host "Formatting…" - packer fmt -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl - - Write-Host "Validating…" - packer validate -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl + } - name: Build VHD with Packer - shell: pwsh + id: build + shell: powershell env: KUBERNETES_VERSION: ${{ inputs.kubernetes_version || '' }} WINDOWS_VERSION: ${{ inputs.windows_version || '' }} + CONTAINERD_VERSION: ${{ inputs.containerd_version || '' }} run: | - Write-Host "Starting Packer build…" - packer build -force -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl - - - name: Locate generated VHD - shell: pwsh - run: | - Write-Host "Searching for VHD in output directories..." - $vhd = Get-ChildItem -Path "./output*", "./output-*", "./output" -Include "*.vhd", "*.vhdx" -Recurse | Select-Object -First 1 - if (-not $vhd) { - throw "No VHD file found in output directory!" - } - Write-Host "Found VHD: $($vhd.FullName)" - echo "VHD_PATH=$($vhd.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload VHD to Azure Blob Storage - shell: pwsh + $ErrorActionPreference = 'Stop' + .\scripts\Build-WindowsImage.ps1 ` + -WindowsVersion $env:WINDOWS_VERSION ` + -KubernetesVersion $env:KUBERNETES_VERSION ` + -ContainerdVersion $env:CONTAINERD_VERSION + + - name: Publish canonical disk to Azure Blob Storage + shell: powershell + env: + BUILD_RESULT_PATH: ${{ steps.build.outputs.result_path }} + AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} + AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }} + AZURE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER }} run: | - Write-Host "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" - az storage blob upload ` - --account-name $env:AZURE_STORAGE_ACCOUNT ` - --account-key $env:AZURE_STORAGE_KEY ` - --container-name $env:AZURE_CONTAINER_NAME ` - --file $env:VHD_PATH ` - --name (Split-Path $env:VHD_PATH -Leaf) ` - --overwrite - Write-Host "Upload complete." \ No newline at end of file + $ErrorActionPreference = 'Stop' + .\scripts\Publish-WindowsImage.ps1 -ResultPath $env:BUILD_RESULT_PATH + + - name: Upload build and publication logs + if: always() && steps.build.outputs.log_directory != '' + uses: actions/upload-artifact@v4 + with: + name: packer-log-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.build.outputs.log_directory }} + if-no-files-found: warn \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..484bd8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +packer_cache/ +build-logs/ +build-artifacts/ diff --git a/README.md b/README.md index 8f73f2d..629874a 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,197 @@ -## Prerequisites: -- Make sure the Hyper-V role is enabled -- Install the Windows Assessment and Deployment Kit (32-bit version). https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install#download-the-adk-for-windows-11-version-22h2 -- Add the following location the the system path variable: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\x86\Oscdimg +# Windows Node Image Builder +## Prerequisites +- Windows PowerShell 5.1 or PowerShell 7, running as Administrator. +- Hyper-V enabled, its management tools installed, and a working `Default Switch` + (or change `switch_name` in the var-file). +- The [Windows ADK Deployment Tools](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install). + The build script adds the standard `amd64` and `x86` Oscdimg locations to its + process PATH. Packer creates the unattended-install CD automatically; AnyBurn + and manually mounting an answer ISO are not needed. +- Packer, and Azure CLI if publishing. With Chocolatey already installed: -### On Powershell Administrator complete the following steps +```powershell +choco install packer azure-cli -y +``` + +The self-hosted GitHub Actions runner needs the same prerequisites and an +appropriately privileged service account. Install Chocolatey on the runner once +if the workflow should install missing Packer/Azure CLI packages. The workflow +does not display UAC prompts or interactively enable Windows features. + +## Build locally -1. Clone the repo +Use the shared entry point rather than invoking `packer build` directly. It +initializes plugins, validates the effective configuration, builds the VM, and +verifies the exported disk. A failed native command stops subsequent stages. ```powershell git clone https://github.com/bobsira/windows-node-image-builder.git +Set-Location .\windows-node-image-builder + +$result = .\scripts\Build-WindowsImage.ps1 +$result.DiskPath ``` -2. Change the current directory to `windows-node-image-builder`: +Optional version overrides work for both validation and the build: ```powershell -cd windows-node-image-builder +$result = .\scripts\Build-WindowsImage.ps1 ` + -WindowsVersion '2022' ` + -KubernetesVersion 'v1.37.0' ` + -ContainerdVersion '1.7.25' ``` -3. Install packer using the command below +Omitted or blank version overrides use `windows.auto.pkrvars.hcl`, not the latest +release. Use `-VarFile` for another var-file. The base VM name defaults to +`hybrid-minikube-windows-server`; `-VmName` overrides that base (and the var-file's +`vm_name`), but the unique build suffix is always appended. + +To initialize and validate without creating a VM, taking the host build lock, or +publishing: ```powershell -choco install packer +.\scripts\Build-WindowsImage.ps1 -ValidateOnly ``` -4. Download and install AnyBurn from [here](https://www.anyburn.com/download.php) to generate the `./setup/auto-install.iso` file. Then load/run auto-install.iso. +The template sends the DVD boot key immediately and retries it ten times, rather +than relying on one delayed keystroke. Packer requires `boot_wait = "-1s"` to +disable the delay; `"0s"` selects its default ten-second wait. Installation then +uses `setup\Autounattend.xml`. + +## Build identity and isolation + +| Resource | Naming | +|----------|--------| +| GitHub build ID | `-` | +| Local build ID | UTC timestamp plus a random suffix | +| Temporary VM | `hybrid-minikube-windows-server-` | +| Run directory | `%ProgramData%\WindowsNodeImageBuilder\builds\` | +| Export directory | `\output` | +| Logs and result | `\logs` | +| Published disk | `hybrid-minikube-windows-server.vhdx` (or `.vhd` for an actual VHD) | + +`-ArtifactRoot` overrides the parent directory of all run directories. The default +is outside the checkout so a later GitHub checkout cannot erase failed-run +evidence. `-BuildId` allows an explicit unique ID; reusing an existing directory +fails rather than overwriting it. Builds do not use `-force`. + +Only the current run's exact export directory is searched. It must contain +exactly one nonempty, readable, detached, independent VHD/VHDX with a matching +extension. `logs\result.json` records the build status, VM name, exact disk path, +format, size, and retained VM details when available. + +Both local and CI builds hold the same exclusive file lock at +`%ProgramData%\WindowsNodeImageBuilder\build.lock`. A competing build fails +explicitly. The lock is released when its owner exits; the file remaining on disk +does not mean it is still locked. Do not delete the lock file to bypass it. +Elevated Administrators and the runner's SYSTEM account must have access to this +shared directory; do not relocate the lock per user or checkout. + +The entry point also rejects an already-running `packer build` for this template, +including older builds that did not acquire the lock. It never terminates them. +All new builds must use the shared entry point: raw Packer commands can bypass +the host lock, even though the template now requires a build ID and output path. + +## Publication + +Local builds do not publish automatically. To publish a successful result, set +`AZURE_STORAGE_ACCOUNT`, `AZURE_STORAGE_KEY`, and `AZURE_CONTAINER_NAME` in the +process environment using your normal secret-management mechanism, then run: + +```powershell +.\scripts\Publish-WindowsImage.ps1 ` + -ResultPath (Join-Path $result.LogDirectory 'result.json') +``` + +Publication rechecks the successful build result and its disk. The canonical +Azure blob name is independent of the temporary VM name. Local exported files +and VM configuration are not renamed; create or import the final VM with the +name `hybrid-minikube-windows-server`. Changing a `.vhdx` extension to `.vhd` is +not a disk-format conversion. + +Publishers coordinate through a 60-second Azure lease on the canonical blob, +renewed every 15 seconds during upload. This includes publishers on other hosts +and local invocations. A competing publisher fails explicitly rather than +waiting. The upload carries the lease ID, so a lost lease cannot commit over +another publisher. Cleanup releases ownership; after a crashed publisher stops +renewing, the finite lease expires. Do not break an active lease. + +An absent destination is conditionally created as an empty block blob before +lease acquisition. A failed first publication can leave that zero-byte blob; +it is not a completed image. An existing image is never replaced by an empty +placeholder. Both `.vhdx` and `.vhd` are published as **block-blob file artifacts**, +not Azure VM page-blob disks. + +Credentials are supplied through environment variables, not logged command-line +arguments. `publication.json`, `publication.log`, and `publication-lease.log` +are saved alongside the build result. The successful publication manifest and +verified nonempty disk distinguish a completed image from an initial placeholder. + +## GitHub Actions + +The workflow calls the same build and publication scripts. Its optional version +inputs retain the var-file defaults when left blank. A branch-independent +concurrency group prevents this repository's workflows from overlapping without +cancelling the active build. The host lock also covers local builds, and the +storage lease protects publication across hosts. + +Publication runs only after a successful build. The workflow always attempts to +upload the run's logs as `packer-log--`, including publication +diagnostics when that stage ran. If a failure happens before the build entry +point can create its log directory, inspect the workflow step's own log. + +## Diagnosing failures and cleanup + +Packer runs with `-on-error=abort`. The failed VM and its associated files are +preserved, and a new run cannot reuse their unique identity. Logs include +`console.log` (native stdout/stderr), `packer-debug.log`, `host.log`, and +`result.json`. Validation-only results are marked `Validated`, never `Succeeded`, +and cannot be published. + +Before retrying, inspect the error and any active workflow/Packer process. For +boot failures, inspect the VM console and DVD boot prompt. For provisioning +failures, check the exact retained VM's IP, WinRM listener on TCP 5985, and guest +events at the failure time. Two controllers acting on the same VM can cause +download file locks and competing restarts. + +After collecting diagnostics and confirming no build is using the resource, +remove only the failed VM identified by `RetainedVM.Id` and the exact associated +paths recorded in its result. VM disks may still be in Packer's temporary build +directory rather than the export directory. Do not delete other VMs, broad +`output*` paths, the shared build root, or an active lock. Logs can contain +sensitive machine details; keep their filesystem and artifact access restricted. + +## Script checks + +Run both test suites with one command (Pester 4.9.0 must be installed): + +```powershell +.\scripts\Test-WindowsImage.ps1 +``` -5. Then run the following commands: +The runner imports the required Pester version and finds the tests relative to +its own location, so it also works when invoked by absolute path from another +directory. It prints the test results and returns exit code `0` on success or `1` +on test failure, missing tests, or a runner error. To run it in a fresh Windows +PowerShell process: ```powershell -packer -v -packer plugins install github.com/hashicorp/hyperv -packer init windows.json.pkr.hcl -packer fmt -var-file=windows.auto.pkrvars.hcl windows.json.pkr.hcl -packer validate . -packer build -force -var-file="windows.auto.pkrvars.hcl" "windows.json.pkr.hcl" +powershell.exe -NoProfile -File .\scripts\Test-WindowsImage.ps1 ``` -To override versions locally: -Add -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' (or your desired values) to your Packer commands: +Check Packer formatting separately: ```powershell -packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' "windows.json.pkr.hcl" +packer fmt -check .\windows.json.pkr.hcl ``` +The script tests use mocks for provisioning and Azure operations; they do not +start a VM or publish an image. ### Default password -|OS|username|password| -|--|--------|--------| -|Windows|Administrator|password| \ No newline at end of file +| OS | Username | Password | +|----|----------|----------| +| Windows | Administrator | password | diff --git a/scripts/Build-WindowsImage.ps1 b/scripts/Build-WindowsImage.ps1 new file mode 100644 index 0000000..c7a6540 --- /dev/null +++ b/scripts/Build-WindowsImage.ps1 @@ -0,0 +1,253 @@ +[CmdletBinding()] +param( + [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$')] + [string]$BuildId, + [ValidatePattern('^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}$')] + [string]$VmName = 'hybrid-minikube-windows-server', + [string]$ArtifactRoot = (Join-Path $env:ProgramData 'WindowsNodeImageBuilder\builds'), + [string]$VarFile, + [string]$WindowsVersion, + [string]$KubernetesVersion, + [string]$ContainerdVersion, + [switch]$ValidateOnly +) + +$repositoryRoot = Split-Path $PSScriptRoot -Parent + +function New-ImageBuildId { + if ($env:GITHUB_ACTIONS -eq 'true') { + if ($env:GITHUB_RUN_ID -notmatch '^\d+$' -or $env:GITHUB_RUN_ATTEMPT -notmatch '^\d+$') { + throw 'GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT must be present in GitHub Actions.' + } + return "$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + } + return ('local-{0}-{1}' -f [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmssfff'), [guid]::NewGuid().ToString('N').Substring(0, 8)) +} + +function Enter-ImageBuildLock { + $directory = Join-Path $env:ProgramData 'WindowsNodeImageBuilder' + New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null + $path = Join-Path $directory 'build.lock' + try { + $stream = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + } catch [IO.IOException] { + if (($_.Exception.HResult -band 0xffff) -in 32, 33) { + throw "Another image build holds the host-wide lock at $path. Wait for it to finish; do not delete the lock file." + } + throw + } + try { + $owner = [Text.Encoding]::UTF8.GetBytes("PID=$PID`r`nStartedUTC=$([DateTime]::UtcNow.ToString('o'))`r`n") + $stream.SetLength(0) + $stream.Write($owner, 0, $owner.Length) + $stream.Flush() + return $stream + } catch { + $stream.Dispose() + throw + } +} + +function Assert-ImageBuildHost { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run the build in an elevated PowerShell session or an appropriately privileged runner service.' + } + Get-Command Get-VM, Get-VHD, Test-VHD -ErrorAction Stop | Out-Null + if ((Get-Service vmms -ErrorAction Stop).Status -ne 'Running') { + throw 'The Hyper-V Virtual Machine Management service is not running.' + } + $legacyBuilds = @(Get-CimInstance Win32_Process -Filter "Name = 'packer.exe'" -ErrorAction Stop | + Where-Object { $_.CommandLine -match '\bbuild\b' -and $_.CommandLine -match 'windows\.json\.pkr\.hcl' }) + if ($legacyBuilds.Count -gt 0) { + throw "An existing Packer image build is running (PID(s): $($legacyBuilds.ProcessId -join ', ')). It may predate the shared lock; leave it untouched." + } + if (-not (Get-Command oscdimg.exe, mkisofs.exe -ErrorAction SilentlyContinue)) { + throw 'Install the Windows ADK Deployment Tools and put oscdimg.exe on PATH before building.' + } +} + +function Invoke-ImageCommand { + param( + [Parameter(Mandatory = $true)][string]$Command, + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$LogPath + ) + Get-Command $Command -ErrorAction Stop | Out-Null + Write-Host "Running $Command $($Arguments -join ' ')" + $oldPreference = $ErrorActionPreference + try { + # Windows PowerShell represents native stderr as ErrorRecords, even on success. + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + & $Command @Arguments 2>&1 | + ForEach-Object { $_.ToString() } | + Tee-Object -FilePath $LogPath -Append -ErrorAction Stop | + ForEach-Object { Write-Host $_ } + $commandExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + if ($commandExitCode -ne 0) { + throw "$Command $($Arguments[0]) failed with exit code $commandExitCode. See $LogPath." + } +} + +function Get-ImageBuildDisk { + param([Parameter(Mandatory = $true)][string]$OutputDirectory) + + if (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + throw "The build did not create its output directory: $OutputDirectory" + } + $disks = @(Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File -ErrorAction Stop | + Where-Object { $_.Extension -in '.vhd', '.vhdx' }) + if ($disks.Count -ne 1) { + throw "Expected exactly one virtual disk in $OutputDirectory; found $($disks.Count)." + } + $disk = $disks[0] + $vhd = Get-VHD -Path $disk.FullName -ErrorAction Stop + $format = [string]$vhd.VhdFormat + if ($disk.Length -le 0 -or $vhd.Attached -or $vhd.ParentPath -or + $format -notin 'VHD', 'VHDX' -or $disk.Extension -ine ".$format" -or + -not (Test-VHD -Path $disk.FullName -ErrorAction Stop)) { + throw "The exported disk is empty, attached, dependent on another disk, or invalid: $($disk.FullName)" + } + return [pscustomobject]@{ Path = $disk.FullName; Format = $format; SizeBytes = $disk.Length } +} + +function Invoke-WindowsImageBuild { + [CmdletBinding()] + param( + [string]$BuildId, + [string]$VmName = 'hybrid-minikube-windows-server', + [string]$ArtifactRoot = (Join-Path $env:ProgramData 'WindowsNodeImageBuilder\builds'), + [string]$VarFile = (Join-Path $repositoryRoot 'windows.auto.pkrvars.hcl'), + [string]$WindowsVersion, + [string]$KubernetesVersion, + [string]$ContainerdVersion, + [switch]$ValidateOnly + ) + $ErrorActionPreference = 'Stop' + if (-not $BuildId) { $BuildId = New-ImageBuildId } + if ($BuildId -notmatch '^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$' -or + $VmName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}$') { + throw 'BuildId or VmName contains unsupported characters or is too long.' + } + $VarFile = (Resolve-Path -LiteralPath $VarFile -ErrorAction Stop).ProviderPath + $artifactRootPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ArtifactRoot) + $runDirectory = Join-Path $artifactRootPath $BuildId + # No -Force: a reused build ID must never overwrite an earlier run's evidence. + New-Item -ItemType Directory -Path $runDirectory -ErrorAction Stop | Out-Null + $logDirectory = Join-Path $runDirectory 'logs' + New-Item -ItemType Directory -Path $logDirectory -ErrorAction Stop | Out-Null + $outputDirectory = Join-Path $runDirectory 'output' + $resultPath = Join-Path $logDirectory 'result.json' + $consoleLog = Join-Path $logDirectory 'console.log' + $result = [ordered]@{ + Status = 'Started' + BuildId = $BuildId + VmName = "$VmName-$BuildId" + OutputDirectory = $outputDirectory + LogDirectory = $logDirectory + StartedUTC = [DateTime]::UtcNow.ToString('o') + FinishedUTC = $null + DiskPath = $null + DiskFormat = $null + DiskSizeBytes = $null + Error = $null + RetainedVM = $null + } + $lock = $null + $oldLog = $env:PACKER_LOG + $oldLogPath = $env:PACKER_LOG_PATH + $oldPath = $env:PATH + $locationPushed = $false + $transcribing = $false + try { + if ($env:GITHUB_ACTIONS -eq 'true' -and $env:GITHUB_OUTPUT) { + @("build_id=$BuildId", "log_directory=$logDirectory", "result_path=$resultPath") | + Out-File -LiteralPath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } + Start-Transcript -Path (Join-Path $logDirectory 'host.log') -ErrorAction Stop | Out-Null + $transcribing = $true + Write-Host "Build ID: $BuildId" + Write-Host "Temporary VM: $($result.VmName)" + Write-Host "Logs: $logDirectory" + $env:PACKER_LOG = '1' + $env:PACKER_LOG_PATH = Join-Path $logDirectory 'packer-debug.log' + foreach ($architecture in 'amd64', 'x86') { + $adkPath = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\$architecture\Oscdimg" + if (Test-Path -LiteralPath (Join-Path $adkPath 'oscdimg.exe')) { $env:PATH += ";$adkPath" } + } + if (-not $ValidateOnly) { + $lock = Enter-ImageBuildLock + Assert-ImageBuildHost + if (Get-VM -ErrorAction Stop | Where-Object Name -eq $result.VmName) { + throw "VM $($result.VmName) already exists and will not be overwritten." + } + } + Push-Location -LiteralPath $repositoryRoot + $locationPushed = $true + $template = Join-Path $repositoryRoot 'windows.json.pkr.hcl' + $variables = @("-var-file=$VarFile", '-var', "build_id=$BuildId", '-var', "vm_name=$VmName", + '-var', "output_directory=$outputDirectory") + foreach ($entry in @( + @{ Name = 'windows_version'; Value = $WindowsVersion }, + @{ Name = 'kubernetes_version'; Value = $KubernetesVersion }, + @{ Name = 'containerd_version'; Value = $ContainerdVersion } + )) { + if (-not [string]::IsNullOrWhiteSpace($entry.Value)) { + $variables += @('-var', "$($entry.Name)=$($entry.Value.Trim())") + } + } + Invoke-ImageCommand -Command packer -Arguments @('init', $template) -LogPath $consoleLog + Invoke-ImageCommand -Command packer -Arguments (@('validate') + $variables + $template) -LogPath $consoleLog + if ($ValidateOnly) { + $result.Status = 'Validated' + } else { + Invoke-ImageCommand -Command packer -Arguments (@('build', '-color=false', '-on-error=abort') + $variables + $template) -LogPath $consoleLog + $disk = Get-ImageBuildDisk -OutputDirectory $outputDirectory + $result.DiskPath = $disk.Path + $result.DiskFormat = $disk.Format + $result.DiskSizeBytes = $disk.SizeBytes + $result.Status = 'Succeeded' + } + } catch { + $result.Status = 'Failed' + $result.Error = $_.Exception.Message + if (-not $ValidateOnly -and $lock) { + try { + $vm = Get-VM -ErrorAction Stop | Where-Object Name -eq $result.VmName + if ($vm) { + $result.RetainedVM = [ordered]@{ + Id = [string]$vm.Id + Path = $vm.Path + Disks = @($vm | Get-VMHardDiskDrive -ErrorAction Stop | Select-Object -ExpandProperty Path) + } + } + } catch { + Write-Warning "Could not collect retained VM details: $($_.Exception.Message)" + } + } + throw + } finally { + try { + $result.FinishedUTC = [DateTime]::UtcNow.ToString('o') + $result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $resultPath -Encoding UTF8 + } finally { + if ($lock) { $lock.Dispose() } + $env:PACKER_LOG = $oldLog + $env:PACKER_LOG_PATH = $oldLogPath + $env:PATH = $oldPath + if ($locationPushed) { Pop-Location } + if ($transcribing) { Stop-Transcript | Out-Null } + } + } + Write-Host "Build status: $($result.Status). Result: $resultPath" + return [pscustomobject]$result +} + +if ($MyInvocation.InvocationName -ne '.') { + Invoke-WindowsImageBuild @PSBoundParameters +} diff --git a/scripts/Publish-WindowsImage.ps1 b/scripts/Publish-WindowsImage.ps1 new file mode 100644 index 0000000..38296fc --- /dev/null +++ b/scripts/Publish-WindowsImage.ps1 @@ -0,0 +1,311 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS +Publishes the exact disk from a successful, isolated build result. +.DESCRIPTION +Requires Hyper-V PowerShell, Azure CLI, AZURE_STORAGE_ACCOUNT, AZURE_STORAGE_KEY, +and AZURE_CONTAINER_NAME. Writes publication.json, publication.log, and +publication-lease.log beside ResultPath. Never renames build artifacts. + +The canonical block blob is also the cross-host lock: a 60-second lease, +renewed every 15 seconds, with fail-fast contention and no lease breaking. +An absent destination is conditionally created as an empty block blob; a failed +first publication may leave that empty blob. Existing images are never seeded. +Both .vhdx and .vhd are uploaded as block-blob file artifacts, not page disks. +The actual upload carries the lease ID, so an expired/lost lease cannot commit +over another publisher. Azure administrators must not break active leases. +#> +[CmdletBinding()] +param([string]$ResultPath) + +function Protect-PublicationText { + param([string]$Text) + if ($env:AZURE_STORAGE_KEY) { + return $Text.Replace($env:AZURE_STORAGE_KEY, '[REDACTED]') + } + return $Text +} + +function Write-PublicationLog { + param([string]$LogPath, [string]$Message) + Add-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction Stop -Value ( + '{0} {1}' -f [DateTime]::UtcNow.ToString('o'), (Protect-PublicationText $Message)) +} + +function Invoke-PublicationAzureCli { + param([string[]]$Arguments, [string]$LogPath, [string]$Operation) + Write-PublicationLog $LogPath $Operation + # Windows PowerShell wraps native stderr in ErrorRecords even on exit 0. + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& az @Arguments --auth-mode key --only-show-errors --output json 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousPreference + } + $text = Protect-PublicationText (($output | ForEach-Object { "$_" }) -join "`n") + if (-not [string]::IsNullOrWhiteSpace($text)) { + Write-PublicationLog $LogPath "$Operation output: $text" + } + if ($exitCode -ne 0) { + throw "$Operation failed (Azure CLI exit $exitCode): $text" + } + return $text +} + +function Get-PublicationAbsolutePath { + param([object]$Path, [string]$Name) + if ($Path -isnot [string] -or $Path -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+\\)') { + throw "$Name must be an absolute Windows filesystem path." + } + return [IO.Path]::GetFullPath($Path) +} + +function Get-PublicationDisk { + param([object]$Result) + foreach ($name in @('Status', 'BuildId', 'VmName', 'OutputDirectory', 'DiskPath', 'DiskFormat', 'DiskSizeBytes')) { + if ($null -eq $Result -or $null -eq $Result.PSObject.Properties[$name]) { + throw "Build result is missing $name." + } + } + if ($Result.Status -cne 'Succeeded') { throw 'Build result must have Status Succeeded.' } + foreach ($name in @('Status', 'BuildId', 'VmName', 'DiskFormat')) { + if ($Result.$name -isnot [string] -or [string]::IsNullOrWhiteSpace($Result.$name)) { + throw "Build result $name must be a nonempty string." + } + } + if (@('VHD', 'VHDX') -cnotcontains $Result.DiskFormat) { throw 'DiskFormat must be VHD or VHDX.' } + if (($Result.DiskSizeBytes -isnot [long] -and $Result.DiskSizeBytes -isnot [int]) -or + $Result.DiskSizeBytes -le 0) { + throw 'DiskSizeBytes must be a positive integer file length.' + } + $directory = Get-PublicationAbsolutePath $Result.OutputDirectory 'OutputDirectory' + $path = Get-PublicationAbsolutePath $Result.DiskPath 'DiskPath' + $prefix = $directory.TrimEnd('\') + '\' + if (-not $path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'DiskPath must be inside OutputDirectory.' + } + $output = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + $file = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (-not $output.PSIsContainer -or $file.PSIsContainer) { throw 'DiskPath must identify a disk file.' } + if ($file.Extension -ine ('.' + $Result.DiskFormat)) { throw 'Disk extension does not match DiskFormat.' } + # Reject redirected paths, including junctions above the output directory. + $item = $file + while ($null -ne $item) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'DiskPath must not traverse a reparse point.' + } + if ($item -is [IO.FileInfo]) { $item = $item.Directory } else { $item = $item.Parent } + } + $stream = $null + try { + # Keep the disk readable but immutable until the upload and verification finish. + $stream = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + if ($stream.Length -ne $Result.DiskSizeBytes) { throw 'Disk file length does not match DiskSizeBytes.' } + $vhd = @(Get-VHD -Path $path -ErrorAction Stop) + if ($vhd.Count -ne 1 -or $vhd[0].VhdFormat -ine $Result.DiskFormat -or + $vhd[0].FileSize -ne $Result.DiskSizeBytes -or + -not [string]::IsNullOrEmpty($vhd[0].ParentPath) -or + $vhd[0].VhdType -eq 'Differencing' -or $vhd[0].Attached) { + throw 'Get-VHD did not verify the expected independent, detached disk format and file size.' + } + if (-not (Test-VHD -Path $path -ErrorAction Stop)) { throw 'Test-VHD reports an unreadable disk.' } + return [pscustomobject]@{ Path = $path; Stream = $stream } + } catch { + if ($null -ne $stream) { $stream.Dispose() } + throw + } +} + +function Start-PublicationLeaseRenewal { + param([string]$Container, [string]$BlobName, [string]$LeaseId, [string]$LogPath) + $parentStart = (Get-Process -Id $PID -ErrorAction Stop).StartTime.ToUniversalTime().Ticks + Start-Job -ArgumentList $Container, $BlobName, $LeaseId, $LogPath, $PID, $parentStart -ScriptBlock { + param($Container, $BlobName, $LeaseId, $LogPath, $OwnerId, $OwnerStart) + $ErrorActionPreference = 'Stop' + $ready = $false + try { + while ($true) { + $owner = Get-Process -Id $OwnerId -ErrorAction Stop + if ($owner.StartTime.ToUniversalTime().Ticks -ne $OwnerStart) { + throw 'Publication owner exited.' + } + $ErrorActionPreference = 'Continue' + $output = @(& az storage blob lease renew --container-name $Container --blob-name $BlobName ` + --lease-id $LeaseId --auth-mode key --timeout 15 --only-show-errors --output json 2>&1) + $exitCode = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + $text = ($output | ForEach-Object { "$_" }) -join "`n" + if ($env:AZURE_STORAGE_KEY) { $text = $text.Replace($env:AZURE_STORAGE_KEY, '[REDACTED]') } + if ($exitCode -ne 0) { throw "Lease renewal failed (Azure CLI exit $exitCode): $text" } + Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value ("{0} Lease renewed." -f [DateTime]::UtcNow.ToString('o')) + if (-not $ready) { Write-Output 'LeaseReady'; $ready = $true } + Start-Sleep -Seconds 15 + } + } catch { + $message = $_.Exception.Message + if ($env:AZURE_STORAGE_KEY) { $message = $message.Replace($env:AZURE_STORAGE_KEY, '[REDACTED]') } + Add-Content -LiteralPath $LogPath -Encoding UTF8 -Value ("{0} {1}" -f [DateTime]::UtcNow.ToString('o'), $message) + throw $message + } + } +} + +function Assert-PublicationLeaseRenewal { + param([object]$Job, [switch]$WaitUntilReady) + $deadline = [DateTime]::UtcNow.AddSeconds(40) + do { + try { $messages = @(Receive-Job -Job $Job -Keep -ErrorAction Stop) } + catch { throw "Lease renewal failed: $(Protect-PublicationText $_.Exception.Message)" } + if ($Job.State -ne 'Running') { throw "Lease renewal job is not running ($($Job.State))." } + if (-not $WaitUntilReady -or $messages -contains 'LeaseReady') { return } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + throw 'Lease renewal did not become ready within 40 seconds; upload was not started.' +} + +function Stop-PublicationLeaseRenewal { + param([object]$Job) + try { Stop-Job -Job $Job -ErrorAction Stop } + finally { Remove-Job -Job $Job -Force -ErrorAction Stop } +} + +function Invoke-WindowsImagePublication { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$ResultPath) + $ErrorActionPreference = 'Stop' + $path = Get-PublicationAbsolutePath $ResultPath 'ResultPath' + $logDirectory = Split-Path -Parent $path + if (-not (Test-Path -LiteralPath $logDirectory -PathType Container)) { throw 'Result log directory does not exist.' } + $logPath = Join-Path $logDirectory 'publication.log' + $manifestPath = Join-Path $logDirectory 'publication.json' + $renewalLog = Join-Path $logDirectory 'publication-lease.log' + $manifest = [ordered]@{ + Status = 'Failed'; ResultPath = $path; BuildId = $null; VmName = $null + StorageAccount = $env:AZURE_STORAGE_ACCOUNT; ContainerName = $env:AZURE_CONTAINER_NAME + BlobName = $null; DiskPath = $null; DiskFormat = $null; DiskSizeBytes = $null + PublishedAtUtc = $null; Error = $null; CleanupErrors = @() + } + $failure = $null + $disk = $null + $job = $null + $leaseId = $null + $seedPath = $null + $oldContainer = $env:AZURE_STORAGE_CONTAINER + $oldConnectionString = $env:AZURE_STORAGE_CONNECTION_STRING + $oldSasToken = $env:AZURE_STORAGE_SAS_TOKEN + try { + Write-PublicationLog $logPath 'Publication requested.' + $resultText = Get-Content -LiteralPath $path -Raw + if ($resultText -notmatch '\A\s*\{') { throw 'Build result must be a JSON object.' } + $result = $resultText | ConvertFrom-Json + $disk = Get-PublicationDisk $result + foreach ($name in @('AZURE_STORAGE_ACCOUNT', 'AZURE_STORAGE_KEY', 'AZURE_CONTAINER_NAME')) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "Required environment variable $name is not set." + } + } + $env:AZURE_STORAGE_CONTAINER = $env:AZURE_CONTAINER_NAME + # Do not let ambient connection-string/SAS authentication select a different destination. + $env:AZURE_STORAGE_CONNECTION_STRING = $null + $env:AZURE_STORAGE_SAS_TOKEN = $null + Get-Command az -CommandType Application -ErrorAction Stop | Out-Null + foreach ($name in @('BuildId', 'VmName', 'DiskFormat', 'DiskSizeBytes')) { $manifest[$name] = $result.$name } + $manifest.DiskPath = $disk.Path + $blobName = 'hybrid-minikube-windows-server.' + $result.DiskFormat.ToLowerInvariant() + $manifest.BlobName = $blobName + $destination = @('--container-name', $env:AZURE_CONTAINER_NAME, '--name', $blobName) + $leaseDestination = @('--container-name', $env:AZURE_CONTAINER_NAME, '--blob-name', $blobName) + $exists = Invoke-PublicationAzureCli (@('storage', 'blob', 'exists') + $destination) $logPath 'Check canonical blob' | + ConvertFrom-Json + if ($exists.exists -isnot [bool]) { throw 'Azure CLI returned a malformed existence response.' } + if (-not $exists.exists) { + $seedPath = Join-Path $logDirectory ('publication-seed-' + [guid]::NewGuid().ToString('N')) + [IO.File]::WriteAllBytes($seedPath, [byte[]]@()) + try { + Invoke-PublicationAzureCli (@('storage', 'blob', 'upload') + $destination + + @('--file', $seedPath, '--type', 'block', '--overwrite', 'false', '--if-none-match', '*', '--no-progress')) ` + $logPath 'Conditionally create absent canonical blob' | Out-Null + } catch { + $creationFailure = $_ + if ($creationFailure.Exception.Message -notmatch '\b(BlobAlreadyExists|ConditionNotMet|LeaseIdMissing)\b') { + throw + } + $exists = Invoke-PublicationAzureCli (@('storage', 'blob', 'exists') + $destination) $logPath 'Check concurrent creation' | + ConvertFrom-Json + if ($exists.exists -isnot [bool] -or -not $exists.exists) { throw $creationFailure } + Write-PublicationLog $logPath 'Canonical blob now exists; acquire its lease without replacing it.' + } + } + $proposedId = [guid]::NewGuid().ToString() + # Set ownership only after a successful atomic acquisition. Never release another owner's lease. + Invoke-PublicationAzureCli (@('storage', 'blob', 'lease', 'acquire') + $leaseDestination + + @('--lease-duration', '60', '--proposed-lease-id', $proposedId, '--timeout', '15')) ` + $logPath 'Acquire canonical blob lease (fail-fast contention)' | Out-Null + $leaseId = $proposedId + $job = Start-PublicationLeaseRenewal $env:AZURE_CONTAINER_NAME $blobName $leaseId $renewalLog + Assert-PublicationLeaseRenewal $job -WaitUntilReady + Invoke-PublicationAzureCli (@('storage', 'blob', 'upload') + $destination + + @('--file', $disk.Path, '--type', 'block', '--overwrite', 'true', '--lease-id', $leaseId, + '--validate-content', '--no-progress')) $logPath 'Upload exact build disk with enforced lease' | Out-Null + Assert-PublicationLeaseRenewal $job + $remote = Invoke-PublicationAzureCli (@('storage', 'blob', 'show') + $destination) $logPath 'Verify published blob' | + ConvertFrom-Json + if ($remote.properties.contentLength -ne $result.DiskSizeBytes -or $remote.properties.blobType -ne 'BlockBlob') { + throw 'Published blob type or length does not match the build disk.' + } + Assert-PublicationLeaseRenewal $job + $manifest.Status = 'Succeeded' + $manifest.PublishedAtUtc = [DateTime]::UtcNow.ToString('o') + } catch { + $failure = $_ + $manifest.Error = Protect-PublicationText $_.Exception.Message + } finally { + if ($null -ne $job) { + try { Stop-PublicationLeaseRenewal $job } + catch { $manifest.CleanupErrors += Protect-PublicationText $_.Exception.Message } + } + if ($null -ne $leaseId) { + try { + Invoke-PublicationAzureCli (@('storage', 'blob', 'lease', 'release') + $leaseDestination + + @('--lease-id', $leaseId, '--timeout', '15')) $logPath 'Release owned canonical blob lease' | Out-Null + } catch { $manifest.CleanupErrors += Protect-PublicationText $_.Exception.Message } + } + if ($null -ne $disk) { + try { $disk.Stream.Dispose() } + catch { $manifest.CleanupErrors += Protect-PublicationText $_.Exception.Message } + } + if ($null -ne $seedPath) { + try { Remove-Item -LiteralPath $seedPath -Force -ErrorAction Stop } + catch { $manifest.CleanupErrors += Protect-PublicationText $_.Exception.Message } + } + $env:AZURE_STORAGE_CONTAINER = $oldContainer + $env:AZURE_STORAGE_CONNECTION_STRING = $oldConnectionString + $env:AZURE_STORAGE_SAS_TOKEN = $oldSasToken + if ($manifest.CleanupErrors.Count -gt 0 -and $null -eq $failure) { + $failure = New-Object System.Exception -ArgumentList ('Publication cleanup failed: ' + ($manifest.CleanupErrors -join '; ')) + $manifest.Error = $failure.Message + } + if ($null -ne $failure) { $manifest.Status = 'Failed'; $manifest.PublishedAtUtc = $null } + try { + $manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + Write-PublicationLog $logPath ("Publication {0}. {1}" -f $manifest.Status, $manifest.Error) + } catch { + if ($null -eq $failure) { $failure = $_ } + else { Write-Warning ("Could not persist publication outcome: " + (Protect-PublicationText $_.Exception.Message)) } + } + } + if ($null -ne $failure) { + if ($failure -is [System.Management.Automation.ErrorRecord]) { + throw (Protect-PublicationText $failure.Exception.Message) + } + throw (Protect-PublicationText $failure.Message) + } + return [pscustomobject]$manifest +} + +if ($MyInvocation.InvocationName -ne '.') { + if ([string]::IsNullOrWhiteSpace($ResultPath)) { throw 'Specify -ResultPath with the absolute successful build result.json path.' } + Invoke-WindowsImagePublication -ResultPath $ResultPath +} diff --git a/scripts/Test-WindowsImage.ps1 b/scripts/Test-WindowsImage.ps1 new file mode 100644 index 0000000..73632d6 --- /dev/null +++ b/scripts/Test-WindowsImage.ps1 @@ -0,0 +1,20 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +try { + Import-Module Pester -RequiredVersion 4.9.0 -ErrorAction Stop + $testDirectory = Join-Path (Split-Path $PSScriptRoot -Parent) 'tests' + $results = Pester\Invoke-Pester -Script $testDirectory -PassThru + if ($results.TotalCount -eq 0) { + throw "No tests were found in $testDirectory." + } + if ($results.FailedCount -gt 0) { + throw "$($results.FailedCount) tests failed." + } +} catch { + Write-Error -ErrorRecord $_ -ErrorAction Continue + exit 1 +} +exit 0 diff --git a/setup/Autounattend.xml b/setup/Autounattend.xml index ff05ab1..219c9f1 100644 --- a/setup/Autounattend.xml +++ b/setup/Autounattend.xml @@ -37,6 +37,7 @@ Primary + 1 @@ -65,6 +66,7 @@ 0 true + OnError @@ -72,16 +74,18 @@ - /IMAGE/INDEX + /IMAGE/INDEX 3 + 0 4 + @@ -93,6 +97,7 @@ + @@ -113,6 +118,7 @@ true + @@ -123,47 +129,32 @@ true Administrator - + - %SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe -File E:\enable-winrm.ps1 + cmd.exe /c "for %i in (C D E F G) do if exist %i:\enable-winrm.ps1 powershell.exe -ExecutionPolicy Bypass -File %i:\enable-winrm.ps1" Enable WinRM 1 - true %SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe -Command "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force" Set Execution Policy 64 Bit 2 - true - + - %SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe -Command "Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force" - Set Execution Policy 32 Bit + cmd.exe /c "wmic useraccount where name='Administrator' set PasswordExpires=FALSE" 3 - true + Disable password expiration - - %SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe -Command Install-WindowsFeature -Name containers - 4 - Installs Containers feature - - %SystemRoot%\system32\WindowsPowerShell\v1.0\powershell.exe -Command Set-SConfig -AutoLaunch $false - 5 + 4 Turns off Server Configuration tool (SConfig) - - cmd.exe /c wmic useraccount where "name='Administrator'" set PasswordExpires=FALSE - 6 - Disable password expiration for Administrator user - - true @@ -174,6 +165,7 @@ Home 1 + password @@ -182,9 +174,10 @@ + false - + \ No newline at end of file diff --git a/setup/auto-install.iso b/setup/auto-install.iso deleted file mode 100644 index 29df673..0000000 Binary files a/setup/auto-install.iso and /dev/null differ diff --git a/setup/configure-vm.ps1 b/setup/configure-vm.ps1 index 41830f5..ed70e22 100644 --- a/setup/configure-vm.ps1 +++ b/setup/configure-vm.ps1 @@ -1,31 +1,33 @@ +# Accept versions passed as script parameters or via environment variables set by Packer. +param( + [string]$KUBERNETES_VERSION = $env:KUBERNETES_VERSION, + [string]$CONTAINERD_VERSION = $env:CONTAINERD_VERSION +) + $envPathRegKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" -if ($env:KUBERNETES_VERSION -and $env:KUBERNETES_VERSION.Trim()) { +# Determine effective Kubernetes version: prefer parameter, then env; fail if not provided. +if ($KUBERNETES_VERSION -and $KUBERNETES_VERSION.Trim()) { + $kubernetes_ver = $KUBERNETES_VERSION.TrimStart('v') + Write-Output "Using Kubernetes version from parameter/env: $kubernetes_ver" +} elseif ($env:KUBERNETES_VERSION -and $env:KUBERNETES_VERSION.Trim()) { $kubernetes_ver = $env:KUBERNETES_VERSION.TrimStart('v') Write-Output "Using Kubernetes version from environment: $kubernetes_ver" } else { - Write-Output "KUBERNETES_VERSION environment variable not set. Fetching latest version..." - $kubernetes_ver = Get-k8LatestVersion - $kubernetes_ver = $kubernetes_ver.TrimStart('v') - Write-Output "Using latest Kubernetes version: $kubernetes_ver" + throw "KUBERNETES_VERSION not provided. Set via Packer (-var 'kubernetes_version=...') or environment." } -function Get-LatestToolVersion($repository) { - try { - $uri = "https://api.github.com/repos/$repository/releases/latest" - $response = Invoke-WebRequest -Uri $uri -UseBasicParsing - $version = ($response.content | ConvertFrom-Json).tag_name - return $version.TrimStart("v") - } - catch { - Throw "Could not get $repository version. $_" - } +# Determine effective Containerd version: prefer parameter, then env; fail if not provided. +if ($CONTAINERD_VERSION -and $CONTAINERD_VERSION.Trim()) { + $containerd_ver = $CONTAINERD_VERSION.TrimStart('v') + Write-Output "Using Containerd version from parameter/env: $containerd_ver" +} elseif ($env:CONTAINERD_VERSION -and $env:CONTAINERD_VERSION.Trim()) { + $containerd_ver = $env:CONTAINERD_VERSION.TrimStart('v') + Write-Output "Using Containerd version from environment: $containerd_ver" +} else { + throw "CONTAINERD_VERSION not provided. Set via Packer (-var 'containerd_version=...') or environment." } -function Get-ContainerdLatestVersion { - $latestVersion = Get-LatestToolVersion -Repository "containerd/containerd" - return $latestVersion -} function Install-Containerd { param( @@ -38,19 +40,16 @@ function Install-Containerd { $DownloadPath = "$HOME\Downloads" ) - $Version = Get-ContainerdLatestVersion - - $Version = $Version.TrimStart('v') - # TODO: revert to this line after finding the right way to handle the new containerd version - # $Version = $Version.TrimStart('v') - $Version = "1.7.25" - Write-Output "* Downloading and installing Containerd v$version at $InstallPath" + # Determine version: prefer explicit env/outer-scope value, else fallback to API + # Use the already-determined $containerd_ver (from env or default above) + $Version = $containerd_ver.TrimStart('v') + Write-Output "* Downloading and installing Containerd v$Version at $InstallPath" # Download file from repo - $containerdTarFile = "containerd-${version}-windows-amd64.tar.gz" + $containerdTarFile = "containerd-${Version}-windows-amd64.tar.gz" try { - $Uri = "https://github.com/containerd/containerd/releases/download/v$version/$($containerdTarFile)" + $Uri = "https://github.com/containerd/containerd/releases/download/v$Version/$($containerdTarFile)" Invoke-WebRequest -Uri $Uri -OutFile $DownloadPath\$containerdTarFile | Out-Null } catch { @@ -70,7 +69,7 @@ function Install-Containerd { Install-RequiredFeature @params | Out-Null - Write-Output "* Containerd v$version successfully installed at $InstallPath" + Write-Output "* Containerd v$Version successfully installed at $InstallPath" containerd.exe -v } @@ -89,11 +88,11 @@ function Install-RequiredFeature { New-Item -ItemType Directory -Force -Path $InstallPath | Out-Null } - # Untar file + # Untar file (use call operator to avoid argument splitting on paths with spaces) if ($DownloadPath.EndsWith("tar.gz")) { - tar.exe -xf $DownloadPath -C $InstallPath - if ($LASTEXITCODE -gt 0) { - Throw "Could not untar $DownloadPath. $_" + & tar.exe -xf "$DownloadPath" -C "$InstallPath" + if ($LASTEXITCODE -ne 0) { + Throw "Could not untar $DownloadPath. Exit code: $LASTEXITCODE" } } @@ -196,7 +195,7 @@ function Initialize-ContainerdService { $containerdConfigFile = "$ContainerdPath\config.toml" $containerdDefault = containerd.exe config default $containerdDefault | Out-File $ContainerdPath\config.toml -Encoding ascii - Write-Information -InformationAction Continue -MessageData "* Review containerd configutations at $containerdConfigFile ..." + Write-Information -InformationAction Continue -MessageData "* Review containerd configurations at $containerdConfigFile ..." Add-MpPreference -ExclusionProcess "$ContainerdPath\containerd.exe" @@ -212,35 +211,31 @@ function Initialize-ContainerdService { # Read the content of the config.toml file $containerdConfigContent = Get-Content -Path $containerdConfigFile -Raw - # Define the replacements - $replacements = @( - @{ - Find = 'bin_dir = "C:\\Program Files\\containerd\\cni\\bin"' - Replace = 'bin_dir = "c:\\opt\\cni\\bin"' - }, - @{ - Find = 'conf_dir = "C:\\Program Files\\containerd\\cni\\conf"' - Replace = 'conf_dir = "c:\\etc\\cni\\net.d\\"' - } - ) - - # Perform the check and replacement in one loop - $replacementsMade = $false - foreach($replacement in $replacements) { - if ($containerdConfigContent -match [regex]::Escape($replacement.Find)) { - $containerdConfigContent = $containerdConfigContent -replace [regex]::Escape($replacement.Find), $replacement.Replace - $replacementsMade = $true + # Use the already-resolved $containerd_ver to pick the right config.toml key/quote format + $containerdMajor = [int]($containerd_ver -split '\.')[0] + if ($containerdMajor -ge 2) { + # containerd 2.x: bin_dirs (array, single quotes) + if ($containerdConfigContent -notmatch "bin_dirs\s*=\s*\['[^']*'\]") { + Throw "containerd 2.x: bin_dirs CNI pattern not found in config.toml." + } + $containerdConfigContent = $containerdConfigContent -replace "bin_dirs\s*=\s*\['[^']*'\]", "bin_dirs = ['c:\opt\cni\bin']" + if ($containerdConfigContent -notmatch "conf_dir\s*=\s*'[^']*containerd[^']*cni[^']*'") { + Throw "containerd 2.x: conf_dir CNI pattern not found in config.toml." + } + $containerdConfigContent = $containerdConfigContent -replace "conf_dir\s*=\s*'[^']*containerd[^']*cni[^']*'", "conf_dir = 'c:\etc\cni\net.d'" + } else { + # containerd 1.x: bin_dir (double quotes) + if ($containerdConfigContent -notmatch 'bin_dir\s*=\s*"[^"]*\\cni\\[^"]*"') { + Throw "containerd 1.x: bin_dir CNI pattern not found in config.toml." + } + $containerdConfigContent = $containerdConfigContent -replace 'bin_dir\s*=\s*"[^"]*\\cni\\[^"]*"', 'bin_dir = "c:\\opt\\cni\\bin"' + if ($containerdConfigContent -notmatch 'conf_dir\s*=\s*"[^"]*containerd[^"]*cni[^"]*"') { + Throw "containerd 1.x: conf_dir CNI pattern not found in config.toml." } + $containerdConfigContent = $containerdConfigContent -replace 'conf_dir\s*=\s*"[^"]*containerd[^"]*cni[^"]*"', 'conf_dir = "c:\\etc\\cni\\net.d\\"' } - # Write the modified content back to the config.toml file if any replacements were made - if ($replacementsMade) { - $containerdConfigContent | Set-Content -Path $containerdConfigFile - # Output a message indicating the changes - # Write-Host "Changes applied to $containerdConfigFile" - } else { - # Write-Host "No changes needed in $containerdConfigFile" - } + $containerdConfigContent | Set-Content -Path $containerdConfigFile # Create the folders if they do not exist $binDir = "c:\opt\cni\bin" @@ -301,10 +296,6 @@ function Install-NSSM { Write-Output "* NSSM is installed ..." } -function Get-k8LatestVersion { - $latestVersion = Get-LatestToolVersion -Repository "kubernetes/kubernetes" - return $latestVersion -} function Install-Kubelet { param ( @@ -381,9 +372,10 @@ function Get-Kubeadm { [string] $KubernetesVersion ) - - $KubernetesVersion = Get-k8LatestVersion - Write-Output "* The latest Kubernetes version is $KubernetesVersion" + if (-not $KubernetesVersion -or [string]::IsNullOrWhiteSpace($KubernetesVersion)) { + $KubernetesVersion = $kubernetes_ver + } + Write-Output "* The Kubernetes version used is $KubernetesVersion" $KubernetesVersion = $KubernetesVersion.TrimStart('v') try { diff --git a/setup/enable-winrm.ps1 b/setup/enable-winrm.ps1 index 25398f3..2cd8716 100644 --- a/setup/enable-winrm.ps1 +++ b/setup/enable-winrm.ps1 @@ -1,44 +1,157 @@ -$ErrorActionPreference = "SilentlyContinue" - -# Switch network connection to private mode -# Required for WinRM firewall rules -$profile = Get-NetConnectionProfile -Set-NetConnectionProfile -Name $profile.Name -NetworkCategory Private -# Disable Network discovery -reg ADD HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff /f -netsh advfirewall firewall set rule group="Network Discovery" new enable=No - -# $profile = Get-NetConnectionProfile -# While ($profile.Name -eq "Identifying..."){ -# Start-Sleep -Seconds 10 -# $profile = Get-NetConnectionProfile -# } -Set-NetConnectionProfile -Name $profile.Name -NetworkCategory Private - -# Enable Windows Remote Management in the Windows Firewall. -Write-Output "Enabling Windows Remote Management in the Windows Firewall..." -$NetworkListManager = [Activator]::CreateInstance([Type]::GetTypeFromCLSID([Guid]"{DCB00C01-570F-4A9B-8D69-199FDBA5723B}")) -$Connections = $NetworkListManager.GetNetworkConnections() -$Connections | ForEach-Object { $_.GetNetwork().SetCategory(1) } - -# Set the Windows Remote Management configuration. -Write-Output "Setting the Windows Remote Management configuration..." -Enable-PSRemoting -Force -winrm quickconfig -q -winrm quickconfig -transport:http -winrm set winrm/config '@{MaxTimeoutms="1800000"}' -winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="800"}' -winrm set winrm/config/service '@{AllowUnencrypted="true"}' -winrm set winrm/config/service/auth '@{Basic="true"}' -winrm set winrm/config/client/auth '@{Basic="true"}' -winrm set winrm/config/listener?Address=*+Transport=HTTP '@{Port="5985"}' - -# Allow Windows Remote Management in the Windows Firewall. -Write-Output "Allowing Windows Remote Management in the Windows Firewall..." -netsh advfirewall firewall set rule group="Windows Remote Administration" new enable=yes -netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=allow - -# Restart Windows Remote Management service. -Write-Output "Restarting Windows Remote Management service..." -Set-Service winrm -startuptype "auto" -Restart-Service winrm \ No newline at end of file +# Enable WinRM + required firewall rules for automation (idempotent-ish) +# Adds logging + better error handling + waits for network profile readiness. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ----------------------------- +# Console-only logging helpers +# ----------------------------- +function Write-Log { + param( + [Parameter(Mandatory=$true)][string]$Message, + [ValidateSet('INFO','WARN','ERROR')][string]$Level = 'INFO' + ) + $line = "[$Level] $Message" + if ($Level -eq 'ERROR') { Write-Error $line -ErrorAction Continue } elseif ($Level -eq 'WARN') { Write-Warning $line } else { Write-Host $line } +} + +function Invoke-Step { + param( + [Parameter(Mandatory=$true)][string]$Name, + [Parameter(Mandatory=$true)][scriptblock]$Action + ) + Write-Log "START: $Name" + try { + & $Action + Write-Log "OK: $Name" + } catch { + Write-Log "FAIL: $Name - $($_.Exception.Message)" "ERROR" + throw + } +} + +Write-Log 'WinRM setup starting.' +Write-Log "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)" + +# ----------------------------- +# 1) Ensure network profile is Private +# ----------------------------- +Invoke-Step "Ensure network profile is Private" { + # Wait for network profile to be in a usable state (avoid 'Identifying...') + $deadline = (Get-Date).AddMinutes(3) + do { + $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue + $ready = $profiles | Where-Object { $_.Name -and $_.Name -ne "Identifying..." -and $_.IPv4Connectivity -ne "Disconnected" } + if (-not $ready) { + Write-Log "Network profile not ready yet. Waiting..." "WARN" + Start-Sleep -Seconds 5 + } + } until ($ready -or (Get-Date) -gt $deadline) + + if (-not $ready) { + Write-Log 'Timed out waiting for network profile. Proceeding anyway.' 'WARN' + $ready = $profiles + } + + foreach ($p in $ready) { + Write-Log "Profile: Name='$($p.Name)' Category='$($p.NetworkCategory)' IPv4='$($p.IPv4Connectivity)'" + if ($p.NetworkCategory -ne "Private") { + Set-NetConnectionProfile -InterfaceIndex $p.InterfaceIndex -NetworkCategory Private + Write-Log "Set profile '$($p.Name)' to Private." + } else { + Write-Log "Profile '$($p.Name)' is already Private." + } + } + + # Disable "new network detected" popups + Network Discovery (optional hardening) + reg.exe ADD 'HKLM\SYSTEM\CurrentControlSet\Control\Network\NewNetworkWindowOff' /f | Out-Null + netsh advfirewall firewall set rule group='Network Discovery' new enable=No | Out-Null + Write-Log 'Disabled Network Discovery firewall group.' +} + +# ----------------------------- +# 2) Enable PSRemoting / WinRM configuration +# ----------------------------- +Invoke-Step "Enable PSRemoting + configure WinRM" { + # Enable-PSRemoting sets up WinRM service + listeners (but we still tune settings) + Enable-PSRemoting -Force | Out-Null + + # Ensure WinRM service is running + Set-Service winrm -StartupType Automatic + Start-Service winrm + + # WinRM base config + winrm quickconfig -q | Out-Null + + # Tune WinRM limits for automation + winrm set winrm/config '@{MaxTimeoutms="1800000"}' | Out-Null + winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="800"}' | Out-Null + + # Allow unencrypted + Basic (use only on trusted networks; required for some automation flows) + winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null + winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null + winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null + + # Ensure HTTP listener on 5985 + winrm set 'winrm/config/listener?Address=*+Transport=HTTP' '@{Port="5985"}' | Out-Null + + Write-Log 'WinRM configured: HTTP/5985, Basic auth enabled, AllowUnencrypted=true.' +} + +# ----------------------------- +# 3) Firewall rules for WinRM +# ----------------------------- +Invoke-Step "Enable WinRM firewall rules" { + # Built-in rule groups (idempotent) + netsh advfirewall firewall set rule group='Windows Remote Administration' new enable=yes | Out-Null + + # This rule name is common but can vary by OS build; enable if it exists. + $rule = Get-NetFirewallRule -DisplayName 'Windows Remote Management (HTTP-In)' -ErrorAction SilentlyContinue + if ($rule) { + Enable-NetFirewallRule -DisplayName 'Windows Remote Management (HTTP-In)' | Out-Null + Write-Log "Enabled firewall rule: Windows Remote Management (HTTP-In)" + } else { + Write-Log "Firewall rule 'Windows Remote Management (HTTP-In)' not found; enabling by service group may be sufficient." 'WARN' + # Fallback: enable WinRM rules by group (best-effort) + Get-NetFirewallRule -Group '@{Microsoft.Windows.RemoteManagement*}' -ErrorAction SilentlyContinue | Enable-NetFirewallRule | Out-Null + } +} + +# ----------------------------- +# 4) Restart WinRM +# ----------------------------- +Invoke-Step "Restart WinRM service" { + Restart-Service winrm -Force + Write-Log 'WinRM service restarted.' +} + +# ----------------------------- +# 5) Quick sanity check +# ----------------------------- +Invoke-Step "Sanity check (WinRM listener + service)" { + $svc = Get-Service winrm + Write-Log "WinRM service status: $($svc.Status) (StartupType: $((Get-CimInstance Win32_Service -Filter \"Name='WinRM'\").StartMode))" + + $listeners = winrm enumerate winrm/config/listener 2>$null + if ($listeners) { + Write-Log 'WinRM listeners:' + $listeners | ForEach-Object { Write-Log $_ } + } else { + Write-Log 'Could not enumerate listeners (non-fatal).' 'WARN' + } +} + +# ----------------------------- +# 6) UAC Fix for Local Accounts (THE 401 FIX) +# ----------------------------- +Invoke-Step "Apply LocalAccountTokenFilterPolicy" { + $registryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" + $name = "LocalAccountTokenFilterPolicy" + if (-not (Test-Path $registryPath)) { New-Item -Path $registryPath -Force | Out-Null } + Set-ItemProperty -Path $registryPath -Name $name -Value 1 -Type DWord + Write-Log 'Registry fix applied: LocalAccountTokenFilterPolicy=1' +} + +Write-Log "WinRM setup completed successfully." +exit 0 \ No newline at end of file diff --git a/tests/Build-WindowsImage.Tests.ps1 b/tests/Build-WindowsImage.Tests.ps1 new file mode 100644 index 0000000..99c597e --- /dev/null +++ b/tests/Build-WindowsImage.Tests.ps1 @@ -0,0 +1,273 @@ +. (Join-Path $PSScriptRoot '..\scripts\Build-WindowsImage.ps1') + +Describe 'Unattended DVD boot configuration' { + It 'disables the Packer default wait and sends ten boot-key attempts' { + $template = Get-Content (Join-Path $PSScriptRoot '..\windows.json.pkr.hcl') -Raw + $template | Should -Match '(?m)^\s*boot_wait\s*=\s*"-1s"' + $template | Should -Match '(?m)^\s*boot_command\s*=\s*\[for attempt in range\(10\)\s*:\s*"a"\]' + } +} + +Describe 'Build identities' { + BeforeEach { + $script:oldActions = $env:GITHUB_ACTIONS + $script:oldRunId = $env:GITHUB_RUN_ID + $script:oldAttempt = $env:GITHUB_RUN_ATTEMPT + $env:GITHUB_ACTIONS = '' + } + AfterEach { + $env:GITHUB_ACTIONS = $script:oldActions + $env:GITHUB_RUN_ID = $script:oldRunId + $env:GITHUB_RUN_ATTEMPT = $script:oldAttempt + } + It 'generates unique local IDs' { + $first = New-ImageBuildId + $first | Should -Match '^local-[a-zA-Z0-9-]+$' + (New-ImageBuildId) | Should -Not -Be $first + } + It 'includes both the workflow run and attempt' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_RUN_ID = '123456' + $env:GITHUB_RUN_ATTEMPT = '2' + New-ImageBuildId | Should -Be '123456-2' + } + It 'rejects missing workflow identity instead of sharing a default' { + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_RUN_ID = '' + { New-ImageBuildId } | Should -Throw + } +} + +Describe 'Host-wide build lock' { + BeforeEach { + $script:oldProgramData = $env:ProgramData + $env:ProgramData = Join-Path $TestDrive 'ProgramData' + } + AfterEach { $env:ProgramData = $script:oldProgramData } + It 'rejects another owner and can be reacquired after release' { + $first = Enter-ImageBuildLock + try { { Enter-ImageBuildLock } | Should -Throw 'host-wide lock' } + finally { $first.Dispose() } + $next = Enter-ImageBuildLock + try { $next.CanWrite | Should -Be $true } + finally { $next.Dispose() } + } + It 'enforces the lock in a separate PowerShell process' { + $lock = Enter-ImageBuildLock + try { + $childOutput = & powershell.exe -NoProfile -NonInteractive -File ` + (Join-Path $PSScriptRoot 'fixtures\acquire-build-lock.ps1') -ProgramDataPath $env:ProgramData 2>&1 + $LASTEXITCODE | Should -Be 7 + ($childOutput | Out-String) | Should -Match 'host-wide lock' + } finally { $lock.Dispose() } + } +} + +Describe 'Native command failure handling' { + It 'captures stdout and stderr without treating stderr alone as failure' { + $log = Join-Path $TestDrive 'native-success.log' + Invoke-ImageCommand powershell.exe @('-NoProfile', '-NonInteractive', '-File', + (Join-Path $PSScriptRoot 'fixtures\native-command.ps1')) $log + $text = Get-Content $log -Raw + $text | Should -Match 'native stdout' + $text | Should -Match 'native stderr' + } + It 'throws for a nonzero exit and preserves both output streams' { + $log = Join-Path $TestDrive 'native-failure.log' + { Invoke-ImageCommand powershell.exe @('-NoProfile', '-NonInteractive', '-File', + (Join-Path $PSScriptRoot 'fixtures\native-command.ps1'), '-ExitCode', '23') $log } | + Should -Throw 'exit code 23' + (Get-Content $log -Raw) | Should -Match 'native stderr' + } +} + +Describe 'Windows PowerShell file entry point' { + It 'resolves the default var-file when launched with -File' { + $oldPath = $env:PATH + $oldActions = $env:GITHUB_ACTIONS + $root = Join-Path $TestDrive 'file-entry' + try { + $env:GITHUB_ACTIONS = '' + $env:PATH = (Join-Path $PSScriptRoot 'fixtures') + ';' + $env:PATH + $output = & powershell.exe -NoProfile -NonInteractive -File ` + (Join-Path $PSScriptRoot '..\scripts\Build-WindowsImage.ps1') ` + -ValidateOnly -BuildId 'file-entry' -ArtifactRoot $root 2>&1 + $LASTEXITCODE | Should -Be 0 + ($output | Out-String) | Should -Match 'Packer fixture validate' + $result = Get-Content (Join-Path $root 'file-entry\logs\result.json') -Raw | ConvertFrom-Json + $result.Status | Should -Be 'Validated' + $result.DiskPath | Should -BeNullOrEmpty + } finally { + $env:PATH = $oldPath + $env:GITHUB_ACTIONS = $oldActions + } + } +} + +Describe 'Exact build disk selection' { + BeforeEach { + $script:output = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory $script:output -Force | Out-Null + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; Attached = $false; ParentPath = '' } } + Mock Test-VHD { $true } + } + It 'rejects missing output' { + { Get-ImageBuildDisk (Join-Path $TestDrive 'missing') } | Should -Throw 'output directory' + } + It 'does not select a disk from an adjacent build' { + Set-Content (Join-Path $TestDrive 'other-build.vhdx') 'other disk' + { Get-ImageBuildDisk $script:output } | Should -Throw 'found 0' + } + It 'accepts exactly one readable independent disk' { + $path = Join-Path $script:output 'temporary-name.vhdx' + Set-Content $path 'disk' + $disk = Get-ImageBuildDisk $script:output + $disk.Path | Should -Be $path + $disk.Format | Should -Be 'VHDX' + } + It 'rejects multiple disks instead of selecting the first one' { + Set-Content (Join-Path $script:output 'first.vhdx') 'disk' + Set-Content (Join-Path $script:output 'second.vhdx') 'disk' + { Get-ImageBuildDisk $script:output } | Should -Throw 'found 2' + } + It 'rejects a failed disk integrity check' { + Set-Content (Join-Path $script:output 'bad.vhdx') 'disk' + Mock Test-VHD { $false } + { Get-ImageBuildDisk $script:output } | Should -Throw 'invalid' + } + It 'rejects an attached or differencing disk' { + Set-Content (Join-Path $script:output 'dependent.vhdx') 'disk' + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; Attached = $true; ParentPath = 'parent.vhdx' } } + { Get-ImageBuildDisk $script:output } | Should -Throw 'invalid' + } + It 'rejects a misleading file extension' { + Set-Content (Join-Path $script:output 'wrong.vhd') 'disk' + { Get-ImageBuildDisk $script:output } | Should -Throw 'invalid' + } +} + +Describe 'Shared build orchestration' { + BeforeEach { + $script:root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + $script:buildLock = New-Object IO.MemoryStream + $script:output = Join-Path $script:root 'test-run\output' + $script:resultPath = Join-Path $script:root 'test-run\logs\result.json' + $script:oldActions = $env:GITHUB_ACTIONS + $env:GITHUB_ACTIONS = '' + $script:failurePhase = '' + Mock Start-Transcript {} + Mock Stop-Transcript {} + Mock Enter-ImageBuildLock { $script:buildLock } + Mock Assert-ImageBuildHost {} + Mock Get-VM { @() } + Mock Invoke-ImageCommand { + if ($Arguments[0] -eq $script:failurePhase) { throw "$script:failurePhase failed" } + } + Mock Get-ImageBuildDisk { + [pscustomobject]@{ Path = (Join-Path $script:output 'image.vhdx'); Format = 'VHDX'; SizeBytes = 1024 } + } + } + AfterEach { + $env:GITHUB_ACTIONS = $script:oldActions + $script:buildLock.Dispose() + } + It 'uses one ID for VM, output, metadata and Packer arguments' { + $result = Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root + $result.Status | Should -Be 'Succeeded' + $result.VmName | Should -Be 'hybrid-minikube-windows-server-test-run' + $result.OutputDirectory | Should -Be $script:output + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 1 -Exactly -ParameterFilter { + $Arguments[0] -eq 'build' -and $Arguments -contains 'build_id=test-run' -and + $Arguments -contains "output_directory=$script:output" -and + $Arguments -contains '-on-error=abort' -and $Arguments -notcontains '-force' + } + $script:buildLock.CanWrite | Should -Be $false + (Get-Content $script:resultPath -Raw | ConvertFrom-Json).Status | Should -Be 'Succeeded' + } + It 'passes version overrides identically to validate and build, ignoring blanks' { + Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root ` + -WindowsVersion '2022' -KubernetesVersion ' v1.37.0 ' -ContainerdVersion ' ' | Out-Null + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 2 -Exactly -ParameterFilter { + $Arguments[0] -in 'validate', 'build' -and $Arguments -contains 'windows_version=2022' -and + $Arguments -contains 'kubernetes_version=v1.37.0' -and + -not ($Arguments | Where-Object { $_ -like 'containerd_version=*' }) + } + } + It 'validates without taking the build lock or creating a VM' { + $result = Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root -ValidateOnly + $result.Status | Should -Be 'Validated' + Assert-MockCalled Enter-ImageBuildLock -Scope It -Times 0 -Exactly + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 0 -Exactly -ParameterFilter { $Arguments[0] -eq 'build' } + Assert-MockCalled Get-ImageBuildDisk -Scope It -Times 0 -Exactly + } + It 'fails rather than overwriting a reused build ID' { + New-Item -ItemType Directory -Path (Join-Path $script:root 'test-run') -Force | Out-Null + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw + Assert-MockCalled Enter-ImageBuildLock -Scope It -Times 0 -Exactly + } + It 'rejects path traversal in an explicit build ID' { + { Invoke-WindowsImageBuild -BuildId '..\other' -ArtifactRoot $script:root } | Should -Throw 'unsupported' + Assert-MockCalled Enter-ImageBuildLock -Scope It -Times 0 -Exactly + } + It 'resolves a relative artifact root against the PowerShell location' { + $base = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $base | Out-Null + Push-Location $base + try { + $result = Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot '.\relative' -ValidateOnly + $result.OutputDirectory | Should -Be (Join-Path $base 'relative\test-run\output') + } finally { Pop-Location } + } + It 'exposes this run log directory to Actions even when the build fails' { + $oldOutput = $env:GITHUB_OUTPUT + $env:GITHUB_ACTIONS = 'true' + $env:GITHUB_OUTPUT = Join-Path $TestDrive 'github-output' + $script:failurePhase = 'init' + try { + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'init failed' + $values = Get-Content $env:GITHUB_OUTPUT + $values | Should -Contain "result_path=$script:resultPath" + $values | Should -Contain "log_directory=$(Split-Path $script:resultPath -Parent)" + } finally { $env:GITHUB_OUTPUT = $oldOutput } + } + It 'preserves failure metadata and stops after initialization fails' { + $script:failurePhase = 'init' + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'init failed' + $saved = Get-Content $script:resultPath -Raw | ConvertFrom-Json + $saved.Status | Should -Be 'Failed' + $saved.DiskPath | Should -BeNullOrEmpty + $script:buildLock.CanWrite | Should -Be $false + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 0 -Exactly -ParameterFilter { $Arguments[0] -in 'validate', 'build' } + } + It 'does not build after validation fails' { + $script:failurePhase = 'validate' + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'validate failed' + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 0 -Exactly -ParameterFilter { $Arguments[0] -eq 'build' } + } + It 'does not report success or select an artifact after Packer fails' { + $script:failurePhase = 'build' + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'build failed' + (Get-Content $script:resultPath -Raw | ConvertFrom-Json).Status | Should -Be 'Failed' + Assert-MockCalled Get-ImageBuildDisk -Scope It -Times 0 -Exactly + } + It 'rejects a successful Packer exit without a valid disk' { + Mock Get-ImageBuildDisk { throw 'No valid disk' } + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'No valid disk' + (Get-Content $script:resultPath -Raw | ConvertFrom-Json).Status | Should -Be 'Failed' + } + It 'does not launch Packer when another build is detected' { + Mock Assert-ImageBuildHost { throw 'Existing Packer build' } + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw 'Existing Packer' + Assert-MockCalled Invoke-ImageCommand -Scope It -Times 0 -Exactly + $script:buildLock.CanWrite | Should -Be $false + } + It 'restores the caller environment after failure' { + $oldLog = $env:PACKER_LOG_PATH + $env:PACKER_LOG_PATH = 'original-path' + try { + Mock Invoke-ImageCommand { throw 'failure' } + { Invoke-WindowsImageBuild -BuildId 'test-run' -ArtifactRoot $script:root } | Should -Throw + $env:PACKER_LOG_PATH | Should -Be 'original-path' + } finally { $env:PACKER_LOG_PATH = $oldLog } + } +} diff --git a/tests/Publish-WindowsImage.Tests.ps1 b/tests/Publish-WindowsImage.Tests.ps1 new file mode 100644 index 0000000..8f0b364 --- /dev/null +++ b/tests/Publish-WindowsImage.Tests.ps1 @@ -0,0 +1,388 @@ +. (Join-Path $PSScriptRoot '..\scripts\Publish-WindowsImage.ps1') + +# Fail closed if a test accidentally invokes the CLI without installing a mock. +function az { throw 'Azure CLI must be mocked in publication tests.' } + +# Keep fixtures in the repository, not Pester's temporary TestDrive. +$script:publicationFixtureRoot = Join-Path $PSScriptRoot ('.publication-tests-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $script:publicationFixtureRoot | Out-Null +if (-not (Get-Command Get-VHD -ErrorAction SilentlyContinue)) { + function Get-VHD { param($Path) throw 'Get-VHD must be mocked.' } +} +if (-not (Get-Command Test-VHD -ErrorAction SilentlyContinue)) { + function Test-VHD { param($Path) throw 'Test-VHD must be mocked.' } +} + +try { + Describe 'Exact publication disk validation' { + BeforeEach { + $script:fixture = Join-Path $script:publicationFixtureRoot ([guid]::NewGuid().ToString('N')) + $script:output = Join-Path $script:fixture 'output' + New-Item -ItemType Directory -Path $script:output -Force | Out-Null + $script:diskPath = Join-Path $script:output 'original-export-name.vhdx' + [IO.File]::WriteAllBytes($script:diskPath, [byte[]](1, 2, 3, 4)) + $script:result = [pscustomobject]@{ + Status = 'Succeeded'; BuildId = 'local-build'; VmName = 'isolated-vm' + OutputDirectory = $script:output; DiskPath = $script:diskPath + DiskFormat = 'VHDX'; DiskSizeBytes = 4 + } + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 4; ParentPath = ''; Attached = $false; VhdType = 'Dynamic' } } + Mock Test-VHD { $true } + } + It 'uses and read-locks the exact manifest disk without renaming it' { + $disk = Get-PublicationDisk $script:result + try { + $disk.Path | Should Be $script:diskPath + { [IO.File]::OpenWrite($script:diskPath) } | Should Throw + Test-Path -LiteralPath $script:diskPath | Should Be $true + Assert-MockCalled Get-VHD -Times 1 -Exactly -Scope It -ParameterFilter { $Path -eq $script:diskPath } + } finally { $disk.Stream.Dispose() } + } + It 'rejects unsuccessful builds' { + $script:result.Status = 'Failed' + { Get-PublicationDisk $script:result } | Should Throw 'Succeeded' + Assert-MockCalled Get-VHD -Times 0 -Exactly -Scope It + } + It 'rejects a missing required field' { + $script:result.PSObject.Properties.Remove('BuildId') + { Get-PublicationDisk $script:result } | Should Throw 'BuildId' + } + It 'rejects an ambiguous disk path array' { + $script:result.DiskPath = @($script:diskPath, $script:diskPath) + { Get-PublicationDisk $script:result } | Should Throw 'absolute' + } + It 'rejects a missing disk rather than selecting a neighboring export' { + $script:result.DiskPath = Join-Path $script:output 'missing.vhdx' + { Get-PublicationDisk $script:result } | Should Throw + } + It 'rejects paths outside the output directory including prefix lookalikes' { + $script:result.OutputDirectory = $script:output.Substring(0, $script:output.Length - 1) + { Get-PublicationDisk $script:result } | Should Throw 'inside OutputDirectory' + } + It 'rejects traversal out of the output directory' { + $script:result.DiskPath = Join-Path $script:output '..\outside.vhdx' + { Get-PublicationDisk $script:result } | Should Throw 'inside OutputDirectory' + } + It 'rejects redirected output directories' { + $redirected = Join-Path $script:fixture 'redirected' + New-Item -ItemType Junction -Path $redirected -Value $script:output | Out-Null + $script:result.OutputDirectory = $redirected + $script:result.DiskPath = Join-Path $redirected 'original-export-name.vhdx' + { Get-PublicationDisk $script:result } | Should Throw 'reparse point' + } + It 'rejects string, zero, or mismatched disk lengths' { + foreach ($length in @('4', 0, 5)) { + $script:result.DiskSizeBytes = $length + { Get-PublicationDisk $script:result } | Should Throw + } + } + It 'rejects a VHD format mismatch reported by Hyper-V' { + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHD'; FileSize = 4; ParentPath = ''; Attached = $false } } + { Get-PublicationDisk $script:result } | Should Throw 'Get-VHD' + } + It 'rejects a Hyper-V file-size mismatch' { + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 8; ParentPath = ''; Attached = $false } } + { Get-PublicationDisk $script:result } | Should Throw 'Get-VHD' + } + It 'rejects an ambiguous Hyper-V result' { + Mock Get-VHD { + [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 4; ParentPath = ''; Attached = $false } + [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 4; ParentPath = ''; Attached = $false } + } + { Get-PublicationDisk $script:result } | Should Throw 'Get-VHD' + } + It 'rejects a differencing disk' { + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 4; ParentPath = 'parent.vhdx'; Attached = $false } } + { Get-PublicationDisk $script:result } | Should Throw 'independent' + } + It 'rejects an attached disk' { + Mock Get-VHD { [pscustomobject]@{ VhdFormat = 'VHDX'; FileSize = 4; ParentPath = ''; Attached = $true } } + { Get-PublicationDisk $script:result } | Should Throw 'detached' + } + It 'rejects an unreadable disk' { + Mock Get-VHD { throw 'Disk is unreadable' } + { Get-PublicationDisk $script:result } | Should Throw 'unreadable' + } + It 'rejects a disk that fails Test-VHD and releases its file handle' { + Mock Test-VHD { $false } + { Get-PublicationDisk $script:result } | Should Throw 'Test-VHD' + $stream = [IO.File]::OpenWrite($script:diskPath) + $stream.Dispose() + } + } + + Describe 'Leased Azure publication orchestration' { + BeforeEach { + $script:fixture = Join-Path $script:publicationFixtureRoot ([guid]::NewGuid().ToString('N')) + $script:output = Join-Path $script:fixture 'output' + New-Item -ItemType Directory -Path $script:output -Force | Out-Null + $script:diskPath = Join-Path $script:output 'unchanged-name.vhdx' + [IO.File]::WriteAllBytes($script:diskPath, [byte[]](1, 2, 3, 4)) + $script:resultPath = Join-Path $script:fixture 'result.json' + $script:result = [pscustomobject]@{ + Status = 'Succeeded'; BuildId = 'run-12'; VmName = 'vm-12' + OutputDirectory = $script:output; DiskPath = $script:diskPath + DiskFormat = 'VHDX'; DiskSizeBytes = 4 + } + $script:result | ConvertTo-Json | Set-Content -LiteralPath $script:resultPath + $script:oldEnvironment = @{} + foreach ($name in @('AZURE_STORAGE_ACCOUNT', 'AZURE_STORAGE_KEY', 'AZURE_CONTAINER_NAME', + 'AZURE_STORAGE_CONTAINER', 'AZURE_STORAGE_CONNECTION_STRING', 'AZURE_STORAGE_SAS_TOKEN')) { + $script:oldEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + $env:AZURE_STORAGE_ACCOUNT = 'testaccount' + $env:AZURE_STORAGE_KEY = 'never-log-this-secret' + $env:AZURE_CONTAINER_NAME = 'images' + $env:AZURE_STORAGE_CONTAINER = 'previous-container' + $env:AZURE_STORAGE_CONNECTION_STRING = 'ambient-connection-string' + $env:AZURE_STORAGE_SAS_TOKEN = 'ambient-sas' + $script:commands = New-Object System.Collections.ArrayList + $script:exists = $true + $script:acquireFailure = $false + $script:uploadFailure = $false + $script:releaseFailure = $false + $script:seedFailure = $false + $script:seedError = 'ConditionNotMet' + $script:remoteLength = 4 + Mock Get-VHD { [pscustomobject]@{ VhdFormat = $script:result.DiskFormat; FileSize = 4; ParentPath = ''; Attached = $false } } + Mock Test-VHD { $true } + Mock Get-Command { [pscustomobject]@{ Name = 'az' } } -ParameterFilter { $Name -eq 'az' } + Mock Invoke-PublicationAzureCli { + $null = $script:commands.Add(@($Arguments)) + if ($Arguments -contains '--account-key' -or $Arguments -contains $env:AZURE_STORAGE_KEY) { throw 'Secret passed in arguments.' } + if ($env:AZURE_STORAGE_CONNECTION_STRING -or $env:AZURE_STORAGE_SAS_TOKEN) { throw 'Ambient authentication was not cleared.' } + if ($Arguments -contains 'exists') { return ('{"exists":' + $script:exists.ToString().ToLowerInvariant() + '}') } + if ($Arguments -contains 'acquire' -and $script:acquireFailure) { throw 'LeaseAlreadyPresent' } + if ($Arguments -contains '--if-none-match' -and $script:seedFailure) { + $script:exists = $true + throw $script:seedError + } + if ($Arguments -contains 'upload' -and $Arguments -contains '--lease-id' -and $script:uploadFailure) { + throw 'Upload failed: LeaseIdMismatchWithBlobOperation' + } + if ($Arguments -contains 'release' -and $script:releaseFailure) { throw 'Release failed' } + if ($Arguments -contains 'show') { return ('{"properties":{"contentLength":' + $script:remoteLength + ',"blobType":"BlockBlob"}}') } + return '{}' + } + Mock Start-PublicationLeaseRenewal { [pscustomobject]@{ Id = 123; State = 'Running' } } + Mock Assert-PublicationLeaseRenewal {} + Mock Stop-PublicationLeaseRenewal {} + } + AfterEach { + foreach ($name in $script:oldEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $script:oldEnvironment[$name]) + } + } + It 'leases the canonical blob and uploads the exact file under the same lease' { + $published = Invoke-WindowsImagePublication $script:resultPath + $published.Status | Should Be 'Succeeded' + $published.BlobName | Should Be 'hybrid-minikube-windows-server.vhdx' + $acquire = @($script:commands | Where-Object { $_ -contains 'acquire' })[0] + $upload = @($script:commands | Where-Object { $_ -contains 'upload' })[0] + $release = @($script:commands | Where-Object { $_ -contains 'release' })[0] + $acquire[$acquire.IndexOf('--lease-duration') + 1] | Should Be '60' + $lease = $acquire[$acquire.IndexOf('--proposed-lease-id') + 1] + $upload[$upload.IndexOf('--lease-id') + 1] | Should Be $lease + $release[$release.IndexOf('--lease-id') + 1] | Should Be $lease + $upload[$upload.IndexOf('--file') + 1] | Should Be $script:diskPath + $upload[$upload.IndexOf('--type') + 1] | Should Be 'block' + @($script:commands | Where-Object { $_ -contains '--if-none-match' }).Count | Should Be 0 + $env:AZURE_STORAGE_CONTAINER | Should Be 'previous-container' + $env:AZURE_STORAGE_CONNECTION_STRING | Should Be 'ambient-connection-string' + $env:AZURE_STORAGE_SAS_TOKEN | Should Be 'ambient-sas' + Test-Path -LiteralPath $script:diskPath | Should Be $true + (Get-Content (Join-Path $script:fixture 'publication.json') -Raw | ConvertFrom-Json).Status | Should Be 'Succeeded' + Assert-MockCalled Start-PublicationLeaseRenewal -Times 1 -Exactly -Scope It + Assert-MockCalled Stop-PublicationLeaseRenewal -Times 1 -Exactly -Scope It + } + It 'uses .vhd only when the manifest and Hyper-V report a real VHD' { + $newPath = Join-Path $script:output 'real-disk.vhd' + Move-Item -LiteralPath $script:diskPath -Destination $newPath + $script:result.DiskPath = $newPath + $script:result.DiskFormat = 'VHD' + $script:result | ConvertTo-Json | Set-Content -LiteralPath $script:resultPath + (Invoke-WindowsImagePublication $script:resultPath).BlobName | Should Be 'hybrid-minikube-windows-server.vhd' + Test-Path -LiteralPath $newPath | Should Be $true + } + It 'creates an absent canonical blob conditionally without overwriting a concurrent creator' { + $script:exists = $false + $script:seedFailure = $true + (Invoke-WindowsImagePublication $script:resultPath).Status | Should Be 'Succeeded' + $seed = @($script:commands | Where-Object { $_ -contains '--if-none-match' })[0] + $seed[$seed.IndexOf('--if-none-match') + 1] | Should Be '*' + $seed[$seed.IndexOf('--overwrite') + 1] | Should Be 'false' + @(Get-ChildItem -LiteralPath $script:fixture -Filter 'publication-seed-*').Count | Should Be 0 + } + It 'fails fast on contention and never uploads or releases the other owner lease' { + $script:acquireFailure = $true + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'LeaseAlreadyPresent' + @($script:commands | Where-Object { $_ -contains 'upload' -or $_ -contains 'release' }).Count | Should Be 0 + Assert-MockCalled Start-PublicationLeaseRenewal -Times 0 -Exactly -Scope It + } + It 'does not hide an unrelated seed failure behind concurrent blob existence' { + $script:exists = $false + $script:seedFailure = $true + $script:seedError = 'AuthorizationFailure' + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'AuthorizationFailure' + @($script:commands | Where-Object { $_ -contains 'acquire' -or $_ -contains '--lease-id' }).Count | Should Be 0 + (Get-Content (Join-Path $script:fixture 'publication.json') -Raw | ConvertFrom-Json).Status | Should Be 'Failed' + } + It 'does not upload when initial renewal is not ready' { + Mock Assert-PublicationLeaseRenewal { throw 'Renewal not ready' } + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'Renewal not ready' + @($script:commands | Where-Object { $_ -contains 'upload' }).Count | Should Be 0 + @($script:commands | Where-Object { $_ -contains 'release' }).Count | Should Be 1 + } + It 'propagates lease loss during upload and cleans up its own renewal job' { + $script:uploadFailure = $true + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'LeaseIdMismatch' + Assert-MockCalled Stop-PublicationLeaseRenewal -Times 1 -Exactly -Scope It + @($script:commands | Where-Object { $_ -contains 'release' }).Count | Should Be 1 + (Get-Content (Join-Path $script:fixture 'publication.json') -Raw | ConvertFrom-Json).Status | Should Be 'Failed' + } + It 'propagates renewal failure after upload even when upload itself returned success' { + Mock Assert-PublicationLeaseRenewal { if (-not $WaitUntilReady) { throw 'Renewal failed during upload' } } + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'Renewal failed during upload' + Assert-MockCalled Stop-PublicationLeaseRenewal -Times 1 -Exactly -Scope It + } + It 'fails when remote length does not match the source' { + $script:remoteLength = 9 + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'length' + } + It 'preserves the upload failure when lease cleanup also fails' { + $script:uploadFailure = $true + $script:releaseFailure = $true + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'Upload failed' + $saved = Get-Content (Join-Path $script:fixture 'publication.json') -Raw | ConvertFrom-Json + $saved.Error | Should Match 'Upload failed' + $saved.CleanupErrors[0] | Should Match 'Release failed' + } + It 'reports cleanup failure instead of reporting successful publication' { + $script:releaseFailure = $true + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'cleanup failed' + (Get-Content (Join-Path $script:fixture 'publication.json') -Raw | ConvertFrom-Json).Status | Should Be 'Failed' + } + It 'rejects malformed JSON before making any Azure call' { + Set-Content -LiteralPath $script:resultPath -Value '{malformed' + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw + Assert-MockCalled Invoke-PublicationAzureCli -Times 0 -Exactly -Scope It + } + It 'rejects a top-level array instead of interpreting it as one build' { + Set-Content -LiteralPath $script:resultPath -Value ('[' + ($script:result | ConvertTo-Json) + ']') + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'JSON object' + Assert-MockCalled Invoke-PublicationAzureCli -Times 0 -Exactly -Scope It + } + It 'rejects an unsuccessful build before making any Azure call' { + $script:result.Status = 'Failed' + $script:result | ConvertTo-Json | Set-Content -LiteralPath $script:resultPath + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'Succeeded' + Assert-MockCalled Invoke-PublicationAzureCli -Times 0 -Exactly -Scope It + } + It 'rejects missing credentials without Azure calls' { + $env:AZURE_STORAGE_KEY = '' + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'AZURE_STORAGE_KEY' + Assert-MockCalled Invoke-PublicationAzureCli -Times 0 -Exactly -Scope It + } + It 'rejects a missing disk without Azure calls' { + Remove-Item -LiteralPath $script:diskPath + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw + Assert-MockCalled Invoke-PublicationAzureCli -Times 0 -Exactly -Scope It + } + It 'redacts the account key in persistent error diagnostics' { + Mock Invoke-PublicationAzureCli { throw "Failure containing $env:AZURE_STORAGE_KEY" } + { Invoke-WindowsImagePublication $script:resultPath } | Should Throw 'REDACTED' + $saved = Get-Content (Join-Path $script:fixture 'publication.json') -Raw + $saved.Contains($env:AZURE_STORAGE_KEY) | Should Be $false + $saved | Should Match 'REDACTED' + } + } + + Describe 'Native CLI errors and lease renewal worker' { + BeforeEach { + $script:workerLog = Join-Path $script:publicationFixtureRoot ([guid]::NewGuid().ToString('N') + '.log') + $script:oldKey = $env:AZURE_STORAGE_KEY + $script:oldExitCode = $global:LASTEXITCODE + $env:AZURE_STORAGE_KEY = 'worker-secret-do-not-log' + $script:worker = $null + $script:workerArguments = $null + Mock Start-Job { + $script:worker = $ScriptBlock + $script:workerArguments = $ArgumentList + [pscustomobject]@{ State = 'Running' } + } + Mock az { $global:LASTEXITCODE = 0; '{}' } + } + AfterEach { + $env:AZURE_STORAGE_KEY = $script:oldKey + $global:LASTEXITCODE = $script:oldExitCode + } + It 'checks native exit codes and redacts both stderr and stdout diagnostics' { + Mock az { + $global:LASTEXITCODE = 23 + "stdout $env:AZURE_STORAGE_KEY" + Write-Error "stderr $env:AZURE_STORAGE_KEY" + } + { Invoke-PublicationAzureCli @('storage', 'blob', 'show') $script:workerLog 'Test native failure' } | + Should Throw 'exit 23' + $saved = Get-Content -LiteralPath $script:workerLog -Raw + $saved | Should Match 'stdout' + $saved | Should Match 'stderr' + $saved | Should Match 'REDACTED' + $saved.Contains($env:AZURE_STORAGE_KEY) | Should Be $false + } + It 'does not confuse stderr alone with a native failure' { + Mock az { $global:LASTEXITCODE = 0; Write-Error 'benign diagnostic' } + { Invoke-PublicationAzureCli @('storage', 'blob', 'show') $script:workerLog 'Test native success' } | + Should Not Throw + } + It 'renews independently before signaling readiness and waits fifteen seconds' { + Mock Start-Sleep { throw 'End renewal test' } -ParameterFilter { $Seconds -eq 15 } + Start-PublicationLeaseRenewal 'images' 'canonical.vhdx' 'test-lease' $script:workerLog | Out-Null + $script:workerArguments.Count | Should Be 6 + ($script:workerArguments -join ' ').Contains($env:AZURE_STORAGE_KEY) | Should Be $false + { & $script:worker @script:workerArguments | Out-Null } | Should Throw 'End renewal test' + (Get-Content -LiteralPath $script:workerLog -Raw) | Should Match 'Lease renewed' + Assert-MockCalled az -Times 1 -Exactly -Scope It + Assert-MockCalled Start-Sleep -Times 1 -Exactly -Scope It -ParameterFilter { $Seconds -eq 15 } + } + It 'fails explicitly on renewal errors without exposing credentials' { + Mock az { $global:LASTEXITCODE = 19; "renew failed $env:AZURE_STORAGE_KEY" } + Start-PublicationLeaseRenewal 'images' 'canonical.vhdx' 'test-lease' $script:workerLog | Out-Null + { & $script:worker @script:workerArguments | Out-Null } | Should Throw 'exit 19' + $saved = Get-Content -LiteralPath $script:workerLog -Raw + $saved | Should Match 'REDACTED' + $saved.Contains($env:AZURE_STORAGE_KEY) | Should Be $false + } + It 'does not renew after its original parent exits even if the PID is reused' { + Start-PublicationLeaseRenewal 'images' 'canonical.vhdx' 'test-lease' $script:workerLog | Out-Null + $script:workerArguments[5] = 0 + { & $script:worker @script:workerArguments | Out-Null } | Should Throw 'owner exited' + Assert-MockCalled az -Times 0 -Exactly -Scope It + } + } + + Describe 'Lease renewal health checks' { + BeforeEach { + $script:healthJob = Start-Job { Start-Sleep -Seconds 60 } + } + AfterEach { + Stop-Job -Job $script:healthJob + Remove-Job -Job $script:healthJob -Force + } + It 'requires a running job and readiness before upload' { + Mock Receive-Job { 'LeaseReady' } + { Assert-PublicationLeaseRenewal $script:healthJob -WaitUntilReady } | Should Not Throw + } + It 'rejects a stopped renewer' { + Mock Receive-Job { 'LeaseReady' } + Stop-Job -Job $script:healthJob + { Assert-PublicationLeaseRenewal $script:healthJob } | Should Throw 'not running' + } + It 'propagates asynchronous renewal errors' { + Mock Receive-Job { throw 'LeaseLost' } + { Assert-PublicationLeaseRenewal $script:healthJob } | Should Throw 'LeaseLost' + } + } +} finally { + Remove-Item -LiteralPath $script:publicationFixtureRoot -Recurse -Force +} diff --git a/tests/fixtures/acquire-build-lock.ps1 b/tests/fixtures/acquire-build-lock.ps1 new file mode 100644 index 0000000..7cbb4aa --- /dev/null +++ b/tests/fixtures/acquire-build-lock.ps1 @@ -0,0 +1,11 @@ +param([Parameter(Mandatory = $true)][string]$ProgramDataPath) +. (Join-Path $PSScriptRoot '..\..\scripts\Build-WindowsImage.ps1') +$env:ProgramData = $ProgramDataPath +try { + $lock = Enter-ImageBuildLock + $lock.Dispose() + exit 0 +} catch { + Write-Output $_.Exception.Message + exit 7 +} diff --git a/tests/fixtures/native-command.ps1 b/tests/fixtures/native-command.ps1 new file mode 100644 index 0000000..32e0536 --- /dev/null +++ b/tests/fixtures/native-command.ps1 @@ -0,0 +1,4 @@ +param([int]$ExitCode = 0) +Write-Output 'native stdout' +[Console]::Error.WriteLine('native stderr') +exit $ExitCode diff --git a/tests/fixtures/packer.cmd b/tests/fixtures/packer.cmd new file mode 100644 index 0000000..365c72b --- /dev/null +++ b/tests/fixtures/packer.cmd @@ -0,0 +1,3 @@ +@echo off +echo Packer fixture %* +exit /b 0 diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index c4d3be8..4508997 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -1,37 +1,30 @@ // VM hardware specs -vm_name = "hybrid-minikube-windows-server" -vm_cpus = "2" -vm_memory = "4096" -vm_disk_size = "65536" -switch_name = "Default Switch" -dynamic_memory = "true" -secure_boot = "false" -tpm = "true" -generation = "2" -headless = "false" -skip_export = "false" +vm_name = "hybrid-minikube-windows-server" +vm_cpus = "2" +vm_memory = "4096" +vm_disk_size = "65536" +switch_name = "Default Switch" +dynamic_memory = "true" +secure_boot = "false" +tpm = "true" +generation = "2" +headless = "false" +skip_export = "false" enable_virtualization_extensions = "false" -guest_additions_mode = "disable" +guest_additions_mode = "disable" // Use the NAT Network // vm_network = "VMnet8" // WinRM -winrm_username = "Administrator" -winrm_password = "password" +winrm_username = "Administrator" +winrm_password = "password" -// server 2022 -// win_iso = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" -// server 2025 -win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" -// In Powershell use the "Get-FileHash" command to find the checksum of the ISO -// server 2022 -//win_checksum = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" -// server 2025 -win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" -windows_version = "2025" -kubernetes_version = "v1.33.1" +kubernetes_version = "v1.37.0" +windows_version = "2025" +containerd_version = "2.2.3" + win_iso_urls = { "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" @@ -41,4 +34,17 @@ win_iso_urls = { win_iso_checksums = { "2022" = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" "2025" = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" -} \ No newline at end of file +} + +// 26100.1742.240906-0331.ge_release_svc_refresh_SERVER_EVAL_x64FRE_en-us.iso +// 5.8GB +// "2025" = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" +// In Powershell use the "Get-FileHash" command to find the checksum of the ISO +// "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" + + +// 26100.32230.260111-0550.lt_release_svc_refresh_SERVER_EVAL_x64FRE_en-us.iso +// 7.9GB +// "2025" = "https://go.microsoft.com/fwlink/?linkid=2345730&clcid=0x409&culture=en-us&country=us" +// In Powershell use the "Get-FileHash" command to find the checksum of the ISO +// "7B052573BA7894C9924E3E87BA732CCD354D18CB75A883EFA9B900EA125BFD51" diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 6fc38b7..dc67224 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -17,51 +17,66 @@ locals { variable "vm_name" { type = string - description = "Image name" + description = "Base image name; the build ID is appended to the temporary VM name" } -variable "vm_cpus" { +variable "build_id" { type = string - description = "amount of vCPUs" + description = "Unique ID supplied by scripts\\Build-WindowsImage.ps1" + + validation { + condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$", var.build_id)) + error_message = "Build ID must contain 1-64 letters, digits, or hyphens and start with a letter or digit." + } } -variable "vm_disk_size" { +variable "output_directory" { type = string - description = "Harddisk size" + description = "Isolated export directory for this build" + + validation { + condition = length(trimspace(var.output_directory)) > 0 + error_message = "Output directory must be explicitly supplied for each build." + } } -variable "vm_memory" { +variable "vm_cpus" { type = string - description = "VM Memory" + description = "amount of vCPUs" } -variable "win_iso" { +variable "vm_disk_size" { type = string - description = "Windows Server ISO location" + description = "Harddisk size" } -variable "win_checksum" { +variable "vm_memory" { type = string - description = "Windows Server ISO checksum" + description = "VM Memory" } variable "win_iso_checksums" { - type = map(string) + type = map(string) default = {} } variable "win_iso_urls" { - type = map(string) + type = map(string) default = {} } variable "windows_version" { - type = string + type = string default = "" } variable "kubernetes_version" { - type = string + type = string + default = "" +} + +variable "containerd_version" { + type = string default = "" } @@ -122,74 +137,114 @@ variable "guest_additions_mode" { } source "hyperv-iso" "windows-server" { - boot_command = ["a"] - boot_wait = "2s" - - secondary_iso_images = ["./setup/auto-install.iso"] - vm_name = var.vm_name - cpus = var.vm_cpus - memory = var.vm_memory - enable_dynamic_memory = var.dynamic_memory - disk_size = var.vm_disk_size - skip_export = var.skip_export - switch_name = var.switch_name - iso_checksum = lookup( var.win_iso_checksums, var.windows_version, "") - iso_url = lookup( var.win_iso_urls, var.windows_version, "") - generation = var.generation - enable_secure_boot = var.secure_boot - guest_additions_mode = var.guest_additions_mode - - + # Zero selects Packer's default 10-second delay; a negative duration skips it. + boot_wait = "-1s" + boot_command = [for attempt in range(10) : "a"] + + vm_name = "${var.vm_name}-${var.build_id}" + output_directory = var.output_directory + cpus = var.vm_cpus + memory = var.vm_memory + enable_dynamic_memory = var.dynamic_memory + disk_size = var.vm_disk_size + skip_export = var.skip_export + switch_name = var.switch_name + iso_checksum = lookup(var.win_iso_checksums, var.windows_version, "") + iso_url = lookup(var.win_iso_urls, var.windows_version, "") + generation = var.generation + enable_secure_boot = var.secure_boot + guest_additions_mode = var.guest_additions_mode + + communicator = "winrm" winrm_port = "5985" winrm_username = var.winrm_username winrm_password = var.winrm_password winrm_timeout = "12h" shutdown_command = "shutdown /s /t 10 /f" - cd_files = ["./setup/*"] - cd_label = "scripts" + cd_files = ["./setup", "./setup/*"] } build { sources = ["source.hyperv-iso.windows-server"] + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 1/7 - starting Containers feature installation'", + "Install-WindowsFeature -Name containers", + "Write-Output 'PACKER: Step 1/7 - completed Containers feature installation'" + ] + } + + provisioner "windows-restart" { + restart_timeout = "15m" + } + + + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 2/7 - about to run ./setup/bootstrap.ps1'" + ] + } + provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password - environment_vars = [ + environment_vars = [ "WINDOWS_VERSION=${var.windows_version}" ] - script = "./setup/bootstrap.ps1" + script = "./setup/bootstrap.ps1" + } + + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 3/7 - about to run ./setup/configure-vm.ps1'" + ] } provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password - environment_vars = [ - "KUBERNETES_VERSION=${var.kubernetes_version}" + environment_vars = [ + "KUBERNETES_VERSION=${var.kubernetes_version}", + "CONTAINERD_VERSION=${var.containerd_version}" ] - script = "./setup/configure-vm.ps1" + script = "./setup/configure-vm.ps1" } provisioner "windows-update" { - search_criteria = "IsInstalled=0" - filters = [ - "exclude:$_.Title -like '*Preview*'", - "include:$true", - ] - update_limit = 25 - } + search_criteria = "IsInstalled=0" + filters = [ + "exclude:$_.Title -like '*Preview*'", + "include:$true", + ] + update_limit = 25 + } provisioner "windows-restart" { + # marker for logs + # PACKER: Step 4/7 - restarting the VM for updates restart_timeout = "1h" } + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 5/7 - running ./setup/disable-autolog.ps1'" + ] + } + provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password scripts = ["./setup/disable-autolog.ps1"] } + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 6/7 - running ./setup/enable-ssh.ps1'" + ] + } + provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password