From 603816edf50c5a42ff3b718b45f94355d5c8b806 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Jun 2025 17:56:08 +0100 Subject: [PATCH 01/38] log output folder --- .github/workflows/build-windows-vhd.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 76d12bf..737eb62 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - user/vhd_pipeline_automation jobs: build-vhd: @@ -101,4 +102,18 @@ jobs: Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { Write-Output 'Building VHD using Packer...' packer build -force -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' + }`"" -Verb RunAs -Wait + + - name: Log Packer Output + shell: pwsh + run: | + Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { + Write-Output 'Logging Packer output directory...' + $outputDir = Get-ChildItem -Path './output' -Recurse + if ($outputDir) { + Write-Output 'Generated files:' + $outputDir | ForEach-Object { Write-Output $_.FullName } + } else { + Write-Output 'No output files found.' + } }`"" -Verb RunAs -Wait \ No newline at end of file From 56de3e16bcdbb7656a801cad0720e86f11c057ed Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Mon, 30 Jun 2025 11:27:51 +0100 Subject: [PATCH 02/38] added my azure credentials --- .github/workflows/build-windows-vhd.yml | 125 +++++++++++------------- 1 file changed, 57 insertions(+), 68 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 737eb62..a310100 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -1,4 +1,4 @@ -name: Build or Test on Hyper-V Runner +name: Build and Upload Windows VHD on: push: @@ -7,113 +7,102 @@ on: - user/vhd_pipeline_automation jobs: - build-vhd: - name: Run on Hyper-V Self-Hosted Runner + build-upload-vhd: + name: Build and Upload VHD runs-on: - self-hosted - 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 run: | - echo "Runner OS: $RUNNER_OS" - echo "Runner Labels: $RUNNER_LABELS" - echo "PowerShell version: $($PSVersionTable.PSVersion)" + Write-Output "Runner OS: $env:RUNNER_OS" + Write-Output "PowerShell version: $($PSVersionTable.PSVersion)" - - name: Ensure Hyper-V is available + - name: Ensure Hyper-V is enabled shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All - if ($hv.State -ne 'Enabled') { - Write-Error 'Hyper-V feature is not enabled on this host.' - } else { - Write-Output 'Hyper-V is enabled.' - } - }`"" -Verb RunAs -Wait + $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All + if ($hv.State -ne 'Enabled') { + throw 'Hyper-V is not enabled.' + } + Write-Output 'Hyper-V is enabled.' - - name: Install Chocolatey if not installed + - name: Install Chocolatey if needed shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Output 'Chocolatey is not installed. Installing...' - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - Write-Output 'Chocolatey installed successfully.' - } else { - Write-Output 'Chocolatey is already installed.' - } - }`"" -Verb RunAs -Wait + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } else { + Write-Output 'Chocolatey already installed.' + } - - name: Install Packer using Chocolatey + - name: Install Packer and Azure CLI shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { - Write-Output 'Packer is not installed. Installing via Chocolatey...' - choco install packer -y - Write-Output 'Packer installed successfully.' - } else { - Write-Output 'Packer is already installed.' - } - }`"" -Verb RunAs -Wait + if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { + choco install packer -y + } + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + choco install azure-cli -y + } - name: Initialize Packer plugins shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Initializing Packer plugins...' - packer plugins install github.com/hashicorp/hyperv - }`"" -Verb RunAs -Wait + packer plugins install github.com/hashicorp/hyperv - name: Initialize Packer configuration shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Initializing Packer configuration...' - packer init windows.json.pkr.hcl - }`"" -Verb RunAs -Wait + packer init windows.json.pkr.hcl - name: Format Packer configuration shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Formatting Packer configuration...' - packer fmt -var-file=windows.auto.pkrvars.hcl windows.json.pkr.hcl - }`"" -Verb RunAs -Wait + packer fmt -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - name: Validate Packer configuration shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Validating Packer configuration...' - packer validate . - }`"" -Verb RunAs -Wait + packer validate -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - name: Build VHD using Packer shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Building VHD using Packer...' - packer build -force -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - }`"" -Verb RunAs -Wait + packer build -force -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' + + - name: Locate generated VHD + shell: pwsh + id: find_vhd + run: | + $vhdFile = Get-ChildItem -Path './output' -Filter '*.vhd*' -Recurse | Select-Object -First 1 + if ($null -eq $vhdFile) { + throw "No VHD file found." + } + Write-Output "VHD_PATH=$($vhdFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Output "Found VHD: $($vhdFile.FullName)" - - name: Log Packer Output + - name: Upload VHD to Azure Blob Storage shell: pwsh run: | - Start-Process -FilePath "powershell.exe" -ArgumentList "-Command `"& { - Write-Output 'Logging Packer output directory...' - $outputDir = Get-ChildItem -Path './output' -Recurse - if ($outputDir) { - Write-Output 'Generated files:' - $outputDir | ForEach-Object { Write-Output $_.FullName } - } else { - Write-Output 'No output files found.' - } - }`"" -Verb RunAs -Wait \ No newline at end of file + 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 -Path "$env:VHD_PATH" -Leaf) ` + --overwrite + Write-Output "Uploaded VHD: $(Split-Path -Path "$env:VHD_PATH" -Leaf)" \ No newline at end of file From a9cb4a665baf989bcdcd8994200c448b3dc97178 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Mon, 30 Jun 2025 11:52:47 +0100 Subject: [PATCH 03/38] elevated ps commands --- .github/workflows/build-windows-vhd.yml | 61 ++++++++++--------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index a310100..ec9b468 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -29,54 +29,42 @@ jobs: Write-Output "Runner OS: $env:RUNNER_OS" Write-Output "PowerShell version: $($PSVersionTable.PSVersion)" - - name: Ensure Hyper-V is enabled + - name: Ensure Hyper-V is enabled (Elevated) shell: pwsh run: | - $hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All - if ($hv.State -ne 'Enabled') { - throw 'Hyper-V is not enabled.' - } - Write-Output 'Hyper-V is enabled.' + Start-Process pwsh -ArgumentList "-Command `"& { + `$hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All + if (`$hv.State -ne 'Enabled') { + throw 'Hyper-V is not enabled.' + } + Write-Output 'Hyper-V is enabled.' + }`"" -Verb RunAs -Wait - - name: Install Chocolatey if needed + - name: Install Chocolatey, Packer, and Azure CLI (Elevated) shell: pwsh run: | - if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - } else { - Write-Output 'Chocolatey already installed.' - } + Start-Process pwsh -ArgumentList "-Command `"& { + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + } - - name: Install Packer and Azure CLI - shell: pwsh - run: | - if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { - choco install packer -y - } - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - choco install azure-cli -y - } + if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { + choco install packer -y + } - - name: Initialize Packer plugins - shell: pwsh - run: | - packer plugins install github.com/hashicorp/hyperv + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + choco install azure-cli -y + } + }`"" -Verb RunAs -Wait - - name: Initialize Packer configuration + - name: Initialize and Validate Packer shell: pwsh run: | + packer plugins install github.com/hashicorp/hyperv packer init windows.json.pkr.hcl - - - name: Format Packer configuration - shell: pwsh - run: | packer fmt -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - - - name: Validate Packer configuration - shell: pwsh - run: | packer validate -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - name: Build VHD using Packer @@ -86,7 +74,6 @@ jobs: - name: Locate generated VHD shell: pwsh - id: find_vhd run: | $vhdFile = Get-ChildItem -Path './output' -Filter '*.vhd*' -Recurse | Select-Object -First 1 if ($null -eq $vhdFile) { From dab4aef2b5310b7a6b49c31bc4479d655b15cf34 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 1 Jul 2025 18:20:18 +0100 Subject: [PATCH 04/38] added logging commands --- .github/workflows/build-windows-vhd.yml | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index ec9b468..3e9e94c 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -26,35 +26,43 @@ jobs: - name: Display runner info shell: pwsh run: | - Write-Output "Runner OS: $env:RUNNER_OS" - Write-Output "PowerShell version: $($PSVersionTable.PSVersion)" + echo "Runner OS: $env:RUNNER_OS" + echo "PowerShell version: $($PSVersionTable.PSVersion)" - name: Ensure Hyper-V is enabled (Elevated) shell: pwsh run: | Start-Process pwsh -ArgumentList "-Command `"& { + echo 'Checking if Hyper-V is enabled...' `$hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All if (`$hv.State -ne 'Enabled') { throw 'Hyper-V is not enabled.' } - Write-Output 'Hyper-V is enabled.' + echo 'Hyper-V is enabled.' }`"" -Verb RunAs -Wait - name: Install Chocolatey, Packer, and Azure CLI (Elevated) shell: pwsh run: | Start-Process pwsh -ArgumentList "-Command `"& { + echo 'Checking and installing dependencies...' if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + echo 'Installing Chocolatey...' Set-ExecutionPolicy Bypass -Scope Process -Force [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + echo 'Chocolatey installed.' + } else { + echo 'Chocolatey already installed.' } if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { + echo 'Installing Packer...' choco install packer -y } if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + echo 'Installing Azure CLI...' choco install azure-cli -y } }`"" -Verb RunAs -Wait @@ -62,29 +70,36 @@ jobs: - name: Initialize and Validate Packer shell: pwsh run: | + echo 'Installing Hyper-V plugin...' packer plugins install github.com/hashicorp/hyperv + echo 'Initializing Packer configuration...' packer init windows.json.pkr.hcl + echo 'Formatting Packer configuration...' packer fmt -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' + echo 'Validating Packer configuration...' packer validate -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - name: Build VHD using Packer shell: pwsh run: | + echo 'Starting Packer build...' packer build -force -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - name: Locate generated VHD shell: pwsh run: | + echo 'Searching for generated VHD file...' $vhdFile = Get-ChildItem -Path './output' -Filter '*.vhd*' -Recurse | Select-Object -First 1 if ($null -eq $vhdFile) { throw "No VHD file found." } - Write-Output "VHD_PATH=$($vhdFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - Write-Output "Found VHD: $($vhdFile.FullName)" + echo "VHD_PATH=$($vhdFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + echo "Found VHD: $($vhdFile.FullName)" - name: Upload VHD to Azure Blob Storage shell: pwsh run: | + echo "Uploading VHD to Azure Blob Storage..." az storage blob upload ` --account-name $env:AZURE_STORAGE_ACCOUNT ` --account-key $env:AZURE_STORAGE_KEY ` @@ -92,4 +107,4 @@ jobs: --file "$env:VHD_PATH" ` --name (Split-Path -Path "$env:VHD_PATH" -Leaf) ` --overwrite - Write-Output "Uploaded VHD: $(Split-Path -Path "$env:VHD_PATH" -Leaf)" \ No newline at end of file + echo "Uploaded VHD: $(Split-Path -Path "$env:VHD_PATH" -Leaf)" \ No newline at end of file From 757937823ecfb578a22c8928241015d570564058 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 1 Jul 2025 19:04:31 +0100 Subject: [PATCH 05/38] minor logging addition --- .github/workflows/build-windows-vhd.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 3e9e94c..2a07fec 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -27,7 +27,9 @@ jobs: shell: pwsh run: | echo "Runner OS: $env:RUNNER_OS" + Write-Host "Runner Architecture: $env:RUNNER_ARCH" echo "PowerShell version: $($PSVersionTable.PSVersion)" + Write-Host "Powershell version: $($PSVersionTable.PSEdition)" - name: Ensure Hyper-V is enabled (Elevated) shell: pwsh From 6a5cae26d105b612b4704bbc0e080eaad02f5aac Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 1 Jul 2025 19:51:14 +0100 Subject: [PATCH 06/38] added required packer block --- .github/workflows/build-windows-vhd.yml | 3 +-- windows.json.pkr.hcl | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 2a07fec..636a8c9 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -72,8 +72,7 @@ jobs: - name: Initialize and Validate Packer shell: pwsh run: | - echo 'Installing Hyper-V plugin...' - packer plugins install github.com/hashicorp/hyperv + echo 'Installing Packer plugins...' echo 'Initializing Packer configuration...' packer init windows.json.pkr.hcl echo 'Formatting Packer configuration...' diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index d260812..4a222c5 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -1,3 +1,16 @@ +packer { + required_plugins { + hyperv = { + version = ">= 1.0.0" + source = "github.com/hashicorp/hyperv" + } + windows-update = { + version = ">= 0.14.0" + source = "github.com/rgl/windows-update" + } + } +} + locals { version = formatdate("YYYY.MM.DD", timestamp()) } From deb3d99d987e574dc18e1cf42e302fb2c3e4b6a0 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 2 Jul 2025 00:52:47 +0100 Subject: [PATCH 07/38] Trigger workflow after fixing permissions From 06ce718f0294c52d654dadd9e2290d9ad24ab104 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 2 Jul 2025 01:07:12 +0100 Subject: [PATCH 08/38] Trigger workflow after fixing permission --- .github/workflows/build-windows-vhd.yml | 127 +++++++++++++++--------- 1 file changed, 81 insertions(+), 46 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 636a8c9..aef52a3 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -16,8 +16,8 @@ jobs: env: AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} - AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }} - AZURE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER }} + AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }} + AZURE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER }} steps: - name: Checkout repository @@ -26,86 +26,121 @@ jobs: - name: Display runner info shell: pwsh run: | - echo "Runner OS: $env:RUNNER_OS" - Write-Host "Runner Architecture: $env:RUNNER_ARCH" - echo "PowerShell version: $($PSVersionTable.PSVersion)" - Write-Host "Powershell version: $($PSVersionTable.PSEdition)" + echo "Runner OS: $env:RUNNER_OS" + Write-Host "Runner OS: $env:RUNNER_OS" + echo "Runner Labels: $env:RUNNER_LABELS" + Write-Host "Runner Labels: $env:RUNNER_LABELS" + echo "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" + Write-Host "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" - - name: Ensure Hyper-V is enabled (Elevated) + - name: Ensure Hyper-V is enabled shell: pwsh run: | - Start-Process pwsh -ArgumentList "-Command `"& { - echo 'Checking if Hyper-V is enabled...' - `$hv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All - if (`$hv.State -ne 'Enabled') { - throw 'Hyper-V is not enabled.' + Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { + echo "Checking Hyper-V feature..." + 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!" } - echo 'Hyper-V is enabled.' - }`"" -Verb RunAs -Wait + echo "Hyper-V is enabled." + Write-Host "Hyper-V is enabled." + }' -Wait - - name: Install Chocolatey, Packer, and Azure CLI (Elevated) + - name: Grant Hyper-V Administrators group membership shell: pwsh run: | - Start-Process pwsh -ArgumentList "-Command `"& { - echo 'Checking and installing dependencies...' + $runnerUser = (whoami).Trim() + echo "Adding '$runnerUser' to Hyper-V Administrators group..." + Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." + Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop + echo "Current Hyper-V Administrators members:" + Write-Host "Current Hyper-V Administrators members:" + Get-LocalGroupMember -Group "Hyper-V Administrators" | ForEach-Object { echo $_.Name; Write-Host $_.Name } + + - name: Install Chocolatey, Packer & Azure CLI + shell: pwsh + run: | + Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { + echo "Checking and installing dependencies…" + Write-Host "Checking and installing dependencies…" + if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - echo 'Installing Chocolatey...' + echo "Installing Chocolatey…" + Write-Host "Installing Chocolatey…" Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - echo 'Chocolatey installed.' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-Expression ((New-Object Net.WebClient).DownloadString("https://community.chocolatey.org/install.ps1")) } else { - echo 'Chocolatey already installed.' + echo "Chocolatey already present." + Write-Host "Chocolatey already present." } if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { - echo 'Installing Packer...' + echo "Installing Packer…" + Write-Host "Installing Packer…" choco install packer -y + } else { + echo "Packer already present." + Write-Host "Packer already present." } if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - echo 'Installing Azure CLI...' + echo "Installing Azure CLI…" + Write-Host "Installing Azure CLI…" choco install azure-cli -y + } else { + echo "Azure CLI already present." + Write-Host "Azure CLI already present." } - }`"" -Verb RunAs -Wait + }' -Wait - - name: Initialize and Validate Packer + - name: Initialize & Validate Packer shell: pwsh run: | - echo 'Installing Packer plugins...' - echo 'Initializing Packer configuration...' + echo "Initializing Packer…" + Write-Host "Initializing Packer…" packer init windows.json.pkr.hcl - echo 'Formatting Packer configuration...' - packer fmt -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - echo 'Validating Packer configuration...' - packer validate -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' - - name: Build VHD using Packer + echo "Formatting…" + Write-Host "Formatting…" + packer fmt -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl + + echo "Validating…" + Write-Host "Validating…" + packer validate -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl + + - name: Build VHD with Packer shell: pwsh run: | - echo 'Starting Packer build...' - packer build -force -var-file='windows.auto.pkrvars.hcl' 'windows.json.pkr.hcl' + echo "Starting Packer build…" + 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: | - echo 'Searching for generated VHD file...' - $vhdFile = Get-ChildItem -Path './output' -Filter '*.vhd*' -Recurse | Select-Object -First 1 - if ($null -eq $vhdFile) { - throw "No VHD file found." + echo "Searching for VHD in ./output…" + Write-Host "Searching for VHD in ./output…" + $vhd = Get-ChildItem -Path ./output -Filter *.vhd* -Recurse | Select-Object -First 1 + if (-not $vhd) { + throw "No VHD file found in output directory!" } - echo "VHD_PATH=$($vhdFile.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - echo "Found VHD: $($vhdFile.FullName)" + echo "Found VHD: $($vhd.FullName)" + 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 run: | - echo "Uploading VHD to Azure Blob Storage..." + echo "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" + 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 ` + --account-key $env:AZURE_STORAGE_KEY ` --container-name $env:AZURE_CONTAINER_NAME ` - --file "$env:VHD_PATH" ` - --name (Split-Path -Path "$env:VHD_PATH" -Leaf) ` + --file $env:VHD_PATH ` + --name (Split-Path $env:VHD_PATH -Leaf) ` --overwrite - echo "Uploaded VHD: $(Split-Path -Path "$env:VHD_PATH" -Leaf)" \ No newline at end of file + echo "Upload complete." + Write-Host "Upload complete." From 1f961a7bb0a1889999ed11fc4d4add6166ee5c81 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 2 Jul 2025 02:37:14 +0100 Subject: [PATCH 09/38] Trigger workflow after fixing permission From a32931f45ec0ae37d38c85102a5362ef8d39bf97 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 2 Jul 2025 02:42:23 +0100 Subject: [PATCH 10/38] removed a few lines --- .github/workflows/build-windows-vhd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index aef52a3..460a52f 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -53,7 +53,7 @@ jobs: $runnerUser = (whoami).Trim() echo "Adding '$runnerUser' to Hyper-V Administrators group..." Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." - Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop + # Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop echo "Current Hyper-V Administrators members:" Write-Host "Current Hyper-V Administrators members:" Get-LocalGroupMember -Group "Hyper-V Administrators" | ForEach-Object { echo $_.Name; Write-Host $_.Name } From 90203752b36c9004acc7d815555ef3b627c53546 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 2 Jul 2025 22:52:44 +0100 Subject: [PATCH 11/38] changed the searched folder --- .github/workflows/build-windows-vhd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 460a52f..b23bdc9 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -120,9 +120,9 @@ jobs: - name: Locate generated VHD shell: pwsh run: | - echo "Searching for VHD in ./output…" - Write-Host "Searching for VHD in ./output…" - $vhd = Get-ChildItem -Path ./output -Filter *.vhd* -Recurse | Select-Object -First 1 + echo "Searching for VHD in output directories..." + 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!" } From 6173503d42e03a4da49670f30b49781e9c4ff1da Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 5 Aug 2025 21:56:11 +0100 Subject: [PATCH 12/38] Update GitHub Actions workflow to set WINDOWS_VERSION and KUBERNETES_VERSION env vars for Packer build --- .github/workflows/build-windows-vhd.yml | 13 ++++ setup/bootstrap.ps1 | 90 +++++++++++-------------- setup/configure-vm.ps1 | 16 +++-- windows.auto.pkrvars.hcl | 15 ++++- windows.json.pkr.hcl | 10 ++- 5 files changed, 84 insertions(+), 60 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index b23bdc9..cdf257b 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -5,6 +5,16 @@ on: branches: - main - user/vhd_pipeline_automation + workflow_dispatch: + inputs: + kubernetes_version: + description: 'Kubernetes version (optional, leave blank for latest)' + required: false + default: '' + windows_version: + description: 'Windows version (optional, leave blank for latest)' + required: false + default: '' jobs: build-upload-vhd: @@ -112,6 +122,9 @@ jobs: - name: Build VHD with Packer shell: pwsh + env: + KUBERNETES_VERSION: ${{ inputs.kubernetes_version || '' }} + WINDOWS_VERSION: ${{ inputs.windows_version || '' }} run: | echo "Starting Packer build…" Write-Host "Starting Packer build…" diff --git a/setup/bootstrap.ps1 b/setup/bootstrap.ps1 index 16dc741..b5056f6 100644 --- a/setup/bootstrap.ps1 +++ b/setup/bootstrap.ps1 @@ -1,61 +1,49 @@ -$global:os="" +$global:os = "" function whichWindows { - $version=(Get-WMIObject win32_operatingsystem).name + # Check if WINDOWS_VERSION environment variable is set + if ($env:WINDOWS_VERSION) { + $global:os = $env:WINDOWS_VERSION + Write-Output "Phase 1 [INFO] - Using Windows Server version from environment variable: $global:os" + printWindowsVersion + return + } + + # Fallback to detecting Windows version dynamically + $version = (Get-WMIObject win32_operatingsystem).name if ($version) { switch -Regex ($version) { '(Server 2016)' { - $global:os="2016" + $global:os = "2016" printWindowsVersion } '(Server 2019)' { - $global:os="2019" + $global:os = "2019" printWindowsVersion } '(Server 2022)' { - $global:os="2022" + $global:os = "2022" printWindowsVersion } '(Server 2025)' { - $global:os="2025" + $global:os = "2025" printWindowsVersion } '(Microsoft Windows Server Standard|Microsoft Windows Server Datacenter)' { - $ws_version=(Get-WmiObject win32_operatingsystem).buildnumber + $ws_version = (Get-WmiObject win32_operatingsystem).buildnumber switch -Regex ($ws_version) { - '16299' { - $global:os="1709" - printWindowsVersion - } - '17134' { - $global:os="1803" - printWindowsVersion - } - '17763' { - $global:os="1809" - printWindowsVersion - } - '18362' { - $global:os="1903" - printWindowsVersion - } - '18363' { - $global:os="1909" - printWindowsVersion - } - '19041' { - $global:os="2004" - printWindowsVersion - } - '19042' { - $global:os="20H2" - printWindowsVersion - } + '16299' { $global:os = "1709"; printWindowsVersion } + '17134' { $global:os = "1803"; printWindowsVersion } + '17763' { $global:os = "1809"; printWindowsVersion } + '18362' { $global:os = "1903"; printWindowsVersion } + '18363' { $global:os = "1909"; printWindowsVersion } + '19041' { $global:os = "2004"; printWindowsVersion } + '19042' { $global:os = "20H2"; printWindowsVersion } } } '(Windows 10)' { Write-Output 'Phase 1 [INFO] - Windows 10 found' - $global:os="10" + $global:os = "10" printWindowsVersion } default { @@ -63,17 +51,15 @@ function whichWindows { printWindowsVersion } } - } - else { - throw "Buildnumber empty, cannot continue" + } else { + throw "Build number empty, cannot continue" } } function printWindowsVersion { if ($global:os) { - Write-Output "Phase 1 [INFO] - Windows Server "$global:os" found." - } - else { + Write-Output "Phase 1 [INFO] - Windows Server $global:os found." + } else { Write-Output "Phase 1 [INFO] - Unknown version of Windows Server found." } } @@ -82,21 +68,22 @@ function printWindowsVersion { Write-Output "Phase 1 [START] - Start of Phase 1" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Import-Module ServerManager -# let's check which windows + +# Determine Windows version whichWindows -# 1709/1803/1809/1903/2019/2022/2025 + +# Apply configurations based on Windows version if ($global:os -notlike '2016') { Enable-NetFirewallRule -DisplayGroup "Windows Defender Firewall Remote Management" -Verbose } -# features and firewall rules common for all Windows Servers +# Features and firewall rules common for all Windows Servers try { Install-WindowsFeature SNMP-Service,SNMP-WMI-Provider -IncludeManagementTools Enable-NetFirewallRule -DisplayGroup "Remote Desktop" -Verbose Enable-NetFirewallRule -DisplayGroup "Remote Service Management" -Verbose -} -catch { - Write-Output "Phase 1 [ERROR] - setting firewall went wrong" +} catch { + Write-Output "Phase 1 [ERROR] - Setting firewall went wrong" } # Terminal services and sysprep registry entries @@ -104,10 +91,9 @@ try { Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -name "fDenyTSConnections" -Value 0 -Verbose -Force Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -name "UserAuthentication" -Value 0 -Verbose -Force Set-ItemProperty -Path 'HKLM:\SYSTEM\Setup\Status\SysprepStatus' -Name 'GeneralizationState' -Value 7 -Verbose -Force -} -catch { - Write-Output "Phase 1 [ERROR] - setting registry went wrong" +} catch { + Write-Output "Phase 1 [ERROR] - Setting registry went wrong" } Write-Output "Phase 1 [END] - End of Phase 1" -exit 0 +exit 0s \ No newline at end of file diff --git a/setup/configure-vm.ps1 b/setup/configure-vm.ps1 index a93f8ca..41830f5 100644 --- a/setup/configure-vm.ps1 +++ b/setup/configure-vm.ps1 @@ -1,5 +1,15 @@ $envPathRegKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" +if ($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" +} + function Get-LatestToolVersion($repository) { try { $uri = "https://api.github.com/repos/$repository/releases/latest" @@ -302,10 +312,6 @@ function Install-Kubelet { $KubernetesVersion ) - $KubernetesVersion = Get-k8LatestVersion - Write-Output "* The latest Kubernetes version is $KubernetesVersion" - $KubernetesVersion = $KubernetesVersion.TrimStart('v') - # Check if kubelet service is already installed $nssmService = Get-WmiObject win32_service | Where-Object {$_.PathName -like '*nssm*'} if ($nssmService.Name -eq 'kubelet') { @@ -401,7 +407,7 @@ Write-Output "Phase 4 [Installing NSSM] ..." Install-NSSM Write-Output "Phase 5 [Installing Kubelet] ..." -Install-Kubelet +Install-Kubelet -KubernetesVersion $kubernetes_ver Write-Output "Phase 6 [Setting Port] ..." Set-Port diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index 38471f6..c4d3be8 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -28,4 +28,17 @@ win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=e // server 2022 //win_checksum = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" // server 2025 -win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" \ No newline at end of file +win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" + +windows_version = "2025" +kubernetes_version = "v1.33.1" + +win_iso_urls = { + "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" + "2025" = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" +} + +win_iso_checksums = { + "2022" = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" + "2025" = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" +} \ No newline at end of file diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 4a222c5..bb81ece 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -113,8 +113,8 @@ source "hyperv-iso" "windows-server" { disk_size = var.vm_disk_size skip_export = var.skip_export switch_name = var.switch_name - iso_checksum = var.win_checksum - iso_url = var.win_iso + 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 @@ -136,12 +136,18 @@ build { provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password + environment_vars = [ + "WINDOWS_VERSION=${var.windows_version}" + ] script = "./setup/bootstrap.ps1" } provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password + environment_vars = [ + "KUBERNETES_VERSION=${var.kubernetes_version}" + ] script = "./setup/configure-vm.ps1" } From 757075d11017eb40d60a312cb94e3cd2e088c473 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 6 Aug 2025 13:06:17 +0100 Subject: [PATCH 13/38] fixed windows serverversion lookup functionality --- windows.json.pkr.hcl | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index bb81ece..2b664e0 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -45,6 +45,26 @@ variable "win_checksum" { description = "Windows Server ISO checksum" } +variable "win_iso_checksums" { + type = map(string) + default = {} +} + +variable "win_iso_urls" { + type = map(string) + default = {} +} + +variable "windows_version" { + type = string + default = {} +} + +variable "kubernetes_version" { + type = string + default = {} +} + variable "winrm_username" { type = string description = "winrm username" @@ -113,8 +133,8 @@ source "hyperv-iso" "windows-server" { 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) + 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 From eb83cb3f1008ff163c3e068eb0feac4ccd7c8d6f Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 6 Aug 2025 13:25:49 +0100 Subject: [PATCH 14/38] string mismatch fix --- windows.json.pkr.hcl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 2b664e0..83c2d37 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -52,12 +52,12 @@ variable "win_iso_checksums" { variable "win_iso_urls" { type = map(string) - default = {} + default = "" } variable "windows_version" { type = string - default = {} + default = "" } variable "kubernetes_version" { From b2220bb8e02e8f27f887d6b77d2bf5a441d7493d Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 6 Aug 2025 13:35:55 +0100 Subject: [PATCH 15/38] reversed back to the correct order of the types --- windows.json.pkr.hcl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 83c2d37..6fc38b7 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -52,7 +52,7 @@ variable "win_iso_checksums" { variable "win_iso_urls" { type = map(string) - default = "" + default = {} } variable "windows_version" { @@ -62,7 +62,7 @@ variable "windows_version" { variable "kubernetes_version" { type = string - default = {} + default = "" } variable "winrm_username" { From 2dc3f51435e0852e319c0483facb510f5cc41772 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Sun, 10 Aug 2025 23:25:11 +0100 Subject: [PATCH 16/38] removed the echo statements --- .github/workflows/build-windows-vhd.yml | 26 ++----------------------- README.md | 7 +++++++ setup/bootstrap.ps1 | 2 +- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index cdf257b..4838232 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -36,24 +36,19 @@ jobs: - name: Display runner info shell: pwsh run: | - echo "Runner OS: $env:RUNNER_OS" Write-Host "Runner OS: $env:RUNNER_OS" - echo "Runner Labels: $env:RUNNER_LABELS" Write-Host "Runner Labels: $env:RUNNER_LABELS" - echo "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" Write-Host "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" - name: Ensure Hyper-V is enabled shell: pwsh run: | Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { - echo "Checking Hyper-V feature..." 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!" } - echo "Hyper-V is enabled." Write-Host "Hyper-V is enabled." }' -Wait @@ -61,46 +56,37 @@ jobs: shell: pwsh run: | $runnerUser = (whoami).Trim() - echo "Adding '$runnerUser' to Hyper-V Administrators group..." Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." # Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop - echo "Current Hyper-V Administrators members:" Write-Host "Current Hyper-V Administrators members:" - Get-LocalGroupMember -Group "Hyper-V Administrators" | ForEach-Object { echo $_.Name; Write-Host $_.Name } + 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 { - echo "Checking and installing dependencies…" Write-Host "Checking and installing dependencies…" if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - echo "Installing Chocolatey…" 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 { - echo "Chocolatey already present." Write-Host "Chocolatey already present." } if (-not (Get-Command packer -ErrorAction SilentlyContinue)) { - echo "Installing Packer…" Write-Host "Installing Packer…" choco install packer -y } else { - echo "Packer already present." Write-Host "Packer already present." } if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - echo "Installing Azure CLI…" Write-Host "Installing Azure CLI…" choco install azure-cli -y } else { - echo "Azure CLI already present." Write-Host "Azure CLI already present." } }' -Wait @@ -108,15 +94,12 @@ jobs: - name: Initialize & Validate Packer shell: pwsh run: | - echo "Initializing Packer…" Write-Host "Initializing Packer…" packer init windows.json.pkr.hcl - echo "Formatting…" Write-Host "Formatting…" packer fmt -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl - echo "Validating…" Write-Host "Validating…" packer validate -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl @@ -126,27 +109,23 @@ jobs: KUBERNETES_VERSION: ${{ inputs.kubernetes_version || '' }} WINDOWS_VERSION: ${{ inputs.windows_version || '' }} run: | - echo "Starting Packer build…" 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: | - echo "Searching for VHD in output directories..." 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!" } - echo "Found VHD: $($vhd.FullName)" 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 run: | - echo "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" Write-Host "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" az storage blob upload ` --account-name $env:AZURE_STORAGE_ACCOUNT ` @@ -155,5 +134,4 @@ jobs: --file $env:VHD_PATH ` --name (Split-Path $env:VHD_PATH -Leaf) ` --overwrite - echo "Upload complete." - Write-Host "Upload complete." + Write-Host "Upload complete." \ No newline at end of file diff --git a/README.md b/README.md index b47201d..8f73f2d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,13 @@ packer validate . packer build -force -var-file="windows.auto.pkrvars.hcl" "windows.json.pkr.hcl" ``` +To override versions locally: +Add -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' (or your desired values) to your Packer commands: + +```powershell +packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' "windows.json.pkr.hcl" +``` + ### Default password diff --git a/setup/bootstrap.ps1 b/setup/bootstrap.ps1 index b5056f6..b58e270 100644 --- a/setup/bootstrap.ps1 +++ b/setup/bootstrap.ps1 @@ -96,4 +96,4 @@ try { } Write-Output "Phase 1 [END] - End of Phase 1" -exit 0s \ No newline at end of file +exit 0 \ No newline at end of file From c63729acead5856b6d7144b7f226c8c277025580 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Sun, 25 Jan 2026 16:32:49 +0000 Subject: [PATCH 17/38] bumped up k8 version --- windows.auto.pkrvars.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index c4d3be8..9864e32 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -31,7 +31,7 @@ win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=e win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" windows_version = "2025" -kubernetes_version = "v1.33.1" +kubernetes_version = "v1.35.0" win_iso_urls = { "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" From fc9e5255ca3b6e51d6f48afea1d8f81b93f30e93 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Sun, 25 Jan 2026 17:41:22 +0000 Subject: [PATCH 18/38] fixed lint issues --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8f73f2d..0cbfdc7 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,34 @@ -## 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 +- Make sure the Hyper-V role is enabled +- Install the Windows Assessment and Deployment Kit (32-bit version). +- Add the following location the the system path variable: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\x86\Oscdimg -### On Powershell Administrator complete the following steps +### On Powershell Administrator complete the following steps -1. Clone the repo +1. Clone the repo ```powershell git clone https://github.com/bobsira/windows-node-image-builder.git ``` -2. Change the current directory to `windows-node-image-builder`: +1. Change the current directory to `windows-node-image-builder`: ```powershell cd windows-node-image-builder ``` -3. Install packer using the command below +1. Install packer using the command below ```powershell choco install packer ``` -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. +1. 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. -5. Then run the following commands: +2. Then run the following commands: ```powershell packer -v @@ -45,9 +46,8 @@ Add -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' (or your desir packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' "windows.json.pkr.hcl" ``` - ### Default password |OS|username|password| |--|--------|--------| -|Windows|Administrator|password| \ No newline at end of file +|Windows|Administrator|password| From 0f7d330770c6f71ad9dddf799e5053c8cfab62cd Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 4 Feb 2026 15:44:37 +0000 Subject: [PATCH 19/38] buid with k8 v1350 --- windows.auto.pkrvars.hcl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index 9864e32..af08de0 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -30,8 +30,9 @@ win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=e // server 2025 win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" -windows_version = "2025" kubernetes_version = "v1.35.0" +windows_version = "2025" + win_iso_urls = { "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" From 129509ce2b51f004f310bd6d500315a895933c49 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Fri, 20 Feb 2026 17:51:49 +0000 Subject: [PATCH 20/38] removed unrequired space --- setup/Autounattend.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/Autounattend.xml b/setup/Autounattend.xml index ff05ab1..dced4db 100644 --- a/setup/Autounattend.xml +++ b/setup/Autounattend.xml @@ -72,7 +72,7 @@ - /IMAGE/INDEX + /IMAGE/INDEX 3 From 2ec90d640c1e2a9fbc032a9ddc7afd7d629fa60a Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 10:45:14 +0000 Subject: [PATCH 21/38] cleaning up for porting over to minikube repo --- .github/workflows/build-windows-vhd.yml | 25 ++- README.md | 6 +- setup/Autounattend.xml | 37 ++--- setup/auto-install.iso | Bin 65536 -> 0 bytes setup/configure-vm.ps1 | 78 +++++---- setup/enable-winrm.ps1 | 201 ++++++++++++++++++------ windows.auto.pkrvars.hcl | 54 ++++--- windows.json.pkr.hcl | 109 +++++++++---- 8 files changed, 339 insertions(+), 171 deletions(-) delete mode 100644 setup/auto-install.iso diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 4838232..e3ea303 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -15,6 +15,10 @@ on: description: 'Windows version (optional, leave blank for latest)' required: false default: '' + containerd_version: + description: 'Containerd version (optional, leave blank to use var-file)' + required: false + default: '' jobs: build-upload-vhd: @@ -98,7 +102,7 @@ jobs: packer init windows.json.pkr.hcl Write-Host "Formatting…" - packer fmt -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl + 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 @@ -108,9 +112,24 @@ jobs: 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 + $k = $env:KUBERNETES_VERSION + $w = $env:WINDOWS_VERSION + $c = $env:CONTAINERD_VERSION + $extra = '' + + if ($k -and $k -ne '') { $extra += "-var \"kubernetes_version=$k\" " } + if ($w -and $w -ne '') { $extra += "-var \"windows_version=$w\" " } + if ($c -and $c -ne '') { $extra += "-var \"containerd_version=$c\" " } + + Write-Host "Starting Packer build…" + if ($extra -ne '') { + Write-Host "Passing extra vars to Packer: $extra" + packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl + } else { + packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl + } - name: Locate generated VHD shell: pwsh diff --git a/README.md b/README.md index 0cbfdc7..81a4613 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,16 @@ choco install packer 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 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" ``` To override versions locally: -Add -var 'windows_version=2022' -var 'kubernetes_version=v1.33.1' (or your desired values) to your Packer commands: +Add -var 'windows_version=2022' -var 'kubernetes_version=v1.35.0' -var 'containerd_version=1.7.25' (or your desired values) to your Packer commands: ```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 build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.35.0' -var 'containerd_version=1.7.25' "windows.json.pkr.hcl" ``` ### Default password diff --git a/setup/Autounattend.xml b/setup/Autounattend.xml index ff05ab1..c1a7a04 100644 --- a/setup/Autounattend.xml +++ b/setup/Autounattend.xml @@ -37,6 +37,7 @@ Primary + 1 @@ -65,6 +66,7 @@ 0 true + OnError @@ -76,12 +78,14 @@ 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 29df6739c293dc07a2bc71f4522b08b0e987abdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 zcmeI*{cqYv7zgm9>a++6sg<^Bn%eHF;mf`u64xT~L?jI2I~U^v9*4^cUKOYYeW3zis7hd(D1oty$L1nFMR< zU{jARJ#cYA00I#BKM34N_J6j^T;{XKEYJAde|cuJeY#onGfzMB?8&KoNoRO`&wpO2 zp4&ZI22=WWa{vF~xWte8)L{4GlzV6G|EF3ozuic^N`WGcnK3J|A(M1=m65)_?n^c6 zQkiD9`18UY{x0SG_<0uX=z1Rwwb2tWV=3ly0D8T+^S@n`H`m?M>E_v0Si zug9O$U;mW8^G)ujo0LlLFQjf&>&4Pm)!f~zZ`X2-roF)aA~Xab009U<00L)y#{TL3 zf8M9?pU9CaU9kUiCg{WA9rm7ibY`+q^i>0r^;wP$SV&(gfCB;$fB*y_0D3YIf-nbn2%JqlB$fPsr2fR6QRjbE+p+NeL z0Ut>Bz)^ztgSOC_x5dYyD@B`k0?Fws2DfXxLyt#}=W#joM3_lG9LS(Up(x}Fq0G8a z6fmi>5kHklH_v#6n{}rz_@s_p^hnpC+g7HQbOSl&jks01K@~T2-czt73vat&D>x8x zoJmX3<;6;czx-pXw%2^q+^N>~%0o#Wx1HRkSmf1<} zL_~b##%7y(;&#Ta-_7N6#`2-|prffAsm!J)#DJSkK^L?ga@$ItJ+i$Z?&tsPBI&@@ zsPB5j5NSxms2|X%syV*X6@BUkcYUsU;wZyLGN^Ky^saQ3FkcPTV5qneRYW~L%6a*! z$cRQ~a&flYP{rXTLA=%Tg$8;c-#vd`mVyS_rXMPYW={6)&UhdwnFg~y+mJ!Oyw%*S zWZ%9Ry6tzyVWnKMtg>aBt83PV`J}jBGM`nRuABDy`bMQxTwTjQE`2tZmnr!1Ob99J zoVMgBO47m;d9%}HKkd|oNxZR<2^a3)&PvvRt=zdWhk$XG<%tkYcsH-rk3oTkyMy@aWoR*c=hd4GjKh8~6 z((%JVAl37pQiDkrJ^7+4R+7?>em|@^f9<$^5e!v7EEx8)=sTN?&yUV3v!W{qd0eta zV^fYBLNi=G#ZUi-A-%JD#ncEM1R7TR+nCxus>q3Y=x zN>1puE-u#cT$(jsTFRqAFMJ_?E4JHGgkj;AUw0;`g_k=!4c&qU2^Wmj^=GS5yY;Av zy7d(^ZhG=Y_56#;`vq(8)Jjuq_g$ZQ9}nwO>+x7`T9>KWr=6)_OjUeh3Cn6hbJJg* zuAV#5ep;YSG8o14a9hyoBGoyj$-tPIfl0~p6(@zGsYi2`wt;wzMl*$*QSjkF(R#)W e8hQb(j7i0(vtH+KE{FvI2tWV=5P-nf3j70@kfGNA diff --git a/setup/configure-vm.ps1 b/setup/configure-vm.ps1 index 41830f5..1878bbd 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" @@ -301,10 +300,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 +376,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..6f750eb 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 } 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/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index af08de0..fc01083 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -1,45 +1,49 @@ // 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" +win_iso = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" +win_checksum = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" + // server 2025 -win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" +//win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" +//win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" kubernetes_version = "v1.35.0" -windows_version = "2025" +windows_version = "2022" +containerd_version = "1.7.25" win_iso_urls = { "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" - "2025" = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" + "2025" = "https://go.microsoft.com/fwlink/?linkid=2345730&clcid=0x409&culture=en-us&country=us" } win_iso_checksums = { "2022" = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" - "2025" = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" -} \ No newline at end of file + "2025" = "7B052573BA7894C9924E3E87BA732CCD354D18CB75A883EFA9B900EA125BFD51" +} + +// 26100.1742.240906-0331.ge_release_svc_refresh_SERVER_EVAL_x64FRE_en-us.iso +// "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" \ No newline at end of file diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 6fc38b7..33cb5a9 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -46,22 +46,27 @@ variable "win_checksum" { } 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 = "" } @@ -125,71 +130,109 @@ 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 - - + 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 + + 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 0/6 - starting Containers feature installation'", + "Install-WindowsFeature -Name containers", + "Write-Output 'PACKER: Step 0/6 - completed Containers feature installation'" + ] + } + + provisioner "windows-restart" { + restart_timeout = "15m" + } + + + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 1/6 - 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 2/6 - 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/6 - restarting the VM for updates restart_timeout = "1h" } + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 5/6 - 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/6 - running ./setup/enable-ssh.ps1'" + ] + } + provisioner "powershell" { elevated_user = var.winrm_username elevated_password = var.winrm_password From 27aa8aa5e597c591bcde0e6bbd81527f8edf4749 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 10:52:48 +0000 Subject: [PATCH 22/38] added powershell - shell --- .github/workflows/build-windows-vhd.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index e3ea303..6ea02cd 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -38,16 +38,16 @@ jobs: 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 + shell: powershell run: | - Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { + Start-Process powershell -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") { @@ -57,7 +57,7 @@ jobs: }' -Wait - name: Grant Hyper-V Administrators group membership - shell: pwsh + shell: powershell run: | $runnerUser = (whoami).Trim() Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." @@ -66,9 +66,9 @@ jobs: Get-LocalGroupMember -Group "Hyper-V Administrators" | ForEach-Object { Write-Host $_.Name } - name: Install Chocolatey, Packer & Azure CLI - shell: pwsh + shell: powershell run: | - Start-Process pwsh -Verb RunAs -ArgumentList '-NoProfile -Command { + Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile -Command { Write-Host "Checking and installing dependencies…" if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { @@ -96,7 +96,7 @@ jobs: }' -Wait - name: Initialize & Validate Packer - shell: pwsh + shell: powershell run: | Write-Host "Initializing Packer…" packer init windows.json.pkr.hcl @@ -108,7 +108,7 @@ jobs: packer validate -var-file='windows.auto.pkrvars.hcl' windows.json.pkr.hcl - name: Build VHD with Packer - shell: pwsh + shell: powershell env: KUBERNETES_VERSION: ${{ inputs.kubernetes_version || '' }} WINDOWS_VERSION: ${{ inputs.windows_version || '' }} @@ -132,7 +132,7 @@ jobs: } - name: Locate generated VHD - shell: pwsh + shell: powershell run: | Write-Host "Searching for VHD in output directories..." $vhd = Get-ChildItem -Path "./output*", "./output-*", "./output" -Include "*.vhd", "*.vhdx" -Recurse | Select-Object -First 1 @@ -143,7 +143,7 @@ jobs: echo "VHD_PATH=$($vhd.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - name: Upload VHD to Azure Blob Storage - shell: pwsh + shell: powershell run: | Write-Host "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" az storage blob upload ` From 4e27f30405109e456c1f66cdbc94575ef9112f43 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 10:56:46 +0000 Subject: [PATCH 23/38] fixing the quotation marks --- .github/workflows/build-windows-vhd.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 6ea02cd..a7bc399 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -119,9 +119,9 @@ jobs: $c = $env:CONTAINERD_VERSION $extra = '' - if ($k -and $k -ne '') { $extra += "-var \"kubernetes_version=$k\" " } - if ($w -and $w -ne '') { $extra += "-var \"windows_version=$w\" " } - if ($c -and $c -ne '') { $extra += "-var \"containerd_version=$c\" " } + if ($k -and $k -ne '') { $extra += ('-var "kubernetes_version={0}" ' -f $k) } + if ($w -and $w -ne '') { $extra += ('-var "windows_version={0}" ' -f $w) } + if ($c -and $c -ne '') { $extra += ('-var "containerd_version={0}" ' -f $c) } Write-Host "Starting Packer build…" if ($extra -ne '') { From 608161d8d2fa11d56900af15898255db6891e60c Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 12:17:38 +0000 Subject: [PATCH 24/38] adding more detailed logging --- .github/workflows/build-windows-vhd.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index a7bc399..2fb7b9e 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -32,6 +32,8 @@ jobs: AZURE_STORAGE_ACCOUNT: ${{ secrets.AZURE_STORAGE_ACCOUNT }} AZURE_STORAGE_KEY: ${{ secrets.AZURE_STORAGE_KEY }} AZURE_CONTAINER_NAME: ${{ secrets.AZURE_STORAGE_CONTAINER }} + PACKER_LOG: '1' + PACKER_LOG_PATH: ${{ github.workspace }}\packer.log steps: - name: Checkout repository @@ -123,13 +125,19 @@ jobs: if ($w -and $w -ne '') { $extra += ('-var "windows_version={0}" ' -f $w) } if ($c -and $c -ne '') { $extra += ('-var "containerd_version={0}" ' -f $c) } + $log = Join-Path $PWD 'packer.log' + $env:PACKER_LOG = '1' + $env:PACKER_LOG_PATH = $log + Write-Host "Starting Packer build…" if ($extra -ne '') { Write-Host "Passing extra vars to Packer: $extra" - packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl + $cmd = "packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl" } else { - packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl + $cmd = "packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl" } + Write-Host "Running: $cmd" + iex "$cmd 2>&1 | Tee-Object -FilePath '$log'" - name: Locate generated VHD shell: powershell @@ -142,11 +150,19 @@ jobs: Write-Host "Found VHD: $($vhd.FullName)" echo "VHD_PATH=$($vhd.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Upload Packer log + uses: actions/upload-artifact@v4 + with: + name: packer-log + path: packer.log + - name: Upload VHD to Azure Blob Storage shell: powershell run: | Write-Host "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" az storage blob upload ` + --only-show-errors ` + --no-progress ` --account-name $env:AZURE_STORAGE_ACCOUNT ` --account-key $env:AZURE_STORAGE_KEY ` --container-name $env:AZURE_CONTAINER_NAME ` From 30241103343fe1fa7ae9c01db6e200bb1510552f Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 14:26:10 +0000 Subject: [PATCH 25/38] testing logging and mounting --- .github/workflows/build-windows-vhd.yml | 24 +++++++--------- setup/vhd-mount.ps1 | 38 +++++++++++++++++++++++++ windows.json.pkr.hcl | 26 ++++++++++++----- 3 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 setup/vhd-mount.ps1 diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 2fb7b9e..95f3b0f 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -116,28 +116,26 @@ jobs: WINDOWS_VERSION: ${{ inputs.windows_version || '' }} CONTAINERD_VERSION: ${{ inputs.containerd_version || '' }} run: | - $k = $env:KUBERNETES_VERSION + k = $env:KUBERNETES_VERSION $w = $env:WINDOWS_VERSION $c = $env:CONTAINERD_VERSION - $extra = '' + $args = @('build','-force','--var-file=./windows.auto.pkrvars.hcl') - if ($k -and $k -ne '') { $extra += ('-var "kubernetes_version={0}" ' -f $k) } - if ($w -and $w -ne '') { $extra += ('-var "windows_version={0}" ' -f $w) } - if ($c -and $c -ne '') { $extra += ('-var "containerd_version={0}" ' -f $c) } + if ($k) { $args += ('-var'); $args += ("kubernetes_version=$k") } + if ($w) { $args += ('-var'); $args += ("windows_version=$w") } + if ($c) { $args += ('-var'); $args += ("containerd_version=$c") } $log = Join-Path $PWD 'packer.log' $env:PACKER_LOG = '1' $env:PACKER_LOG_PATH = $log Write-Host "Starting Packer build…" - if ($extra -ne '') { - Write-Host "Passing extra vars to Packer: $extra" - $cmd = "packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl" - } else { - $cmd = "packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl" - } - Write-Host "Running: $cmd" - iex "$cmd 2>&1 | Tee-Object -FilePath '$log'" + Write-Host "Running: packer $($args -join ' ')" + + # run packer and write all output only to packer.log + Start-Process -FilePath 'packer' -ArgumentList $args -NoNewWindow -Wait -RedirectStandardOutput $log -RedirectStandardError $log + + Write-Host "Packer finished; log written to $log" - name: Locate generated VHD shell: powershell diff --git a/setup/vhd-mount.ps1 b/setup/vhd-mount.ps1 new file mode 100644 index 0000000..11ae742 --- /dev/null +++ b/setup/vhd-mount.ps1 @@ -0,0 +1,38 @@ +# Minimal VHD mount -> set partition -> dismount script for CI host +# Looks for: output-windows-server\Virtual Hard Disks\hybrid-minikube-windows-server.vhdx + +$VhdName = 'hybrid-minikube-windows-server.vhdx' +$relative = "..\output-windows-server\Virtual Hard Disks\$VhdName" +$vhdPath = Join-Path -Path $PSScriptRoot -ChildPath $relative + +function Write-Log { param($m) Write-Host "[vhd-mount] $m" } + +Write-Log "Looking for VHD: $vhdPath" +if (-not (Test-Path -Path $vhdPath)) { + Write-Error "VHD not found: $vhdPath" + exit 1 +} + +try { + Write-Log "Mounting VHD (read-only)..." + Mount-VHD -Path $vhdPath -ReadOnly -ErrorAction Stop + + Start-Sleep -Seconds 2 + + Write-Log "Assigning drive letter Z to DiskNumber 1 PartitionNumber 4" + Set-Partition -DiskNumber 1 -PartitionNumber 4 -NewDriveLetter Z -ErrorAction Stop + +} catch { + Write-Error "Operation failed: $_" + # attempt best-effort dismount + try { Dismount-VHD -Path $vhdPath -ErrorAction SilentlyContinue } catch {} + exit 1 +} finally { + Write-Log "Dismounting VHD..." + try { Dismount-VHD -Path $vhdPath -ErrorAction Stop } catch { + Write-Error "Failed to dismount VHD: $($_.Exception.Message)" + exit 1 + } +} + +Write-Log "Completed mount, assign, dismount." diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 33cb5a9..d5f8f4f 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -158,9 +158,9 @@ build { provisioner "powershell" { inline = [ - "Write-Output 'PACKER: Step 0/6 - starting Containers feature installation'", + "Write-Output 'PACKER: Step 1/7 - starting Containers feature installation'", "Install-WindowsFeature -Name containers", - "Write-Output 'PACKER: Step 0/6 - completed Containers feature installation'" + "Write-Output 'PACKER: Step 1/7 - completed Containers feature installation'" ] } @@ -171,7 +171,7 @@ build { provisioner "powershell" { inline = [ - "Write-Output 'PACKER: Step 1/6 - about to run ./setup/bootstrap.ps1'" + "Write-Output 'PACKER: Step 2/7 - about to run ./setup/bootstrap.ps1'" ] } @@ -186,7 +186,7 @@ build { provisioner "powershell" { inline = [ - "Write-Output 'PACKER: Step 2/6 - about to run ./setup/configure-vm.ps1'" + "Write-Output 'PACKER: Step 3/7 - about to run ./setup/configure-vm.ps1'" ] } @@ -211,13 +211,13 @@ build { provisioner "windows-restart" { # marker for logs - # PACKER: Step 4/6 - restarting the VM for updates + # PACKER: Step 4/7 - restarting the VM for updates restart_timeout = "1h" } provisioner "powershell" { inline = [ - "Write-Output 'PACKER: Step 5/6 - running ./setup/disable-autolog.ps1'" + "Write-Output 'PACKER: Step 5/7 - running ./setup/disable-autolog.ps1'" ] } @@ -229,7 +229,7 @@ build { provisioner "powershell" { inline = [ - "Write-Output 'PACKER: Step 6/6 - running ./setup/enable-ssh.ps1'" + "Write-Output 'PACKER: Step 6/7 - running ./setup/enable-ssh.ps1'" ] } @@ -238,4 +238,16 @@ build { elevated_password = var.winrm_password scripts = ["./setup/enable-ssh.ps1"] } + + provisioner "powershell" { + inline = [ + "Write-Output 'PACKER: Step 7/7 - running ./setup/vhd-mount.ps1 on the Packer host'" + ] + } + + provisioner "powershell" { + elevated_user = var.winrm_username + elevated_password = var.winrm_password + scripts = ["./setup/vhd-mount.ps1"] + } } \ No newline at end of file From 5af5d5c405ff36ae055f6550b775a1c9a46dfd7c Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 14:31:09 +0000 Subject: [PATCH 26/38] more log fixes --- .github/workflows/build-windows-vhd.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 95f3b0f..b19ccf1 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -116,7 +116,7 @@ jobs: WINDOWS_VERSION: ${{ inputs.windows_version || '' }} CONTAINERD_VERSION: ${{ inputs.containerd_version || '' }} run: | - k = $env:KUBERNETES_VERSION + $k = $env:KUBERNETES_VERSION $w = $env:WINDOWS_VERSION $c = $env:CONTAINERD_VERSION $args = @('build','-force','--var-file=./windows.auto.pkrvars.hcl') @@ -130,10 +130,12 @@ jobs: $env:PACKER_LOG_PATH = $log Write-Host "Starting Packer build…" - Write-Host "Running: packer $($args -join ' ')" + Write-Host "Running Packer (output redirected to packer.log)" - # run packer and write all output only to packer.log - Start-Process -FilePath 'packer' -ArgumentList $args -NoNewWindow -Wait -RedirectStandardOutput $log -RedirectStandardError $log + # run packer and write all output only to packer.log (use cmd.exe to ensure redirection) + $argString = $args -join ' ' + $redirectCmd = "packer $argString > \"$log\" 2>&1" + Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $redirectCmd -NoNewWindow -Wait Write-Host "Packer finished; log written to $log" From c99e4423834af261614744eae75c6dbe2942bb38 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 14:33:21 +0000 Subject: [PATCH 27/38] fixed concat issues --- .github/workflows/build-windows-vhd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index b19ccf1..82e6761 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -134,7 +134,7 @@ jobs: # run packer and write all output only to packer.log (use cmd.exe to ensure redirection) $argString = $args -join ' ' - $redirectCmd = "packer $argString > \"$log\" 2>&1" + $redirectCmd = 'packer ' + $argString + ' > "' + $log + '" 2>&1' Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $redirectCmd -NoNewWindow -Wait Write-Host "Packer finished; log written to $log" From 70940b0f660abe4e39ba9981ac30041e72124ae2 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 14:38:23 +0000 Subject: [PATCH 28/38] revert back --- .github/workflows/build-windows-vhd.yml | 28 +++++++++---------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 82e6761..b9e7c22 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -119,25 +119,17 @@ jobs: $k = $env:KUBERNETES_VERSION $w = $env:WINDOWS_VERSION $c = $env:CONTAINERD_VERSION - $args = @('build','-force','--var-file=./windows.auto.pkrvars.hcl') - - if ($k) { $args += ('-var'); $args += ("kubernetes_version=$k") } - if ($w) { $args += ('-var'); $args += ("windows_version=$w") } - if ($c) { $args += ('-var'); $args += ("containerd_version=$c") } - - $log = Join-Path $PWD 'packer.log' - $env:PACKER_LOG = '1' - $env:PACKER_LOG_PATH = $log - + $extra = '' + if ($k -and $k -ne '') { $extra += ('-var "kubernetes_version={0}" ' -f $k) } + if ($w -and $w -ne '') { $extra += ('-var "windows_version={0}" ' -f $w) } + if ($c -and $c -ne '') { $extra += ('-var "containerd_version={0}" ' -f $c) } Write-Host "Starting Packer build…" - Write-Host "Running Packer (output redirected to packer.log)" - - # run packer and write all output only to packer.log (use cmd.exe to ensure redirection) - $argString = $args -join ' ' - $redirectCmd = 'packer ' + $argString + ' > "' + $log + '" 2>&1' - Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $redirectCmd -NoNewWindow -Wait - - Write-Host "Packer finished; log written to $log" + if ($extra -ne '') { + Write-Host "Passing extra vars to Packer: $extra" + packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl + } else { + packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl + } - name: Locate generated VHD shell: powershell From 362c0f3d61fb15001cbc854c91eda887157200ea Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Tue, 24 Feb 2026 16:10:24 +0000 Subject: [PATCH 29/38] fixed vhd locator issues --- setup/vhd-mount.ps1 | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/setup/vhd-mount.ps1 b/setup/vhd-mount.ps1 index 11ae742..bdd1e0c 100644 --- a/setup/vhd-mount.ps1 +++ b/setup/vhd-mount.ps1 @@ -9,8 +9,43 @@ function Write-Log { param($m) Write-Host "[vhd-mount] $m" } Write-Log "Looking for VHD: $vhdPath" if (-not (Test-Path -Path $vhdPath)) { - Write-Error "VHD not found: $vhdPath" - exit 1 + Write-Log "Primary path not found; searching output directories (output*, output-*, output) for .vhd/.vhdx files..." + + function Find-GeneratedVhd { + param( + [string]$baseDir + ) + $searchPatterns = @("$baseDir\output*", "$baseDir\output-*", "$baseDir\output") + foreach ($pattern in $searchPatterns) { + try { + $found = Get-ChildItem -Path $pattern -Include "*.vhd","*.vhdx" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { return $found.FullName } + } catch { + # ignore and continue + } + } + return $null + } + + # Try script root, then its parent directories (keeps script generic for local and CI use) + $found = Find-GeneratedVhd -baseDir $PSScriptRoot + if (-not $found) { + $parent = Split-Path -Path $PSScriptRoot -Parent + while ($parent -and -not $found) { + $found = Find-GeneratedVhd -baseDir $parent + if ($found) { break } + $next = Split-Path -Path $parent -Parent + if ($next -and $next -ne $parent) { $parent = $next } else { break } + } + } + + if (-not $found) { + Write-Error "VHD not found: $vhdPath" + exit 1 + } + + $vhdPath = $found + Write-Log "Found VHD: $vhdPath" } try { From 679536253200baef559d94aa25ceba55c84ae827 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 25 Feb 2026 13:00:36 +0000 Subject: [PATCH 30/38] fixed host/guest mismatch --- .github/workflows/build-windows-vhd.yml | 11 ++++ setup/vhd-mount.ps1 | 86 +++++++++++++++++++++---- windows.json.pkr.hcl | 12 ---- 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index b9e7c22..4f96c20 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -141,6 +141,17 @@ jobs: } Write-Host "Found VHD: $($vhd.FullName)" echo "VHD_PATH=$($vhd.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append + - name: Validate VHD mounts (host) + shell: powershell + run: | + Write-Host "Validating VHD mount/dismount on host: $env:VHD_PATH" + if (-not (Test-Path -LiteralPath $env:VHD_PATH)) { + throw "VHD_PATH does not exist: $env:VHD_PATH" + } + + # This script accepts -VhdPath and will mount, set partition drive letter, then dismount. + powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\setup\vhd-mount.ps1" -VhdPath "$env:VHD_PATH" -DriveLetter "Z" + - name: Upload Packer log uses: actions/upload-artifact@v4 diff --git a/setup/vhd-mount.ps1 b/setup/vhd-mount.ps1 index bdd1e0c..3a01229 100644 --- a/setup/vhd-mount.ps1 +++ b/setup/vhd-mount.ps1 @@ -1,11 +1,34 @@ # Minimal VHD mount -> set partition -> dismount script for CI host -# Looks for: output-windows-server\Virtual Hard Disks\hybrid-minikube-windows-server.vhdx +# Usage: .\vhd-mount.ps1 [-VhdPath ] [-DriveLetter ] +# If no -VhdPath is provided the script searches for generated VHDs under output* dirs. + +param( + [string]$VhdPath, + [string]$DriveLetter = 'Z', + [switch]$ReadOnly = $true +) $VhdName = 'hybrid-minikube-windows-server.vhdx' $relative = "..\output-windows-server\Virtual Hard Disks\$VhdName" $vhdPath = Join-Path -Path $PSScriptRoot -ChildPath $relative -function Write-Log { param($m) Write-Host "[vhd-mount] $m" } +# If caller provided an explicit VhdPath, prefer that +if ($VhdPath) { + $vhdPath = $VhdPath +} + +function Write-Log { param([string]$m) Write-Host "[vhd-mount] $m" } + +# Ensure running elevated +try { + $isAdmin = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} catch { + $isAdmin = $false +} +if (-not $isAdmin) { + Write-Error "This script must be run as Administrator." + exit 1 +} Write-Log "Looking for VHD: $vhdPath" if (-not (Test-Path -Path $vhdPath)) { @@ -49,24 +72,63 @@ if (-not (Test-Path -Path $vhdPath)) { } try { - Write-Log "Mounting VHD (read-only)..." - Mount-VHD -Path $vhdPath -ReadOnly -ErrorAction Stop + $mounted = $false + Write-Log "Mounting VHD (ReadOnly=$ReadOnly)..." + if ($ReadOnly) { + Mount-VHD -Path $vhdPath -ReadOnly -ErrorAction Stop + } else { + Mount-VHD -Path $vhdPath -ErrorAction Stop + } + $mounted = $true Start-Sleep -Seconds 2 - Write-Log "Assigning drive letter Z to DiskNumber 1 PartitionNumber 4" - Set-Partition -DiskNumber 1 -PartitionNumber 4 -NewDriveLetter Z -ErrorAction Stop + # Resolve disk/partition info from the mounted VHD + $vhd = Get-VHD -Path $vhdPath -ErrorAction Stop + if ($null -eq $vhd.DiskNumber) { + throw "Mounted VHD has no DiskNumber (mount may have failed)." + } + + $disk = Get-Disk -Number $vhd.DiskNumber -ErrorAction Stop + Write-Log "Mounted disk: Number=$($disk.Number) Size=$($disk.Size) Style=$($disk.PartitionStyle)" + + # Choose the largest Basic Data partition (Windows volume) by GPT type or Basic type + $part = Get-Partition -DiskNumber $disk.Number | + Where-Object { ($_.GptType -and ($_.GptType -ieq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}')) -or ($_.Type -and ($_.Type -ieq 'Basic')) } | + Sort-Object Size -Descending | + Select-Object -First 1 + + if (-not $part) { + throw "No Basic/Windows partition found on disk $($disk.Number)" + } + + if (-not $part.DriveLetter) { + Write-Log "Assigning drive letter $DriveLetter to disk $($disk.Number) partition $($part.PartitionNumber)" + Set-Partition -DiskNumber $disk.Number -PartitionNumber $part.PartitionNumber -NewDriveLetter $DriveLetter -ErrorAction Stop + } else { + $DriveLetter = $part.DriveLetter + Write-Log "Partition already has drive letter: $DriveLetter" + } + + Write-Log "Sanity: Test-Path ${DriveLetter}:\\Windows => $(Test-Path "${DriveLetter}:\Windows")" + if (-not (Test-Path "${DriveLetter}:\Windows")) { + throw "Mounted volume does not look like Windows. Check partition selection." + } + + Write-Log "Top-level contents:" + Get-ChildItem "${DriveLetter}:\" -Force | Select-Object -First 20 | ForEach-Object { Write-Log (" - " + $_.Name) } } catch { Write-Error "Operation failed: $_" - # attempt best-effort dismount - try { Dismount-VHD -Path $vhdPath -ErrorAction SilentlyContinue } catch {} exit 1 } finally { - Write-Log "Dismounting VHD..." - try { Dismount-VHD -Path $vhdPath -ErrorAction Stop } catch { - Write-Error "Failed to dismount VHD: $($_.Exception.Message)" - exit 1 + if ($mounted) { + Write-Log "Dismounting VHD (best-effort)..." + try { + Dismount-VHD -Path $vhdPath -ErrorAction Stop + } catch { + Write-Error "Warning: failed to dismount VHD: $($_.Exception.Message)" + } } } diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index d5f8f4f..f28b17d 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -238,16 +238,4 @@ build { elevated_password = var.winrm_password scripts = ["./setup/enable-ssh.ps1"] } - - provisioner "powershell" { - inline = [ - "Write-Output 'PACKER: Step 7/7 - running ./setup/vhd-mount.ps1 on the Packer host'" - ] - } - - provisioner "powershell" { - elevated_user = var.winrm_username - elevated_password = var.winrm_password - scripts = ["./setup/vhd-mount.ps1"] - } } \ No newline at end of file From 257c90ef61793cfc8d3f433f221b6953482009cd Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Wed, 25 Feb 2026 14:20:23 +0000 Subject: [PATCH 31/38] admin priv for workflow --- .github/workflows/build-windows-vhd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 4f96c20..ff13db5 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -63,7 +63,7 @@ jobs: run: | $runnerUser = (whoami).Trim() Write-Host "Adding '$runnerUser' to Hyper-V Administrators group..." - # Add-LocalGroupMember -Group "Hyper-V Administrators" -Member $runnerUser -ErrorAction Stop + 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 } From 0ca132eb1af21f1c0fdbf82a49be9674724caf05 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Fri, 27 Feb 2026 03:47:10 +0000 Subject: [PATCH 32/38] removed mouting script --- .github/workflows/build-windows-vhd.yml | 12 +-- setup/vhd-mount.ps1 | 135 ------------------------ windows.auto.pkrvars.hcl | 23 ++-- windows.json.pkr.hcl | 10 -- 4 files changed, 13 insertions(+), 167 deletions(-) delete mode 100644 setup/vhd-mount.ps1 diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index ff13db5..5394d2a 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -140,17 +140,7 @@ jobs: 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: Validate VHD mounts (host) - shell: powershell - run: | - Write-Host "Validating VHD mount/dismount on host: $env:VHD_PATH" - if (-not (Test-Path -LiteralPath $env:VHD_PATH)) { - throw "VHD_PATH does not exist: $env:VHD_PATH" - } - - # This script accepts -VhdPath and will mount, set partition drive letter, then dismount. - powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\setup\vhd-mount.ps1" -VhdPath "$env:VHD_PATH" -DriveLetter "Z" + echo "VHD_PATH=$($vhd.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append - name: Upload Packer log diff --git a/setup/vhd-mount.ps1 b/setup/vhd-mount.ps1 deleted file mode 100644 index 3a01229..0000000 --- a/setup/vhd-mount.ps1 +++ /dev/null @@ -1,135 +0,0 @@ -# Minimal VHD mount -> set partition -> dismount script for CI host -# Usage: .\vhd-mount.ps1 [-VhdPath ] [-DriveLetter ] -# If no -VhdPath is provided the script searches for generated VHDs under output* dirs. - -param( - [string]$VhdPath, - [string]$DriveLetter = 'Z', - [switch]$ReadOnly = $true -) - -$VhdName = 'hybrid-minikube-windows-server.vhdx' -$relative = "..\output-windows-server\Virtual Hard Disks\$VhdName" -$vhdPath = Join-Path -Path $PSScriptRoot -ChildPath $relative - -# If caller provided an explicit VhdPath, prefer that -if ($VhdPath) { - $vhdPath = $VhdPath -} - -function Write-Log { param([string]$m) Write-Host "[vhd-mount] $m" } - -# Ensure running elevated -try { - $isAdmin = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} catch { - $isAdmin = $false -} -if (-not $isAdmin) { - Write-Error "This script must be run as Administrator." - exit 1 -} - -Write-Log "Looking for VHD: $vhdPath" -if (-not (Test-Path -Path $vhdPath)) { - Write-Log "Primary path not found; searching output directories (output*, output-*, output) for .vhd/.vhdx files..." - - function Find-GeneratedVhd { - param( - [string]$baseDir - ) - $searchPatterns = @("$baseDir\output*", "$baseDir\output-*", "$baseDir\output") - foreach ($pattern in $searchPatterns) { - try { - $found = Get-ChildItem -Path $pattern -Include "*.vhd","*.vhdx" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($found) { return $found.FullName } - } catch { - # ignore and continue - } - } - return $null - } - - # Try script root, then its parent directories (keeps script generic for local and CI use) - $found = Find-GeneratedVhd -baseDir $PSScriptRoot - if (-not $found) { - $parent = Split-Path -Path $PSScriptRoot -Parent - while ($parent -and -not $found) { - $found = Find-GeneratedVhd -baseDir $parent - if ($found) { break } - $next = Split-Path -Path $parent -Parent - if ($next -and $next -ne $parent) { $parent = $next } else { break } - } - } - - if (-not $found) { - Write-Error "VHD not found: $vhdPath" - exit 1 - } - - $vhdPath = $found - Write-Log "Found VHD: $vhdPath" -} - -try { - $mounted = $false - Write-Log "Mounting VHD (ReadOnly=$ReadOnly)..." - if ($ReadOnly) { - Mount-VHD -Path $vhdPath -ReadOnly -ErrorAction Stop - } else { - Mount-VHD -Path $vhdPath -ErrorAction Stop - } - $mounted = $true - - Start-Sleep -Seconds 2 - - # Resolve disk/partition info from the mounted VHD - $vhd = Get-VHD -Path $vhdPath -ErrorAction Stop - if ($null -eq $vhd.DiskNumber) { - throw "Mounted VHD has no DiskNumber (mount may have failed)." - } - - $disk = Get-Disk -Number $vhd.DiskNumber -ErrorAction Stop - Write-Log "Mounted disk: Number=$($disk.Number) Size=$($disk.Size) Style=$($disk.PartitionStyle)" - - # Choose the largest Basic Data partition (Windows volume) by GPT type or Basic type - $part = Get-Partition -DiskNumber $disk.Number | - Where-Object { ($_.GptType -and ($_.GptType -ieq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}')) -or ($_.Type -and ($_.Type -ieq 'Basic')) } | - Sort-Object Size -Descending | - Select-Object -First 1 - - if (-not $part) { - throw "No Basic/Windows partition found on disk $($disk.Number)" - } - - if (-not $part.DriveLetter) { - Write-Log "Assigning drive letter $DriveLetter to disk $($disk.Number) partition $($part.PartitionNumber)" - Set-Partition -DiskNumber $disk.Number -PartitionNumber $part.PartitionNumber -NewDriveLetter $DriveLetter -ErrorAction Stop - } else { - $DriveLetter = $part.DriveLetter - Write-Log "Partition already has drive letter: $DriveLetter" - } - - Write-Log "Sanity: Test-Path ${DriveLetter}:\\Windows => $(Test-Path "${DriveLetter}:\Windows")" - if (-not (Test-Path "${DriveLetter}:\Windows")) { - throw "Mounted volume does not look like Windows. Check partition selection." - } - - Write-Log "Top-level contents:" - Get-ChildItem "${DriveLetter}:\" -Force | Select-Object -First 20 | ForEach-Object { Write-Log (" - " + $_.Name) } - -} catch { - Write-Error "Operation failed: $_" - exit 1 -} finally { - if ($mounted) { - Write-Log "Dismounting VHD (best-effort)..." - try { - Dismount-VHD -Path $vhdPath -ErrorAction Stop - } catch { - Write-Error "Warning: failed to dismount VHD: $($_.Exception.Message)" - } - } -} - -Write-Log "Completed mount, assign, dismount." diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index fc01083..0d1b37f 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -20,30 +20,31 @@ guest_additions_mode = "disable" 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" -win_checksum = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" - -// server 2025 -//win_iso = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" -//win_checksum = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" kubernetes_version = "v1.35.0" -windows_version = "2022" +windows_version = "2025" containerd_version = "1.7.25" win_iso_urls = { "2022" = "https://go.microsoft.com/fwlink/p/?LinkID=2195280&clcid=0x409&culture=en-us&country=US" - "2025" = "https://go.microsoft.com/fwlink/?linkid=2345730&clcid=0x409&culture=en-us&country=us" + "2025" = "https://go.microsoft.com/fwlink/?linkid=2293312&clcid=0x409&culture=en-us&country=us" } win_iso_checksums = { "2022" = "3E4FA6D8507B554856FC9CA6079CC402DF11A8B79344871669F0251535255325" - "2025" = "7B052573BA7894C9924E3E87BA732CCD354D18CB75A883EFA9B900EA125BFD51" + "2025" = "D0EF4502E350E3C6C53C15B1B3020D38A5DED011BF04998E950720AC8579B23D" } // 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" \ No newline at end of file +// "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" \ No newline at end of file diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index f28b17d..5be5aed 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -35,16 +35,6 @@ variable "vm_memory" { description = "VM Memory" } -variable "win_iso" { - type = string - description = "Windows Server ISO location" -} - -variable "win_checksum" { - type = string - description = "Windows Server ISO checksum" -} - variable "win_iso_checksums" { type = map(string) default = {} From 8facdb03d3039977b2887789c4ef38cc9d52e753 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Fri, 27 Feb 2026 04:03:02 +0000 Subject: [PATCH 33/38] clean up --- .github/workflows/build-windows-vhd.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 5394d2a..3f11655 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -58,14 +58,14 @@ jobs: Write-Host "Hyper-V is enabled." }' -Wait - - name: Grant Hyper-V Administrators group membership - shell: powershell - 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: Grant Hyper-V Administrators group membership + # shell: powershell + # 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: powershell From 332b97a0ea017d1b2e75588dc317fd491ea8253f Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Thu, 23 Apr 2026 18:01:10 +0100 Subject: [PATCH 34/38] bump containerd to 2.2.3 and fix CNI path patching for 2.x config format --- setup/configure-vm.ps1 | 42 +++++++++++++++------------------------- windows.auto.pkrvars.hcl | 2 +- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/setup/configure-vm.ps1 b/setup/configure-vm.ps1 index 1878bbd..f5bdd52 100644 --- a/setup/configure-vm.ps1 +++ b/setup/configure-vm.ps1 @@ -211,35 +211,25 @@ 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) + $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) + $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" diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index 0d1b37f..d5b5958 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -23,7 +23,7 @@ winrm_password = "password" kubernetes_version = "v1.35.0" windows_version = "2025" -containerd_version = "1.7.25" +containerd_version = "2.2.3" win_iso_urls = { From 14025d110e978be6b60ef764f86febab7fa59d4a Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Thu, 10 Sep 2026 13:35:19 +0100 Subject: [PATCH 35/38] Update Kubernetes to v1.37.0 --- README.md | 4 ++-- windows.auto.pkrvars.hcl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 81a4613..9266a5e 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,10 @@ packer build -force -var-file="windows.auto.pkrvars.hcl" "windows.json.pkr.hcl" ``` To override versions locally: -Add -var 'windows_version=2022' -var 'kubernetes_version=v1.35.0' -var 'containerd_version=1.7.25' (or your desired values) to your Packer commands: +Add -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containerd_version=1.7.25' (or your desired values) to your Packer commands: ```powershell -packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.35.0' -var 'containerd_version=1.7.25' "windows.json.pkr.hcl" +packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containerd_version=1.7.25' "windows.json.pkr.hcl" ``` ### Default password diff --git a/windows.auto.pkrvars.hcl b/windows.auto.pkrvars.hcl index a4ace8f..4508997 100644 --- a/windows.auto.pkrvars.hcl +++ b/windows.auto.pkrvars.hcl @@ -21,7 +21,7 @@ winrm_username = "Administrator" winrm_password = "password" -kubernetes_version = "v1.35.0" +kubernetes_version = "v1.37.0" windows_version = "2025" containerd_version = "2.2.3" From 900a4c1f829a6b7812e1350fcba0016d950e8871 Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Thu, 10 Sep 2026 14:05:45 +0100 Subject: [PATCH 36/38] Fix duplicate Packer variables --- .gitignore | 1 + windows.json.pkr.hcl | 20 -------------------- 2 files changed, 1 insertion(+), 20 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8422cf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +packer_cache/ diff --git a/windows.json.pkr.hcl b/windows.json.pkr.hcl index 275b1b3..5be5aed 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -60,26 +60,6 @@ variable "containerd_version" { default = "" } -variable "win_iso_checksums" { - type = map(string) - default = {} -} - -variable "win_iso_urls" { - type = map(string) - default = {} -} - -variable "windows_version" { - type = string - default = "" -} - -variable "kubernetes_version" { - type = string - default = "" -} - variable "winrm_username" { type = string description = "winrm username" From c1aa594d7b57c7f4efd17d85adf4fc79d695704d Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Fri, 11 Sep 2026 14:27:02 +0100 Subject: [PATCH 37/38] Address PR feedback and preserve failed build diagnostics Fix Packer override arguments, validate CNI configuration keys, preserve original WinRM setup errors, and clarify workflow documentation. Always upload available Packer logs and retain failed provisioning VMs for inspection. --- .github/workflows/build-windows-vhd.yml | 20 ++++++++++---------- README.md | 23 +++++++++++++++++++++-- setup/configure-vm.ps1 | 6 ++++++ setup/enable-winrm.ps1 | 2 +- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 3f11655..1876c04 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -8,11 +8,11 @@ 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: @@ -119,17 +119,15 @@ jobs: $k = $env:KUBERNETES_VERSION $w = $env:WINDOWS_VERSION $c = $env:CONTAINERD_VERSION - $extra = '' - if ($k -and $k -ne '') { $extra += ('-var "kubernetes_version={0}" ' -f $k) } - if ($w -and $w -ne '') { $extra += ('-var "windows_version={0}" ' -f $w) } - if ($c -and $c -ne '') { $extra += ('-var "containerd_version={0}" ' -f $c) } + $extra = @() + if ($k -and $k -ne '') { $extra += @('-var', "kubernetes_version=$k") } + if ($w -and $w -ne '') { $extra += @('-var', "windows_version=$w") } + if ($c -and $c -ne '') { $extra += @('-var', "containerd_version=$c") } Write-Host "Starting Packer build…" - if ($extra -ne '') { + if ($extra.Count -gt 0) { Write-Host "Passing extra vars to Packer: $extra" - packer build -force --var-file=./windows.auto.pkrvars.hcl $extra windows.json.pkr.hcl - } else { - packer build -force --var-file=./windows.auto.pkrvars.hcl windows.json.pkr.hcl } + packer build -force -on-error=abort --var-file=./windows.auto.pkrvars.hcl @extra windows.json.pkr.hcl - name: Locate generated VHD shell: powershell @@ -144,10 +142,12 @@ jobs: - name: Upload Packer log + if: always() uses: actions/upload-artifact@v4 with: name: packer-log path: packer.log + if-no-files-found: warn - name: Upload VHD to Azure Blob Storage shell: powershell diff --git a/README.md b/README.md index 9266a5e..bd27a94 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ - Make sure the Hyper-V role is enabled - Install the Windows Assessment and Deployment Kit (32-bit version). -- Add the following location the the system path variable: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\x86\Oscdimg +- Add the following location to the system PATH environment variable: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\x86\Oscdimg -### On Powershell Administrator complete the following steps +### Run the following steps in PowerShell as Administrator 1. Clone the repo @@ -46,6 +46,25 @@ Add -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containe packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containerd_version=1.7.25' "windows.json.pkr.hcl" ``` +## Pipeline version overrides + +The workflow's optional version inputs override values in +`windows.auto.pkrvars.hcl`. Leave an input blank to use its var-file value, not to +resolve the latest release. + +## Diagnosing pipeline failures + +The GitHub Actions workflow attempts to upload the `packer-log` artifact even when +the build fails, warning if no log was created. Packer runs with `-on-error=abort`, +leaving the VM and build files in place after a provisioning failure so they can +be inspected on the self-hosted Hyper-V runner. + +Before starting another run, download the log and inspect the failed VM's console, +IP address, WinRM listener (TCP 5985), and Windows System/Windows Update events +around the failure time. The workflow uses `-force`, so a subsequent run can remove +preserved build output or conflict with the retained VM. After collecting +diagnostics, manually remove only the failed build's VM and associated files. + ### Default password |OS|username|password| diff --git a/setup/configure-vm.ps1 b/setup/configure-vm.ps1 index f5bdd52..ed70e22 100644 --- a/setup/configure-vm.ps1 +++ b/setup/configure-vm.ps1 @@ -215,6 +215,9 @@ function Initialize-ContainerdService { $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." @@ -222,6 +225,9 @@ function Initialize-ContainerdService { $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." diff --git a/setup/enable-winrm.ps1 b/setup/enable-winrm.ps1 index 6f750eb..2cd8716 100644 --- a/setup/enable-winrm.ps1 +++ b/setup/enable-winrm.ps1 @@ -13,7 +13,7 @@ function Write-Log { [ValidateSet('INFO','WARN','ERROR')][string]$Level = 'INFO' ) $line = "[$Level] $Message" - if ($Level -eq 'ERROR') { Write-Error $line } elseif ($Level -eq 'WARN') { Write-Warning $line } else { Write-Host $line } + if ($Level -eq 'ERROR') { Write-Error $line -ErrorAction Continue } elseif ($Level -eq 'WARN') { Write-Warning $line } else { Write-Host $line } } function Invoke-Step { From c369c8fc28e0709d8ce6740dd8758ae7c08fdf3d Mon Sep 17 00:00:00 2001 From: Bob Sira Date: Sat, 12 Sep 2026 23:00:10 +0100 Subject: [PATCH 38/38] Make Windows image builds unattended and isolate build publication Add unique build identities, shared host locking, isolated outputs and logs, and immediate DVD boot retries. Publish the canonical disk under a renewable Azure lease and provide a single-command test runner. --- .github/workflows/build-windows-vhd.yml | 151 +++------ .gitignore | 2 + README.md | 201 +++++++++--- scripts/Build-WindowsImage.ps1 | 253 +++++++++++++++ scripts/Publish-WindowsImage.ps1 | 311 +++++++++++++++++++ scripts/Test-WindowsImage.ps1 | 20 ++ tests/Build-WindowsImage.Tests.ps1 | 273 +++++++++++++++++ tests/Publish-WindowsImage.Tests.ps1 | 388 ++++++++++++++++++++++++ tests/fixtures/acquire-build-lock.ps1 | 11 + tests/fixtures/native-command.ps1 | 4 + tests/fixtures/packer.cmd | 3 + windows.json.pkr.hcl | 30 +- 12 files changed, 1498 insertions(+), 149 deletions(-) create mode 100644 scripts/Build-WindowsImage.ps1 create mode 100644 scripts/Publish-WindowsImage.ps1 create mode 100644 scripts/Test-WindowsImage.ps1 create mode 100644 tests/Build-WindowsImage.Tests.ps1 create mode 100644 tests/Publish-WindowsImage.Tests.ps1 create mode 100644 tests/fixtures/acquire-build-lock.ps1 create mode 100644 tests/fixtures/native-command.ps1 create mode 100644 tests/fixtures/packer.cmd diff --git a/.github/workflows/build-windows-vhd.yml b/.github/workflows/build-windows-vhd.yml index 1876c04..42253d5 100644 --- a/.github/workflows/build-windows-vhd.yml +++ b/.github/workflows/build-windows-vhd.yml @@ -20,6 +20,13 @@ on: required: false default: '' +permissions: + contents: read + +concurrency: + group: windows-node-image-builder + cancel-in-progress: false + jobs: build-upload-vhd: name: Build and Upload VHD @@ -28,13 +35,6 @@ 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 }} - PACKER_LOG: '1' - PACKER_LOG_PATH: ${{ github.workspace }}\packer.log - steps: - name: Checkout repository uses: actions/checkout@v4 @@ -46,120 +46,57 @@ jobs: Write-Host "Runner Labels: $env:RUNNER_LABELS" Write-Host "PowerShell Ed.: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion)" - - name: Ensure Hyper-V is enabled + - name: Ensure Packer and Azure CLI are installed shell: powershell run: | - Start-Process powershell -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: powershell - # 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: powershell - run: | - Start-Process powershell -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." - } - - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Host "Installing Azure CLI…" - choco install azure-cli -y - } else { - Write-Host "Azure CLI 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 } - }' -Wait - - - name: Initialize & Validate Packer - shell: powershell - 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 + id: build shell: powershell env: KUBERNETES_VERSION: ${{ inputs.kubernetes_version || '' }} WINDOWS_VERSION: ${{ inputs.windows_version || '' }} CONTAINERD_VERSION: ${{ inputs.containerd_version || '' }} run: | - $k = $env:KUBERNETES_VERSION - $w = $env:WINDOWS_VERSION - $c = $env:CONTAINERD_VERSION - $extra = @() - if ($k -and $k -ne '') { $extra += @('-var', "kubernetes_version=$k") } - if ($w -and $w -ne '') { $extra += @('-var', "windows_version=$w") } - if ($c -and $c -ne '') { $extra += @('-var', "containerd_version=$c") } - Write-Host "Starting Packer build…" - if ($extra.Count -gt 0) { - Write-Host "Passing extra vars to Packer: $extra" - } - packer build -force -on-error=abort --var-file=./windows.auto.pkrvars.hcl @extra windows.json.pkr.hcl + $ErrorActionPreference = 'Stop' + .\scripts\Build-WindowsImage.ps1 ` + -WindowsVersion $env:WINDOWS_VERSION ` + -KubernetesVersion $env:KUBERNETES_VERSION ` + -ContainerdVersion $env:CONTAINERD_VERSION - - name: Locate generated VHD + - 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 "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 - + $ErrorActionPreference = 'Stop' + .\scripts\Publish-WindowsImage.ps1 -ResultPath $env:BUILD_RESULT_PATH - - name: Upload Packer log - if: always() + - name: Upload build and publication logs + if: always() && steps.build.outputs.log_directory != '' uses: actions/upload-artifact@v4 with: - name: packer-log - path: packer.log - if-no-files-found: warn - - - name: Upload VHD to Azure Blob Storage - shell: powershell - run: | - Write-Host "Uploading $env:VHD_PATH to blob container '$env:AZURE_CONTAINER_NAME'…" - az storage blob upload ` - --only-show-errors ` - --no-progress ` - --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 + 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 index 8422cf7..484bd8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ packer_cache/ +build-logs/ +build-artifacts/ diff --git a/README.md b/README.md index bd27a94..629874a 100644 --- a/README.md +++ b/README.md @@ -2,71 +2,196 @@ ## Prerequisites -- Make sure the Hyper-V role is enabled -- Install the Windows Assessment and Deployment Kit (32-bit version). -- Add the following location to the system PATH environment variable: C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\x86\Oscdimg +- 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: -### Run the following steps in PowerShell as Administrator +```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. -1. Clone the repo +## Build locally + +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 ``` -1. 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' ``` -1. 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 ``` -1. 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. - -2. Then run the following commands: +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 -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" +.\scripts\Publish-WindowsImage.ps1 ` + -ResultPath (Join-Path $result.LogDirectory 'result.json') ``` -To override versions locally: -Add -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containerd_version=1.7.25' (or your desired values) to your Packer commands: +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 -packer build -force -var-file="windows.auto.pkrvars.hcl" -var 'windows_version=2022' -var 'kubernetes_version=v1.37.0' -var 'containerd_version=1.7.25' "windows.json.pkr.hcl" +.\scripts\Test-WindowsImage.ps1 ``` -## Pipeline version overrides +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: -The workflow's optional version inputs override values in -`windows.auto.pkrvars.hcl`. Leave an input blank to use its var-file value, not to -resolve the latest release. +```powershell +powershell.exe -NoProfile -File .\scripts\Test-WindowsImage.ps1 +``` -## Diagnosing pipeline failures +Check Packer formatting separately: -The GitHub Actions workflow attempts to upload the `packer-log` artifact even when -the build fails, warning if no log was created. Packer runs with `-on-error=abort`, -leaving the VM and build files in place after a provisioning failure so they can -be inspected on the self-hosted Hyper-V runner. +```powershell +packer fmt -check .\windows.json.pkr.hcl +``` -Before starting another run, download the log and inspect the failed VM's console, -IP address, WinRM listener (TCP 5985), and Windows System/Windows Update events -around the failure time. The workflow uses `-force`, so a subsequent run can remove -preserved build output or conflict with the retained VM. After collecting -diagnostics, manually remove only the failed build's VM and associated files. +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| +| 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/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.json.pkr.hcl b/windows.json.pkr.hcl index 5be5aed..dc67224 100644 --- a/windows.json.pkr.hcl +++ b/windows.json.pkr.hcl @@ -17,7 +17,27 @@ locals { variable "vm_name" { type = string - description = "Image name" + description = "Base image name; the build ID is appended to the temporary VM name" +} + +variable "build_id" { + type = string + 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 "output_directory" { + type = string + 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_cpus" { @@ -117,10 +137,12 @@ variable "guest_additions_mode" { } source "hyperv-iso" "windows-server" { - boot_command = ["a"] - boot_wait = "2s" + # 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 + 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