From 045089da73b9ad318605e4aae28728391f916fd5 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Tue, 26 May 2026 10:41:18 +1000 Subject: [PATCH 01/12] Upgrade Windows WorkerTools(noop) From edb3ecb3e571fb501a1b38b3070dfb0a9939444b Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Tue, 26 May 2026 12:25:35 +1000 Subject: [PATCH 02/12] Add Windows LTSC 2025 worker tools build --- docker-compose.build.yml | 8 + windows.ltsc2025/Dockerfile | 127 ++++ windows.ltsc2025/README.md | 49 ++ windows.ltsc2025/Tests.Dockerfile | 4 + windows.ltsc2025/scripts/dotnet-install.ps1 | 686 ++++++++++++++++++ windows.ltsc2025/scripts/run-tests.ps1 | 22 + windows.ltsc2025/scripts/update_path.cmd | 2 + .../spec/windows.ltsc2025.tests.ps1 | 132 ++++ 8 files changed, 1030 insertions(+) create mode 100644 windows.ltsc2025/Dockerfile create mode 100644 windows.ltsc2025/README.md create mode 100644 windows.ltsc2025/Tests.Dockerfile create mode 100644 windows.ltsc2025/scripts/dotnet-install.ps1 create mode 100644 windows.ltsc2025/scripts/run-tests.ps1 create mode 100644 windows.ltsc2025/scripts/update_path.cmd create mode 100644 windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 diff --git a/docker-compose.build.yml b/docker-compose.build.yml index e71b772..f2891be 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -52,3 +52,11 @@ services: - "./windows.ltsc2022:c:\\app" working_dir: "c:\\app" entrypoint: ["pwsh", "-file", "scripts/run-tests.ps1"] + + windows.ltsc2025: + build: windows.ltsc2025 + image: docker.packages.octopushq.com/octopusdeploy/worker-tools:${BUILD_NUMBER?err}-windows.ltsc2025 + volumes: + - "./windows.ltsc2025:c:\\app" + working_dir: "c:\\app" + entrypoint: ["pwsh", "-file", "scripts/run-tests.ps1"] diff --git a/windows.ltsc2025/Dockerfile b/windows.ltsc2025/Dockerfile new file mode 100644 index 0000000..bb4f729 --- /dev/null +++ b/windows.ltsc2025/Dockerfile @@ -0,0 +1,127 @@ +# escape=` + +FROM mcr.microsoft.com/dotnet/framework/runtime:4.8.1-20260512-windowsservercore-ltsc2025 +SHELL ["powershell", "-Command"] + +ARG 7Zip_Version=26.0.0 +ARG Argo_Cli_Version=3.4.2 +ARG Aws_Cli_Version=2.34.53 +ARG Aws_Iam_Authenticator_Version=0.7.16 +ARG Aws_Powershell_Version=5.0.218 +ARG Azure_Cli_Version=2.86.0 +ARG Azure_Powershell_Version=15.6.1 +ARG Eks_Cli_Version=0.226.0 +ARG Git_Version=2.54.0 +ARG Google_Cloud_Cli_Version=569.0.0 +ARG Helm_Version=4.2.0 +ARG Java_Jdk_Version=25.0.0.1 +ARG Kubectl_Version=1.36.1 +ARG Kubelogin_Version=0.2.17 +ARG Node_Version=24.16.0 +ARG Octopus_Cli_Legacy_Version=9.1.7 +ARG Octopus_Cli_Version=2.21.1 +ARG Octopus_Client_Version=21.11.2726 +ARG Powershell_Version=7.6.1 +ARG Python_Version=3.14.5 +ARG ScriptCs_Version=0.17.1 +ARG Terraform_Version=1.15.4 + +# Install Choco +RUN $ProgressPreference = 'SilentlyContinue'; ` + Set-ExecutionPolicy Bypass -Scope Process -Force; ` + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; ` + iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) + +# Install dotnet 8.0+ +RUN Invoke-WebRequest 'https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.ps1' -outFile 'dotnet-install.ps1'; ` + [Environment]::SetEnvironmentVariable('DOTNET_CLI_TELEMETRY_OPTOUT', '1', 'Machine'); ` + .\dotnet-install.ps1 -Channel '8.0'; ` + rm dotnet-install.ps1 + +# Install JDK +RUN choco install openjdk --allow-empty-checksums --y --no-progress --version $Env:Java_Jdk_Version; ` + Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1; ` + Update-SessionEnvironment + +# Install Azure CLI +RUN choco install azure-cli -y --version $Env:Azure_Cli_Version --no-progress + +# remove az cli warning - https://github.com/Azure/arm-deploy/issues/173 +RUN az config set bicep.use_binary_from_path=false + +# Install the AWS CLI +RUN choco install awscli -y --version $Env:Aws_Cli_Version --no-progress + +# Install the AWS IAM Authenticator +RUN choco install aws-iam-authenticator -y --version $Env:Aws_Iam_Authenticator_Version --no-progress + +# Install AWS PowerShell modules +# https://docs.aws.amazon.com/powershell/latest/userguide/pstools-getting-set-up-windows.html#ps-installing-awspowershellnetcore +RUN Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force; ` + Install-Module -name AWSPowerShell.NetCore -RequiredVersion $Env:Aws_Powershell_Version -Force + +# Install Azure PowerShell modules +# https://docs.microsoft.com/en-us/powershell/azure/install-az-ps?view=azps-3.6.1 +RUN Install-Module -Force -Name Az -AllowClobber -Scope AllUsers -MaximumVersion $Env:Azure_Powershell_Version; ` + Enable-AzureRmAlias -Scope LocalMachine + +# Install NodeJS +RUN choco install nodejs-lts -y --version $Env:Node_Version --no-progress + +# Install kubectl +RUN Invoke-WebRequest "https://dl.k8s.io/release/v${Env:Kubectl_Version}/bin/windows/amd64/kubectl.exe" -OutFile .\kubectl.exe; ` + mv .\kubectl.exe C:\Windows\system32\; + +# Install Kubelogin +RUN choco install azure-kubelogin --version $Env:Kubelogin_Version --no-progress -y + +# Install helm 3 +RUN Invoke-WebRequest "https://get.helm.sh/helm-v${Env:Helm_Version}-windows-amd64.zip" -OutFile helm.zip; ` + Expand-Archive helm.zip -DestinationPath helm; ` + mv helm\windows-amd64\helm.exe C:\Windows\system32\; ` + Remove-Item -Recurse -Force helm, helm.zip + +# Install Terraform +RUN choco install -y terraform --version $Env:Terraform_Version --no-progress + +# Install python +RUN choco install -y python3 --version $Env:Python_Version --no-progress; ` + Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1; ` + Update-SessionEnvironment + +# Install 7ZIP because gcloud +RUN choco install 7zip -y --version $Env:7Zip_Version --no-progress + +# Install gcloud +RUN choco install gcloudsdk -y --version $Env:Google_Cloud_Cli_Version --no-progress + +# Install gcloud kubectl auth +RUN gcloud components install gke-gcloud-auth-plugin --quiet + +# Install ScriptCS +RUN choco install scriptcs -y --version $Env:ScriptCs_Version --no-progress + +# Install Octopus CLI +RUN choco install octopus-cli -y --version $Env:Octopus_Cli_Version --no-progress + +# Install octo +RUN choco install octopustools -y --version $Env:Octopus_Cli_Legacy_Version --no-progress + +# Install Octopus Client +RUN Install-Package Octopus.Client -source https://www.nuget.org/api/v2 -SkipDependencies -Force -RequiredVersion $Env:Octopus_Client_Version + +# Install eksctl +RUN choco install eksctl -y --version $Env:Eks_Cli_Version --no-progress + +# Install Powershell Core +RUN choco install powershell-core --yes --version $Env:Powershell_Version --no-progress + +# Install Git +RUN choco install git.install --yes --version $Env:Git_Version --no-progress + +# Install Argo CD +RUN choco install argocd-cli --yes --version $Env:Argo_Cli_Version --no-progress + +# Update path for new tools +ADD .\scripts\update_path.cmd C:\update_path.cmd +RUN .\update_path.cmd; diff --git a/windows.ltsc2025/README.md b/windows.ltsc2025/README.md new file mode 100644 index 0000000..0ee3638 --- /dev/null +++ b/windows.ltsc2025/README.md @@ -0,0 +1,49 @@ +# Windows WorkerTools + +> Please note that we update this document periodically to match the latest version on DockerHub which is publicly available. +> This does not necessarily match the content of Dockerfiles in this repository, as they may contain changes that are not released yet. + +## Image Name + +`octopusdeploy/worker-tools` + +## Tags + +- `6.0.0-windows.ltsc2025` +- `6.0-windows.ltsc2025` +- `6-windows.ltsc2025` +- `windows.ltsc2025` + +## Digest + +`` + +## Base Image + +`mcr.microsoft.com/windows/servercore:ltsc2025-amd64` + +## Installed Software + +- Argo CD CLI 3.4.2 +- Aws CLI 2.34.53 +- Aws Iam Authenticator 0.7.16 +- Aws PowerShell Modules 5.0.218 +- Azure CLI 2.86.0 +- Azure PowerShell Modules 15.6.1 +- Eksctl 0.226.0 +- Google Cloud CLI 569.0.0 +- Google Cloud GKE auth plugin 569.0.0-0 +- Helm 4.2.0 +- Java Jdk 25.0.0.1 +- Kubectl 1.36.1 +- Kubelogin (azure-kubelogin) 0.2.17 +- Node 24.16.0 +- Octopus CLI Legacy 9.1.7 +- Octopus CLI 2.21.1 +- Octopus Client 21.11.2726 +- Powershell 7.6.1 +- Python 3.14.5 +- ScriptCs 0.17.1 +- Terraform 1.15.4 +- 7Zip 26.0 +- Chocolatey - Latest diff --git a/windows.ltsc2025/Tests.Dockerfile b/windows.ltsc2025/Tests.Dockerfile new file mode 100644 index 0000000..7b83e05 --- /dev/null +++ b/windows.ltsc2025/Tests.Dockerfile @@ -0,0 +1,4 @@ +ARG ContainerUnderTest=octopusdeploy/worker-tools + +FROM ${ContainerUnderTest} +SHELL ["powershell", "-Command"] \ No newline at end of file diff --git a/windows.ltsc2025/scripts/dotnet-install.ps1 b/windows.ltsc2025/scripts/dotnet-install.ps1 new file mode 100644 index 0000000..16e9be8 --- /dev/null +++ b/windows.ltsc2025/scripts/dotnet-install.ps1 @@ -0,0 +1,686 @@ +# +# Copyright (c) .NET Foundation and contributors. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +<# +.SYNOPSIS + Installs dotnet cli +.DESCRIPTION + Installs dotnet cli. If dotnet installation already exists in the given directory + it will update it only if the requested version differs from the one already installed. +.PARAMETER Channel + Default: LTS + Download from the Channel specified. Possible values: + - Current - most current release + - LTS - most current supported release + - 2-part version in a format A.B - represents a specific release + examples: 2.0, 1.0 + - Branch name + examples: release/2.0.0, Master + Note: The version parameter overrides the channel parameter. +.PARAMETER Version + Default: latest + Represents a build version on specific channel. Possible values: + - latest - most latest build on specific channel + - coherent - most latest coherent build on specific channel + coherent applies only to SDK downloads + - 3-part version in a format A.B.C - represents specific version of build + examples: 2.0.0-preview2-006120, 1.1.0 +.PARAMETER InstallDir + Default: %LocalAppData%\Microsoft\dotnet + Path to where to install dotnet. Note that binaries will be placed directly in a given directory. +.PARAMETER Architecture + Default: - this value represents currently running OS architecture + Architecture of dotnet binaries to be installed. + Possible values are: , amd64, x64, x86, arm64, arm +.PARAMETER SharedRuntime + This parameter is obsolete and may be removed in a future version of this script. + The recommended alternative is '-Runtime dotnet'. + Installs just the shared runtime bits, not the entire SDK. +.PARAMETER Runtime + Installs just a shared runtime, not the entire SDK. + Possible values: + - dotnet - the Microsoft.NETCore.App shared runtime + - aspnetcore - the Microsoft.AspNetCore.App shared runtime + - windowsdesktop - the Microsoft.WindowsDesktop.App shared runtime +.PARAMETER DryRun + If set it will not perform installation but instead display what command line to use to consistently install + currently requested version of dotnet cli. In example if you specify version 'latest' it will display a link + with specific version so that this command can be used deterministicly in a build script. + It also displays binaries location if you prefer to install or download it yourself. +.PARAMETER NoPath + By default this script will set environment variable PATH for the current process to the binaries folder inside installation folder. + If set it will display binaries location but not set any environment variable. +.PARAMETER Verbose + Displays diagnostics information. +.PARAMETER AzureFeed + Default: https://dotnetcli.azureedge.net/dotnet + This parameter typically is not changed by the user. + It allows changing the URL for the Azure feed used by this installer. +.PARAMETER UncachedFeed + This parameter typically is not changed by the user. + It allows changing the URL for the Uncached feed used by this installer. +.PARAMETER FeedCredential + Used as a query string to append to the Azure feed. + It allows changing the URL to use non-public blob storage accounts. +.PARAMETER ProxyAddress + If set, the installer will use the proxy when making web requests +.PARAMETER ProxyUseDefaultCredentials + Default: false + Use default credentials, when using proxy address. +.PARAMETER SkipNonVersionedFiles + Default: false + Skips installing non-versioned files if they already exist, such as dotnet.exe. +.PARAMETER NoCdn + Disable downloading from the Azure CDN, and use the uncached feed directly. +.PARAMETER JSonFile + Determines the SDK version from a user specified global.json file + Note: global.json must have a value for 'SDK:Version' +#> +[cmdletbinding()] +param( + [string]$Channel="LTS", + [string]$Version="Latest", + [string]$JSonFile, + [string]$InstallDir="", + [string]$Architecture="", + [ValidateSet("dotnet", "aspnetcore", "windowsdesktop", IgnoreCase = $false)] + [string]$Runtime, + [Obsolete("This parameter may be removed in a future version of this script. The recommended alternative is '-Runtime dotnet'.")] + [switch]$SharedRuntime, + [switch]$DryRun, + [switch]$NoPath, + [string]$AzureFeed="https://dotnetcli.azureedge.net/dotnet", + [string]$UncachedFeed="https://dotnetcli.blob.core.windows.net/dotnet", + [string]$FeedCredential, + [string]$ProxyAddress, + [switch]$ProxyUseDefaultCredentials, + [switch]$SkipNonVersionedFiles, + [switch]$NoCdn +) + +Set-StrictMode -Version Latest +$ErrorActionPreference="Stop" +$ProgressPreference="SilentlyContinue" + +if ($NoCdn) { + $AzureFeed = $UncachedFeed +} + +$BinFolderRelativePath="" + +if ($SharedRuntime -and (-not $Runtime)) { + $Runtime = "dotnet" +} + +# example path with regex: shared/1.0.0-beta-12345/somepath +$VersionRegEx="/\d+\.\d+[^/]+/" +$OverrideNonVersionedFiles = !$SkipNonVersionedFiles + +function Say($str) { + Write-Host "dotnet-install: $str" +} + +function Say-Verbose($str) { + Write-Verbose "dotnet-install: $str" +} + +function Say-Invocation($Invocation) { + $command = $Invocation.MyCommand; + $args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ") + Say-Verbose "$command $args" +} + +function Invoke-With-Retry([ScriptBlock]$ScriptBlock, [int]$MaxAttempts = 3, [int]$SecondsBetweenAttempts = 1) { + $Attempts = 0 + + while ($true) { + try { + return $ScriptBlock.Invoke() + } + catch { + $Attempts++ + if ($Attempts -lt $MaxAttempts) { + Start-Sleep $SecondsBetweenAttempts + } + else { + throw + } + } + } +} + +function Get-Machine-Architecture() { + Say-Invocation $MyInvocation + + # possible values: amd64, x64, x86, arm64, arm + return $ENV:PROCESSOR_ARCHITECTURE +} + +function Get-CLIArchitecture-From-Architecture([string]$Architecture) { + Say-Invocation $MyInvocation + + switch ($Architecture.ToLower()) { + { $_ -eq "" } { return Get-CLIArchitecture-From-Architecture $(Get-Machine-Architecture) } + { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "x64" } + { $_ -eq "x86" } { return "x86" } + { $_ -eq "arm" } { return "arm" } + { $_ -eq "arm64" } { return "arm64" } + default { throw "Architecture not supported. If you think this is a bug, report it at https://github.com/dotnet/sdk/issues" } + } +} + +# The version text returned from the feeds is a 1-line or 2-line string: +# For the SDK and the dotnet runtime (2 lines): +# Line 1: # commit_hash +# Line 2: # 4-part version +# For the aspnetcore runtime (1 line): +# Line 1: # 4-part version +function Get-Version-Info-From-Version-Text([string]$VersionText) { + Say-Invocation $MyInvocation + + $Data = -split $VersionText + + $VersionInfo = @{ + CommitHash = $(if ($Data.Count -gt 1) { $Data[0] }) + Version = $Data[-1] # last line is always the version number. + } + return $VersionInfo +} + +function Load-Assembly([string] $Assembly) { + try { + Add-Type -Assembly $Assembly | Out-Null + } + catch { + # On Nano Server, Powershell Core Edition is used. Add-Type is unable to resolve base class assemblies because they are not GAC'd. + # Loading the base class assemblies is not unnecessary as the types will automatically get resolved. + } +} + +function GetHTTPResponse([Uri] $Uri) +{ + Invoke-With-Retry( + { + + $HttpClient = $null + + try { + # HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet. + Load-Assembly -Assembly System.Net.Http + + if(-not $ProxyAddress) { + try { + # Despite no proxy being explicitly specified, we may still be behind a default proxy + $DefaultProxy = [System.Net.WebRequest]::DefaultWebProxy; + if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) { + $ProxyAddress = $DefaultProxy.GetProxy($Uri).OriginalString + $ProxyUseDefaultCredentials = $true + } + } catch { + # Eat the exception and move forward as the above code is an attempt + # at resolving the DefaultProxy that may not have been a problem. + $ProxyAddress = $null + Say-Verbose("Exception ignored: $_.Exception.Message - moving forward...") + } + } + + if($ProxyAddress) { + $HttpClientHandler = New-Object System.Net.Http.HttpClientHandler + $HttpClientHandler.Proxy = New-Object System.Net.WebProxy -Property @{Address=$ProxyAddress;UseDefaultCredentials=$ProxyUseDefaultCredentials} + $HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler + } + else { + + $HttpClient = New-Object System.Net.Http.HttpClient + } + # Default timeout for HttpClient is 100s. For a 50 MB download this assumes 500 KB/s average, any less will time out + # 20 minutes allows it to work over much slower connections. + $HttpClient.Timeout = New-TimeSpan -Minutes 20 + $Response = $HttpClient.GetAsync("${Uri}${FeedCredential}").Result + if (($Response -eq $null) -or (-not ($Response.IsSuccessStatusCode))) { + # The feed credential is potentially sensitive info. Do not log FeedCredential to console output. + $ErrorMsg = "Failed to download $Uri." + if ($Response -ne $null) { + $ErrorMsg += " $Response" + } + + throw $ErrorMsg + } + + return $Response + } + finally { + if ($HttpClient -ne $null) { + $HttpClient.Dispose() + } + } + }) +} + +function Get-Latest-Version-Info([string]$AzureFeed, [string]$Channel, [bool]$Coherent) { + Say-Invocation $MyInvocation + + $VersionFileUrl = $null + if ($Runtime -eq "dotnet") { + $VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version" + } + elseif ($Runtime -eq "aspnetcore") { + $VersionFileUrl = "$UncachedFeed/aspnetcore/Runtime/$Channel/latest.version" + } + # Currently, the WindowsDesktop runtime is manufactured with the .Net core runtime + elseif ($Runtime -eq "windowsdesktop") { + $VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version" + } + elseif (-not $Runtime) { + if ($Coherent) { + $VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.coherent.version" + } + else { + $VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.version" + } + } + else { + throw "Invalid value for `$Runtime" + } + try { + $Response = GetHTTPResponse -Uri $VersionFileUrl + } + catch { + throw "Could not resolve version information." + } + $StringContent = $Response.Content.ReadAsStringAsync().Result + + switch ($Response.Content.Headers.ContentType) { + { ($_ -eq "application/octet-stream") } { $VersionText = $StringContent } + { ($_ -eq "text/plain") } { $VersionText = $StringContent } + { ($_ -eq "text/plain; charset=UTF-8") } { $VersionText = $StringContent } + default { throw "``$Response.Content.Headers.ContentType`` is an unknown .version file content type." } + } + + $VersionInfo = Get-Version-Info-From-Version-Text $VersionText + + return $VersionInfo +} + +function Parse-Jsonfile-For-Version([string]$JSonFile) { + Say-Invocation $MyInvocation + + If (-Not (Test-Path $JSonFile)) { + throw "Unable to find '$JSonFile'" + } + try { + $JSonContent = Get-Content($JSonFile) -Raw | ConvertFrom-Json | Select-Object -expand "sdk" -ErrorAction SilentlyContinue + } + catch { + throw "Json file unreadable: '$JSonFile'" + } + if ($JSonContent) { + try { + $JSonContent.PSObject.Properties | ForEach-Object { + $PropertyName = $_.Name + if ($PropertyName -eq "version") { + $Version = $_.Value + Say-Verbose "Version = $Version" + } + } + } + catch { + throw "Unable to parse the SDK node in '$JSonFile'" + } + } + else { + throw "Unable to find the SDK node in '$JSonFile'" + } + If ($Version -eq $null) { + throw "Unable to find the SDK:version node in '$JSonFile'" + } + return $Version +} + +function Get-Specific-Version-From-Version([string]$AzureFeed, [string]$Channel, [string]$Version, [string]$JSonFile) { + Say-Invocation $MyInvocation + + if (-not $JSonFile) { + switch ($Version.ToLower()) { + { $_ -eq "latest" } { + $LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $False + return $LatestVersionInfo.Version + } + { $_ -eq "coherent" } { + $LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $True + return $LatestVersionInfo.Version + } + default { return $Version } + } + } + else { + return Parse-Jsonfile-For-Version $JSonFile + } +} + +function Get-Download-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) { + Say-Invocation $MyInvocation + + if ($Runtime -eq "dotnet") { + $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-runtime-$SpecificVersion-win-$CLIArchitecture.zip" + } + elseif ($Runtime -eq "aspnetcore") { + $PayloadURL = "$AzureFeed/aspnetcore/Runtime/$SpecificVersion/aspnetcore-runtime-$SpecificVersion-win-$CLIArchitecture.zip" + } + elseif ($Runtime -eq "windowsdesktop") { + $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/windowsdesktop-runtime-$SpecificVersion-win-$CLIArchitecture.zip" + } + elseif (-not $Runtime) { + $PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-sdk-$SpecificVersion-win-$CLIArchitecture.zip" + } + else { + throw "Invalid value for `$Runtime" + } + + Say-Verbose "Constructed primary named payload URL: $PayloadURL" + + return $PayloadURL +} + +function Get-LegacyDownload-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) { + Say-Invocation $MyInvocation + + if (-not $Runtime) { + $PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-dev-win-$CLIArchitecture.$SpecificVersion.zip" + } + elseif ($Runtime -eq "dotnet") { + $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-win-$CLIArchitecture.$SpecificVersion.zip" + } + else { + return $null + } + + Say-Verbose "Constructed legacy named payload URL: $PayloadURL" + + return $PayloadURL +} + +function Get-User-Share-Path() { + Say-Invocation $MyInvocation + + $InstallRoot = $env:DOTNET_INSTALL_DIR + if (!$InstallRoot) { + $InstallRoot = "$env:LocalAppData\Microsoft\dotnet" + } + return $InstallRoot +} + +function Resolve-Installation-Path([string]$InstallDir) { + Say-Invocation $MyInvocation + + if ($InstallDir -eq "") { + return Get-User-Share-Path + } + return $InstallDir +} + +function Is-Dotnet-Package-Installed([string]$InstallRoot, [string]$RelativePathToPackage, [string]$SpecificVersion) { + Say-Invocation $MyInvocation + + $DotnetPackagePath = Join-Path -Path $InstallRoot -ChildPath $RelativePathToPackage | Join-Path -ChildPath $SpecificVersion + Say-Verbose "Is-Dotnet-Package-Installed: DotnetPackagePath=$DotnetPackagePath" + return Test-Path $DotnetPackagePath -PathType Container +} + +function Get-Absolute-Path([string]$RelativeOrAbsolutePath) { + # Too much spam + # Say-Invocation $MyInvocation + + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath) +} + +function Get-Path-Prefix-With-Version($path) { + $match = [regex]::match($path, $VersionRegEx) + if ($match.Success) { + return $entry.FullName.Substring(0, $match.Index + $match.Length) + } + + return $null +} + +function Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package([System.IO.Compression.ZipArchive]$Zip, [string]$OutPath) { + Say-Invocation $MyInvocation + + $ret = @() + foreach ($entry in $Zip.Entries) { + $dir = Get-Path-Prefix-With-Version $entry.FullName + if ($dir -ne $null) { + $path = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $dir) + if (-Not (Test-Path $path -PathType Container)) { + $ret += $dir + } + } + } + + $ret = $ret | Sort-Object | Get-Unique + + $values = ($ret | foreach { "$_" }) -join ";" + Say-Verbose "Directories to unpack: $values" + + return $ret +} + +# Example zip content and extraction algorithm: +# Rule: files if extracted are always being extracted to the same relative path locally +# .\ +# a.exe # file does not exist locally, extract +# b.dll # file exists locally, override only if $OverrideFiles set +# aaa\ # same rules as for files +# ... +# abc\1.0.0\ # directory contains version and exists locally +# ... # do not extract content under versioned part +# abc\asd\ # same rules as for files +# ... +# def\ghi\1.0.1\ # directory contains version and does not exist locally +# ... # extract content +function Extract-Dotnet-Package([string]$ZipPath, [string]$OutPath) { + Say-Invocation $MyInvocation + + Load-Assembly -Assembly System.IO.Compression.FileSystem + Set-Variable -Name Zip + try { + $Zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) + + $DirectoriesToUnpack = Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package -Zip $Zip -OutPath $OutPath + + foreach ($entry in $Zip.Entries) { + $PathWithVersion = Get-Path-Prefix-With-Version $entry.FullName + if (($PathWithVersion -eq $null) -Or ($DirectoriesToUnpack -contains $PathWithVersion)) { + $DestinationPath = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $entry.FullName) + $DestinationDir = Split-Path -Parent $DestinationPath + $OverrideFiles=$OverrideNonVersionedFiles -Or (-Not (Test-Path $DestinationPath)) + if ((-Not $DestinationPath.EndsWith("\")) -And $OverrideFiles) { + New-Item -ItemType Directory -Force -Path $DestinationDir | Out-Null + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $DestinationPath, $OverrideNonVersionedFiles) + } + } + } + } + finally { + if ($Zip -ne $null) { + $Zip.Dispose() + } + } +} + +function DownloadFile($Source, [string]$OutPath) { + if ($Source -notlike "http*") { + # Using System.IO.Path.GetFullPath to get the current directory + # does not work in this context - $pwd gives the current directory + if (![System.IO.Path]::IsPathRooted($Source)) { + $Source = $(Join-Path -Path $pwd -ChildPath $Source) + } + $Source = Get-Absolute-Path $Source + Say "Copying file from $Source to $OutPath" + Copy-Item $Source $OutPath + return + } + + $Stream = $null + + try { + $Response = GetHTTPResponse -Uri $Source + $Stream = $Response.Content.ReadAsStreamAsync().Result + $File = [System.IO.File]::Create($OutPath) + $Stream.CopyTo($File) + $File.Close() + } + finally { + if ($Stream -ne $null) { + $Stream.Dispose() + } + } +} + +function Prepend-Sdk-InstallRoot-To-Path([string]$InstallRoot, [string]$BinFolderRelativePath) { + $BinPath = Get-Absolute-Path $(Join-Path -Path $InstallRoot -ChildPath $BinFolderRelativePath) + if (-Not $NoPath) { + $SuffixedBinPath = "$BinPath;" + if (-Not $env:path.Contains($SuffixedBinPath)) { + Say "Adding to current process PATH: `"$BinPath`". Note: This change will not be visible if PowerShell was run as a child process." + $env:path = $SuffixedBinPath + $env:path + } else { + Say-Verbose "Current process PATH already contains `"$BinPath`"" + } + } + else { + Say "Binaries of dotnet can be found in $BinPath" + } +} + +$CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture +$SpecificVersion = Get-Specific-Version-From-Version -AzureFeed $AzureFeed -Channel $Channel -Version $Version -JSonFile $JSonFile +$DownloadLink = Get-Download-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture +$LegacyDownloadLink = Get-LegacyDownload-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture + +$InstallRoot = Resolve-Installation-Path $InstallDir +Say-Verbose "InstallRoot: $InstallRoot" +$ScriptName = $MyInvocation.MyCommand.Name + +if ($DryRun) { + Say "Payload URLs:" + Say "Primary named payload URL: $DownloadLink" + if ($LegacyDownloadLink) { + Say "Legacy named payload URL: $LegacyDownloadLink" + } + $RepeatableCommand = ".\$ScriptName -Version `"$SpecificVersion`" -InstallDir `"$InstallRoot`" -Architecture `"$CLIArchitecture`"" + if ($Runtime -eq "dotnet") { + $RepeatableCommand+=" -Runtime `"dotnet`"" + } + elseif ($Runtime -eq "aspnetcore") { + $RepeatableCommand+=" -Runtime `"aspnetcore`"" + } + foreach ($key in $MyInvocation.BoundParameters.Keys) { + if (-not (@("Architecture","Channel","DryRun","InstallDir","Runtime","SharedRuntime","Version") -contains $key)) { + $RepeatableCommand+=" -$key `"$($MyInvocation.BoundParameters[$key])`"" + } + } + Say "Repeatable invocation: $RepeatableCommand" + exit 0 +} + +if ($Runtime -eq "dotnet") { + $assetName = ".NET Core Runtime" + $dotnetPackageRelativePath = "shared\Microsoft.NETCore.App" +} +elseif ($Runtime -eq "aspnetcore") { + $assetName = "ASP.NET Core Runtime" + $dotnetPackageRelativePath = "shared\Microsoft.AspNetCore.App" +} +elseif ($Runtime -eq "windowsdesktop") { + $assetName = ".NET Core Windows Desktop Runtime" + $dotnetPackageRelativePath = "shared\Microsoft.WindowsDesktop.App" +} +elseif (-not $Runtime) { + $assetName = ".NET Core SDK" + $dotnetPackageRelativePath = "sdk" +} +else { + throw "Invalid value for `$Runtime" +} + +# Check if the SDK version is already installed. +$isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion +if ($isAssetInstalled) { + Say "$assetName version $SpecificVersion is already installed." + Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath + exit 0 +} + +New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null + +$installDrive = $((Get-Item $InstallRoot).PSDrive.Name); +$diskInfo = Get-PSDrive -Name $installDrive +if ($diskInfo.Free / 1MB -le 100) { + Say "There is not enough disk space on drive ${installDrive}:" + exit 0 +} + +$ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) +Say-Verbose "Zip path: $ZipPath" + +$DownloadFailed = $false +Say "Downloading link: $DownloadLink" +try { + DownloadFile -Source $DownloadLink -OutPath $ZipPath +} +catch { + Say "Cannot download: $DownloadLink" + if ($LegacyDownloadLink) { + $DownloadLink = $LegacyDownloadLink + $ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) + Say-Verbose "Legacy zip path: $ZipPath" + Say "Downloading legacy link: $DownloadLink" + try { + DownloadFile -Source $DownloadLink -OutPath $ZipPath + } + catch { + Say "Cannot download: $DownloadLink" + $DownloadFailed = $true + } + } + else { + $DownloadFailed = $true + } +} + +if ($DownloadFailed) { + throw "Could not find/download: `"$assetName`" with version = $SpecificVersion`nRefer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" +} + +Say "Extracting zip from $DownloadLink" +Extract-Dotnet-Package -ZipPath $ZipPath -OutPath $InstallRoot + +# Check if the SDK version is installed; if not, fail the installation. +$isAssetInstalled = $false + +# if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. +if ($SpecificVersion -Match "rtm" -or $SpecificVersion -Match "servicing") { + $ReleaseVersion = $SpecificVersion.Split("-")[0] + Say-Verbose "Checking installation: version = $ReleaseVersion" + $isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $ReleaseVersion +} + +# Check if the SDK version is installed. +if (!$isAssetInstalled) { + Say-Verbose "Checking installation: version = $SpecificVersion" + $isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion +} + +if (!$isAssetInstalled) { + throw "`"$assetName`" with version = $SpecificVersion failed to install with an unknown error." +} + +Remove-Item $ZipPath + +Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath + +Say "Installation finished" +exit 0 diff --git a/windows.ltsc2025/scripts/run-tests.ps1 b/windows.ltsc2025/scripts/run-tests.ps1 new file mode 100644 index 0000000..ae30688 --- /dev/null +++ b/windows.ltsc2025/scripts/run-tests.ps1 @@ -0,0 +1,22 @@ +Write-Output "##teamcity[blockOpened name='Pester tests']" + +try { + Install-Module -Name "Pester" -MinimumVersion "5.0.2" -Force + + Import-Module -Name "Pester" + + Set-Location /app/spec + + Write-Output "Running Pester Tests" + $configuration = [PesterConfiguration]::Default + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputPath = '/app/spec/PesterTestResults.xml' + $configuration.TestResult.OutputFormat = 'NUnitXml' + $configuration.Run.PassThru = $true + $configuration.Output.Verbosity = "Detailed" + + Invoke-Pester -configuration $configuration +} catch { + exit 1 +} +Write-Output "##teamcity[blockClosed name='Pester tests']" diff --git a/windows.ltsc2025/scripts/update_path.cmd b/windows.ltsc2025/scripts/update_path.cmd new file mode 100644 index 0000000..177fcb4 --- /dev/null +++ b/windows.ltsc2025/scripts/update_path.cmd @@ -0,0 +1,2 @@ +setx /M path "%PATH%;C:\Users\ContainerAdministrator\AppData\Local\Microsoft\dotnet;C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.20.3.2503\lib\net462\Octopus.Client.dll" + diff --git a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 new file mode 100644 index 0000000..705bcd5 --- /dev/null +++ b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 @@ -0,0 +1,132 @@ +$ErrorActionPreference = "Continue" + +$pesterModules = @( Get-Module -Name "Pester"); +Write-Host 'Running tests with Pester v'+$($pesterModules[0].Version) + +Describe 'installed dependencies' { + It 'has powershell installed' { + $output = & powershell -command "`$PSVersionTable.PSVersion.ToString()" + $LASTEXITCODE | Should -be 0 + $output | Should -Match '^5\.1\.' + } + + It 'has Octopus.Client installed ' { + $expectedVersion = "21.11.2726" + Test-Path "C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.$expectedVersion\lib\net462\Octopus.Client.dll" | Should -Be $true + [Reflection.AssemblyName]::GetAssemblyName("C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.$expectedVersion\lib\net462\Octopus.Client.dll").Version.ToString() | Should -Match "$expectedVersion.0" + } + + It 'has dotnet installed' { + dotnet --version | Should -Match '8.0.\d+' + $LASTEXITCODE | Should -be 0 + } + + It 'has java installed' { + java -version 2>&1 | Select-String -Pattern '25' | Should -BeLike "*25*" + $LASTEXITCODE | Should -be 0 + } + + It 'has az installed' { + $output = (& az version) | convertfrom-json + $output.'azure-cli' | Should -Be '2.86.0' + $LASTEXITCODE | Should -be 0 + } + + It 'has az powershell module installed' { + (Get-Module Az -ListAvailable).Version.ToString() | should -be '15.6.1' + } + + It 'has aws cli installed' { + aws --version 2>&1 | Should -Match '2.34.53' + } + + It 'has aws powershell installed' { + Import-Module AWSPowerShell.NetCore + Get-AWSPowerShellVersion | Should -Match '5.0.218' + } + + # There is no version command for aws-iam-authenticator, so we just check for the installed version. + It 'has aws-iam-authenticator installed' { + Test-Path 'C:\ProgramData\chocolatey\bin\aws-iam-authenticator.exe' | should -be $true + } + + It 'has node installed' { + node --version | Should -Match '24.16.0' + $LASTEXITCODE | Should -be 0 + } + + It 'has kubectl installed' { + kubectl version --client | Select-String -Pattern "1.36.1" | Should -BeLike "Client Version: v1.36.1" + $LASTEXITCODE | Should -be 0 + } + + It 'has kubelogin installed' { + kubelogin --version | Select-Object -First 1 -Skip 1 | Should -match 'v0.2.17' + $LASTEXITCODE | Should -be 0 + } + + It 'has helm installed' { + helm version | Should -Match '4.2.0' + $LASTEXITCODE | Should -be 0 + } + + # If the terraform version is not the latest, then `terraform version` returns multiple lines and a non-zero return code + It 'has terraform installed' { + terraform version | Select-Object -First 1 | Should -Match '1.15.4' + } + + It 'has python installed' { + python --version | Should -Match '3.14.5' + $LASTEXITCODE | Should -be 0 + } + + It 'has gcloud installed' { + gcloud --version | Select-String -Pattern "569.0.0" | Should -BeLike "Google Cloud SDK 569.0.0" + $LASTEXITCODE | Should -be 0 + } + + # Version follows gcloud SDK bundled plugin; pin loosely to avoid drift. + It 'has gke-gcloud-auth-plugin installed' { + gke-gcloud-auth-plugin --version | Select -First 1 | Should -BeLike "Kubernetes v*" + $LASTEXITCODE | Should -be 0 + } + + It 'has octopus cli installed' { + octopus version | Should -Match '2.21.1' + $LASTEXITCODE | Should -be 0 + } + + It 'has octo installed' { + octo --version | Should -Match '9.1.7' + $LASTEXITCODE | Should -be 0 + } + + It 'has eksctl installed' { + eksctl version | Should -Match '0.226.0' + $LASTEXITCODE | Should -be 0 + } + + It 'has 7zip installed' { + $output = (& "C:\Program Files\7-Zip\7z.exe" --help) -join "`n" + $output | Should -Match '7-Zip 26.00' + $LASTEXITCODE | Should -be 0 + } + + It 'should have installed powershell core' { + $output = & pwsh --version + $LASTEXITCODE | Should -be 0 + $output | Should -Match '^PowerShell 7\.6\.1*' + } + + It 'should have installed git' { + $output = & git --version + $LASTEXITCODE | Should -be 0 + $output | Should -Match '2.54.0' + } + + It 'should have installed argo cli' { + $output = (& argocd version --client) -join "`n" + $LASTEXITCODE | Should -be 0 + $output | Should -Match '3.4.2' + } +} From 30ebd271cffe4ec15ed9317dccdd7ba997e953b4 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Tue, 26 May 2026 16:07:40 +1000 Subject: [PATCH 03/12] test using cicd --- .github/workflows/windows-ltsc2025-test.yml | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/windows-ltsc2025-test.yml diff --git a/.github/workflows/windows-ltsc2025-test.yml b/.github/workflows/windows-ltsc2025-test.yml new file mode 100644 index 0000000..7aa8929 --- /dev/null +++ b/.github/workflows/windows-ltsc2025-test.yml @@ -0,0 +1,57 @@ +# TEMPORARY: validates the Windows LTSC 2025 image build for PR #122. +# Remove this file before/after merging to main. +name: windows-ltsc2025-test + +on: + push: + branches: + - cal/md-1763-add-a-windowsltsc2025-workertools-build + workflow_dispatch: + +jobs: + build-and-test: + runs-on: windows-2025 + timeout-minutes: 120 + defaults: + run: + shell: pwsh + working-directory: windows.ltsc2025 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Show Docker info + run: | + docker version + docker info + + - name: Build worker-tools image + run: | + docker build ` + --tag octopusdeploy/worker-tools:ci-windows.ltsc2025 ` + . + + - name: Build tests image + run: | + docker build ` + --build-arg ContainerUnderTest=octopusdeploy/worker-tools:ci-windows.ltsc2025 ` + --tag worker-tools-tests:ci-windows.ltsc2025 ` + --file Tests.Dockerfile ` + . + + - name: Run Pester tests + run: | + docker run --rm ` + -v "${{ github.workspace }}\windows.ltsc2025:C:\app" ` + -w "C:\app" ` + worker-tools-tests:ci-windows.ltsc2025 ` + pwsh -File scripts/run-tests.ps1 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pester-results-windows-ltsc2025 + path: windows.ltsc2025/spec/PesterTestResults.xml + if-no-files-found: ignore From 124e7fe4dc284224ff0cc60aa83f9e6d744adb62 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 27 May 2026 15:23:38 +1000 Subject: [PATCH 04/12] update --- .github/workflows/windows-ltsc2025-test.yml | 51 ++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows-ltsc2025-test.yml b/.github/workflows/windows-ltsc2025-test.yml index 7aa8929..b84ee23 100644 --- a/.github/workflows/windows-ltsc2025-test.yml +++ b/.github/workflows/windows-ltsc2025-test.yml @@ -21,8 +21,57 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Show Docker info + - name: Ensure Docker daemon is running + shell: pwsh + working-directory: . run: | + # Workaround for actions/runner-images#13729: + # On Windows runners the docker service is sometimes left in the Stopped + # state at job start because the Hyper-V virtual switch fails to + # initialise during the runner's resume from a saved image. Starting + # the service (with retries) recovers the daemon. + $ErrorActionPreference = 'Continue' + + $ready = $false + for ($attempt = 1; $attempt -le 5; $attempt++) { + $svc = Get-Service -Name docker -ErrorAction SilentlyContinue + if (-not $svc) { + throw "docker service is not installed on this runner." + } + Write-Host "Attempt $attempt: docker service status is $($svc.Status)" + if ($svc.Status -ne 'Running') { + Start-Service -Name docker -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + } + + $deadline = (Get-Date).AddSeconds(60) + while ((Get-Date) -lt $deadline) { + docker version --format '{{.Server.Version}}' *> $null + if ($LASTEXITCODE -eq 0) { $ready = $true; break } + Start-Sleep -Seconds 2 + } + + if ($ready) { + Write-Host "Docker daemon is responsive." + break + } + + Write-Host "Daemon did not respond on attempt $attempt; restarting service." + Stop-Service -Name docker -Force -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + + if (-not $ready) { + Write-Host "::group::Docker service state" + Get-Service -Name docker | Format-List * + Write-Host "::endgroup::" + Write-Host "::group::Recent Application event log (docker)" + Get-EventLog -LogName Application -Source docker -Newest 20 -ErrorAction SilentlyContinue | + Format-List TimeGenerated, EntryType, Message + Write-Host "::endgroup::" + throw "Docker daemon did not become ready after 5 attempts." + } + docker version docker info From 15858832861f1670df8802a154e76e778d58b7b2 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 27 May 2026 16:00:13 +1000 Subject: [PATCH 05/12] parsing errors --- .github/workflows/windows-ltsc2025-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-ltsc2025-test.yml b/.github/workflows/windows-ltsc2025-test.yml index b84ee23..ec12bbc 100644 --- a/.github/workflows/windows-ltsc2025-test.yml +++ b/.github/workflows/windows-ltsc2025-test.yml @@ -38,7 +38,7 @@ jobs: if (-not $svc) { throw "docker service is not installed on this runner." } - Write-Host "Attempt $attempt: docker service status is $($svc.Status)" + Write-Host "Attempt ${attempt}: docker service status is $($svc.Status)" if ($svc.Status -ne 'Running') { Start-Service -Name docker -ErrorAction SilentlyContinue Start-Sleep -Seconds 3 From 48206eb8e8a58f8b20a746d49f7035b0855cb2bd Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Thu, 28 May 2026 09:37:33 +1000 Subject: [PATCH 06/12] pin helm back to helm 3 --- windows.ltsc2025/Dockerfile | 2 +- windows.ltsc2025/README.md | 2 +- windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/windows.ltsc2025/Dockerfile b/windows.ltsc2025/Dockerfile index bb4f729..01a2245 100644 --- a/windows.ltsc2025/Dockerfile +++ b/windows.ltsc2025/Dockerfile @@ -13,7 +13,7 @@ ARG Azure_Powershell_Version=15.6.1 ARG Eks_Cli_Version=0.226.0 ARG Git_Version=2.54.0 ARG Google_Cloud_Cli_Version=569.0.0 -ARG Helm_Version=4.2.0 +ARG Helm_Version=3.21.0 ARG Java_Jdk_Version=25.0.0.1 ARG Kubectl_Version=1.36.1 ARG Kubelogin_Version=0.2.17 diff --git a/windows.ltsc2025/README.md b/windows.ltsc2025/README.md index 0ee3638..59eb888 100644 --- a/windows.ltsc2025/README.md +++ b/windows.ltsc2025/README.md @@ -33,7 +33,7 @@ - Eksctl 0.226.0 - Google Cloud CLI 569.0.0 - Google Cloud GKE auth plugin 569.0.0-0 -- Helm 4.2.0 +- Helm 3.21.0 - Java Jdk 25.0.0.1 - Kubectl 1.36.1 - Kubelogin (azure-kubelogin) 0.2.17 diff --git a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 index 705bcd5..2263600 100644 --- a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 +++ b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 @@ -66,7 +66,7 @@ Describe 'installed dependencies' { } It 'has helm installed' { - helm version | Should -Match '4.2.0' + helm version | Should -Match '3.21.0' $LASTEXITCODE | Should -be 0 } From 85e8f5e0f05a0729fb68adb73e4a96b55eb04e1b Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Mon, 1 Jun 2026 11:46:46 +1000 Subject: [PATCH 07/12] Remove github workflow --- .github/workflows/windows-ltsc2025-test.yml | 106 -------------------- 1 file changed, 106 deletions(-) delete mode 100644 .github/workflows/windows-ltsc2025-test.yml diff --git a/.github/workflows/windows-ltsc2025-test.yml b/.github/workflows/windows-ltsc2025-test.yml deleted file mode 100644 index ec12bbc..0000000 --- a/.github/workflows/windows-ltsc2025-test.yml +++ /dev/null @@ -1,106 +0,0 @@ -# TEMPORARY: validates the Windows LTSC 2025 image build for PR #122. -# Remove this file before/after merging to main. -name: windows-ltsc2025-test - -on: - push: - branches: - - cal/md-1763-add-a-windowsltsc2025-workertools-build - workflow_dispatch: - -jobs: - build-and-test: - runs-on: windows-2025 - timeout-minutes: 120 - defaults: - run: - shell: pwsh - working-directory: windows.ltsc2025 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Ensure Docker daemon is running - shell: pwsh - working-directory: . - run: | - # Workaround for actions/runner-images#13729: - # On Windows runners the docker service is sometimes left in the Stopped - # state at job start because the Hyper-V virtual switch fails to - # initialise during the runner's resume from a saved image. Starting - # the service (with retries) recovers the daemon. - $ErrorActionPreference = 'Continue' - - $ready = $false - for ($attempt = 1; $attempt -le 5; $attempt++) { - $svc = Get-Service -Name docker -ErrorAction SilentlyContinue - if (-not $svc) { - throw "docker service is not installed on this runner." - } - Write-Host "Attempt ${attempt}: docker service status is $($svc.Status)" - if ($svc.Status -ne 'Running') { - Start-Service -Name docker -ErrorAction SilentlyContinue - Start-Sleep -Seconds 3 - } - - $deadline = (Get-Date).AddSeconds(60) - while ((Get-Date) -lt $deadline) { - docker version --format '{{.Server.Version}}' *> $null - if ($LASTEXITCODE -eq 0) { $ready = $true; break } - Start-Sleep -Seconds 2 - } - - if ($ready) { - Write-Host "Docker daemon is responsive." - break - } - - Write-Host "Daemon did not respond on attempt $attempt; restarting service." - Stop-Service -Name docker -Force -ErrorAction SilentlyContinue - Start-Sleep -Seconds 2 - } - - if (-not $ready) { - Write-Host "::group::Docker service state" - Get-Service -Name docker | Format-List * - Write-Host "::endgroup::" - Write-Host "::group::Recent Application event log (docker)" - Get-EventLog -LogName Application -Source docker -Newest 20 -ErrorAction SilentlyContinue | - Format-List TimeGenerated, EntryType, Message - Write-Host "::endgroup::" - throw "Docker daemon did not become ready after 5 attempts." - } - - docker version - docker info - - - name: Build worker-tools image - run: | - docker build ` - --tag octopusdeploy/worker-tools:ci-windows.ltsc2025 ` - . - - - name: Build tests image - run: | - docker build ` - --build-arg ContainerUnderTest=octopusdeploy/worker-tools:ci-windows.ltsc2025 ` - --tag worker-tools-tests:ci-windows.ltsc2025 ` - --file Tests.Dockerfile ` - . - - - name: Run Pester tests - run: | - docker run --rm ` - -v "${{ github.workspace }}\windows.ltsc2025:C:\app" ` - -w "C:\app" ` - worker-tools-tests:ci-windows.ltsc2025 ` - pwsh -File scripts/run-tests.ps1 - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: pester-results-windows-ltsc2025 - path: windows.ltsc2025/spec/PesterTestResults.xml - if-no-files-found: ignore From 7a87a9119f66a36f0c929e07f46464f7f7d06c18 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Mon, 1 Jun 2026 11:54:09 +1000 Subject: [PATCH 08/12] Small fixes --- windows.ltsc2025/scripts/update_path.cmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows.ltsc2025/scripts/update_path.cmd b/windows.ltsc2025/scripts/update_path.cmd index 177fcb4..322e4b2 100644 --- a/windows.ltsc2025/scripts/update_path.cmd +++ b/windows.ltsc2025/scripts/update_path.cmd @@ -1,2 +1,2 @@ -setx /M path "%PATH%;C:\Users\ContainerAdministrator\AppData\Local\Microsoft\dotnet;C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.20.3.2503\lib\net462\Octopus.Client.dll" +setx /M path "%PATH%;C:\Users\ContainerAdministrator\AppData\Local\Microsoft\dotnet;C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.21.11.2726\lib\net462\Octopus.Client.dll" From b75c7467a8f54065cb812cf9c511a4000850429b Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 3 Jun 2026 11:29:26 +1000 Subject: [PATCH 09/12] update base image to sdk 10 --- windows.ltsc2025/Dockerfile | 17 +++++++++-------- .../spec/windows.ltsc2025.tests.ps1 | 7 ++++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/windows.ltsc2025/Dockerfile b/windows.ltsc2025/Dockerfile index 01a2245..3618b5b 100644 --- a/windows.ltsc2025/Dockerfile +++ b/windows.ltsc2025/Dockerfile @@ -1,6 +1,6 @@ # escape=` -FROM mcr.microsoft.com/dotnet/framework/runtime:4.8.1-20260512-windowsservercore-ltsc2025 +FROM mcr.microsoft.com/dotnet/sdk:10.0.300-windowsservercore-ltsc2025 SHELL ["powershell", "-Command"] ARG 7Zip_Version=26.0.0 @@ -32,11 +32,13 @@ RUN $ProgressPreference = 'SilentlyContinue'; ` [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; ` iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1')) -# Install dotnet 8.0+ -RUN Invoke-WebRequest 'https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.ps1' -outFile 'dotnet-install.ps1'; ` - [Environment]::SetEnvironmentVariable('DOTNET_CLI_TELEMETRY_OPTOUT', '1', 'Machine'); ` - .\dotnet-install.ps1 -Channel '8.0'; ` - rm dotnet-install.ps1 +# Disable .NET CLI telemetry +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 + +# Verify .NET Framework 4.8.1 is present (inbox on Windows Server 2025) for ScriptCs and Octopus.Client +RUN $release = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Release).Release; ` + Write-Host "Detected .NET Framework Release: $release"; ` + if ($release -lt 533320) { throw ".NET Framework 4.8.1 not found (Release $release)" } # Install JDK RUN choco install openjdk --allow-empty-checksums --y --no-progress --version $Env:Java_Jdk_Version; ` @@ -113,8 +115,7 @@ RUN Install-Package Octopus.Client -source https://www.nuget.org/api/v2 -SkipDep # Install eksctl RUN choco install eksctl -y --version $Env:Eks_Cli_Version --no-progress -# Install Powershell Core -RUN choco install powershell-core --yes --version $Env:Powershell_Version --no-progress +# PowerShell 7 is provided by the .NET SDK base image (see Powershell_Version ARG) # Install Git RUN choco install git.install --yes --version $Env:Git_Version --no-progress diff --git a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 index 2263600..74d3200 100644 --- a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 +++ b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 @@ -17,10 +17,15 @@ Describe 'installed dependencies' { } It 'has dotnet installed' { - dotnet --version | Should -Match '8.0.\d+' + dotnet --version | Should -Match '10.0.\d+' $LASTEXITCODE | Should -be 0 } + It 'has .NET Framework 4.8.1 installed' { + $release = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Release).Release + $release | Should -BeGreaterOrEqual 533320 + } + It 'has java installed' { java -version 2>&1 | Select-String -Pattern '25' | Should -BeLike "*25*" $LASTEXITCODE | Should -be 0 From 67e5e1f1f2d8f1ef2494d22aafd9d91b28059584 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 3 Jun 2026 13:12:11 +1000 Subject: [PATCH 10/12] Remove dotnet-install and early verification check in Dockerfile --- windows.ltsc2025/Dockerfile | 5 - windows.ltsc2025/scripts/dotnet-install.ps1 | 686 -------------------- 2 files changed, 691 deletions(-) delete mode 100644 windows.ltsc2025/scripts/dotnet-install.ps1 diff --git a/windows.ltsc2025/Dockerfile b/windows.ltsc2025/Dockerfile index 3618b5b..604dd79 100644 --- a/windows.ltsc2025/Dockerfile +++ b/windows.ltsc2025/Dockerfile @@ -35,11 +35,6 @@ RUN $ProgressPreference = 'SilentlyContinue'; ` # Disable .NET CLI telemetry ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 -# Verify .NET Framework 4.8.1 is present (inbox on Windows Server 2025) for ScriptCs and Octopus.Client -RUN $release = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' -Name Release).Release; ` - Write-Host "Detected .NET Framework Release: $release"; ` - if ($release -lt 533320) { throw ".NET Framework 4.8.1 not found (Release $release)" } - # Install JDK RUN choco install openjdk --allow-empty-checksums --y --no-progress --version $Env:Java_Jdk_Version; ` Import-Module $env:ChocolateyInstall\helpers\chocolateyProfile.psm1; ` diff --git a/windows.ltsc2025/scripts/dotnet-install.ps1 b/windows.ltsc2025/scripts/dotnet-install.ps1 deleted file mode 100644 index 16e9be8..0000000 --- a/windows.ltsc2025/scripts/dotnet-install.ps1 +++ /dev/null @@ -1,686 +0,0 @@ -# -# Copyright (c) .NET Foundation and contributors. All rights reserved. -# Licensed under the MIT license. See LICENSE file in the project root for full license information. -# - -<# -.SYNOPSIS - Installs dotnet cli -.DESCRIPTION - Installs dotnet cli. If dotnet installation already exists in the given directory - it will update it only if the requested version differs from the one already installed. -.PARAMETER Channel - Default: LTS - Download from the Channel specified. Possible values: - - Current - most current release - - LTS - most current supported release - - 2-part version in a format A.B - represents a specific release - examples: 2.0, 1.0 - - Branch name - examples: release/2.0.0, Master - Note: The version parameter overrides the channel parameter. -.PARAMETER Version - Default: latest - Represents a build version on specific channel. Possible values: - - latest - most latest build on specific channel - - coherent - most latest coherent build on specific channel - coherent applies only to SDK downloads - - 3-part version in a format A.B.C - represents specific version of build - examples: 2.0.0-preview2-006120, 1.1.0 -.PARAMETER InstallDir - Default: %LocalAppData%\Microsoft\dotnet - Path to where to install dotnet. Note that binaries will be placed directly in a given directory. -.PARAMETER Architecture - Default: - this value represents currently running OS architecture - Architecture of dotnet binaries to be installed. - Possible values are: , amd64, x64, x86, arm64, arm -.PARAMETER SharedRuntime - This parameter is obsolete and may be removed in a future version of this script. - The recommended alternative is '-Runtime dotnet'. - Installs just the shared runtime bits, not the entire SDK. -.PARAMETER Runtime - Installs just a shared runtime, not the entire SDK. - Possible values: - - dotnet - the Microsoft.NETCore.App shared runtime - - aspnetcore - the Microsoft.AspNetCore.App shared runtime - - windowsdesktop - the Microsoft.WindowsDesktop.App shared runtime -.PARAMETER DryRun - If set it will not perform installation but instead display what command line to use to consistently install - currently requested version of dotnet cli. In example if you specify version 'latest' it will display a link - with specific version so that this command can be used deterministicly in a build script. - It also displays binaries location if you prefer to install or download it yourself. -.PARAMETER NoPath - By default this script will set environment variable PATH for the current process to the binaries folder inside installation folder. - If set it will display binaries location but not set any environment variable. -.PARAMETER Verbose - Displays diagnostics information. -.PARAMETER AzureFeed - Default: https://dotnetcli.azureedge.net/dotnet - This parameter typically is not changed by the user. - It allows changing the URL for the Azure feed used by this installer. -.PARAMETER UncachedFeed - This parameter typically is not changed by the user. - It allows changing the URL for the Uncached feed used by this installer. -.PARAMETER FeedCredential - Used as a query string to append to the Azure feed. - It allows changing the URL to use non-public blob storage accounts. -.PARAMETER ProxyAddress - If set, the installer will use the proxy when making web requests -.PARAMETER ProxyUseDefaultCredentials - Default: false - Use default credentials, when using proxy address. -.PARAMETER SkipNonVersionedFiles - Default: false - Skips installing non-versioned files if they already exist, such as dotnet.exe. -.PARAMETER NoCdn - Disable downloading from the Azure CDN, and use the uncached feed directly. -.PARAMETER JSonFile - Determines the SDK version from a user specified global.json file - Note: global.json must have a value for 'SDK:Version' -#> -[cmdletbinding()] -param( - [string]$Channel="LTS", - [string]$Version="Latest", - [string]$JSonFile, - [string]$InstallDir="", - [string]$Architecture="", - [ValidateSet("dotnet", "aspnetcore", "windowsdesktop", IgnoreCase = $false)] - [string]$Runtime, - [Obsolete("This parameter may be removed in a future version of this script. The recommended alternative is '-Runtime dotnet'.")] - [switch]$SharedRuntime, - [switch]$DryRun, - [switch]$NoPath, - [string]$AzureFeed="https://dotnetcli.azureedge.net/dotnet", - [string]$UncachedFeed="https://dotnetcli.blob.core.windows.net/dotnet", - [string]$FeedCredential, - [string]$ProxyAddress, - [switch]$ProxyUseDefaultCredentials, - [switch]$SkipNonVersionedFiles, - [switch]$NoCdn -) - -Set-StrictMode -Version Latest -$ErrorActionPreference="Stop" -$ProgressPreference="SilentlyContinue" - -if ($NoCdn) { - $AzureFeed = $UncachedFeed -} - -$BinFolderRelativePath="" - -if ($SharedRuntime -and (-not $Runtime)) { - $Runtime = "dotnet" -} - -# example path with regex: shared/1.0.0-beta-12345/somepath -$VersionRegEx="/\d+\.\d+[^/]+/" -$OverrideNonVersionedFiles = !$SkipNonVersionedFiles - -function Say($str) { - Write-Host "dotnet-install: $str" -} - -function Say-Verbose($str) { - Write-Verbose "dotnet-install: $str" -} - -function Say-Invocation($Invocation) { - $command = $Invocation.MyCommand; - $args = (($Invocation.BoundParameters.Keys | foreach { "-$_ `"$($Invocation.BoundParameters[$_])`"" }) -join " ") - Say-Verbose "$command $args" -} - -function Invoke-With-Retry([ScriptBlock]$ScriptBlock, [int]$MaxAttempts = 3, [int]$SecondsBetweenAttempts = 1) { - $Attempts = 0 - - while ($true) { - try { - return $ScriptBlock.Invoke() - } - catch { - $Attempts++ - if ($Attempts -lt $MaxAttempts) { - Start-Sleep $SecondsBetweenAttempts - } - else { - throw - } - } - } -} - -function Get-Machine-Architecture() { - Say-Invocation $MyInvocation - - # possible values: amd64, x64, x86, arm64, arm - return $ENV:PROCESSOR_ARCHITECTURE -} - -function Get-CLIArchitecture-From-Architecture([string]$Architecture) { - Say-Invocation $MyInvocation - - switch ($Architecture.ToLower()) { - { $_ -eq "" } { return Get-CLIArchitecture-From-Architecture $(Get-Machine-Architecture) } - { ($_ -eq "amd64") -or ($_ -eq "x64") } { return "x64" } - { $_ -eq "x86" } { return "x86" } - { $_ -eq "arm" } { return "arm" } - { $_ -eq "arm64" } { return "arm64" } - default { throw "Architecture not supported. If you think this is a bug, report it at https://github.com/dotnet/sdk/issues" } - } -} - -# The version text returned from the feeds is a 1-line or 2-line string: -# For the SDK and the dotnet runtime (2 lines): -# Line 1: # commit_hash -# Line 2: # 4-part version -# For the aspnetcore runtime (1 line): -# Line 1: # 4-part version -function Get-Version-Info-From-Version-Text([string]$VersionText) { - Say-Invocation $MyInvocation - - $Data = -split $VersionText - - $VersionInfo = @{ - CommitHash = $(if ($Data.Count -gt 1) { $Data[0] }) - Version = $Data[-1] # last line is always the version number. - } - return $VersionInfo -} - -function Load-Assembly([string] $Assembly) { - try { - Add-Type -Assembly $Assembly | Out-Null - } - catch { - # On Nano Server, Powershell Core Edition is used. Add-Type is unable to resolve base class assemblies because they are not GAC'd. - # Loading the base class assemblies is not unnecessary as the types will automatically get resolved. - } -} - -function GetHTTPResponse([Uri] $Uri) -{ - Invoke-With-Retry( - { - - $HttpClient = $null - - try { - # HttpClient is used vs Invoke-WebRequest in order to support Nano Server which doesn't support the Invoke-WebRequest cmdlet. - Load-Assembly -Assembly System.Net.Http - - if(-not $ProxyAddress) { - try { - # Despite no proxy being explicitly specified, we may still be behind a default proxy - $DefaultProxy = [System.Net.WebRequest]::DefaultWebProxy; - if($DefaultProxy -and (-not $DefaultProxy.IsBypassed($Uri))) { - $ProxyAddress = $DefaultProxy.GetProxy($Uri).OriginalString - $ProxyUseDefaultCredentials = $true - } - } catch { - # Eat the exception and move forward as the above code is an attempt - # at resolving the DefaultProxy that may not have been a problem. - $ProxyAddress = $null - Say-Verbose("Exception ignored: $_.Exception.Message - moving forward...") - } - } - - if($ProxyAddress) { - $HttpClientHandler = New-Object System.Net.Http.HttpClientHandler - $HttpClientHandler.Proxy = New-Object System.Net.WebProxy -Property @{Address=$ProxyAddress;UseDefaultCredentials=$ProxyUseDefaultCredentials} - $HttpClient = New-Object System.Net.Http.HttpClient -ArgumentList $HttpClientHandler - } - else { - - $HttpClient = New-Object System.Net.Http.HttpClient - } - # Default timeout for HttpClient is 100s. For a 50 MB download this assumes 500 KB/s average, any less will time out - # 20 minutes allows it to work over much slower connections. - $HttpClient.Timeout = New-TimeSpan -Minutes 20 - $Response = $HttpClient.GetAsync("${Uri}${FeedCredential}").Result - if (($Response -eq $null) -or (-not ($Response.IsSuccessStatusCode))) { - # The feed credential is potentially sensitive info. Do not log FeedCredential to console output. - $ErrorMsg = "Failed to download $Uri." - if ($Response -ne $null) { - $ErrorMsg += " $Response" - } - - throw $ErrorMsg - } - - return $Response - } - finally { - if ($HttpClient -ne $null) { - $HttpClient.Dispose() - } - } - }) -} - -function Get-Latest-Version-Info([string]$AzureFeed, [string]$Channel, [bool]$Coherent) { - Say-Invocation $MyInvocation - - $VersionFileUrl = $null - if ($Runtime -eq "dotnet") { - $VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version" - } - elseif ($Runtime -eq "aspnetcore") { - $VersionFileUrl = "$UncachedFeed/aspnetcore/Runtime/$Channel/latest.version" - } - # Currently, the WindowsDesktop runtime is manufactured with the .Net core runtime - elseif ($Runtime -eq "windowsdesktop") { - $VersionFileUrl = "$UncachedFeed/Runtime/$Channel/latest.version" - } - elseif (-not $Runtime) { - if ($Coherent) { - $VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.coherent.version" - } - else { - $VersionFileUrl = "$UncachedFeed/Sdk/$Channel/latest.version" - } - } - else { - throw "Invalid value for `$Runtime" - } - try { - $Response = GetHTTPResponse -Uri $VersionFileUrl - } - catch { - throw "Could not resolve version information." - } - $StringContent = $Response.Content.ReadAsStringAsync().Result - - switch ($Response.Content.Headers.ContentType) { - { ($_ -eq "application/octet-stream") } { $VersionText = $StringContent } - { ($_ -eq "text/plain") } { $VersionText = $StringContent } - { ($_ -eq "text/plain; charset=UTF-8") } { $VersionText = $StringContent } - default { throw "``$Response.Content.Headers.ContentType`` is an unknown .version file content type." } - } - - $VersionInfo = Get-Version-Info-From-Version-Text $VersionText - - return $VersionInfo -} - -function Parse-Jsonfile-For-Version([string]$JSonFile) { - Say-Invocation $MyInvocation - - If (-Not (Test-Path $JSonFile)) { - throw "Unable to find '$JSonFile'" - } - try { - $JSonContent = Get-Content($JSonFile) -Raw | ConvertFrom-Json | Select-Object -expand "sdk" -ErrorAction SilentlyContinue - } - catch { - throw "Json file unreadable: '$JSonFile'" - } - if ($JSonContent) { - try { - $JSonContent.PSObject.Properties | ForEach-Object { - $PropertyName = $_.Name - if ($PropertyName -eq "version") { - $Version = $_.Value - Say-Verbose "Version = $Version" - } - } - } - catch { - throw "Unable to parse the SDK node in '$JSonFile'" - } - } - else { - throw "Unable to find the SDK node in '$JSonFile'" - } - If ($Version -eq $null) { - throw "Unable to find the SDK:version node in '$JSonFile'" - } - return $Version -} - -function Get-Specific-Version-From-Version([string]$AzureFeed, [string]$Channel, [string]$Version, [string]$JSonFile) { - Say-Invocation $MyInvocation - - if (-not $JSonFile) { - switch ($Version.ToLower()) { - { $_ -eq "latest" } { - $LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $False - return $LatestVersionInfo.Version - } - { $_ -eq "coherent" } { - $LatestVersionInfo = Get-Latest-Version-Info -AzureFeed $AzureFeed -Channel $Channel -Coherent $True - return $LatestVersionInfo.Version - } - default { return $Version } - } - } - else { - return Parse-Jsonfile-For-Version $JSonFile - } -} - -function Get-Download-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) { - Say-Invocation $MyInvocation - - if ($Runtime -eq "dotnet") { - $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-runtime-$SpecificVersion-win-$CLIArchitecture.zip" - } - elseif ($Runtime -eq "aspnetcore") { - $PayloadURL = "$AzureFeed/aspnetcore/Runtime/$SpecificVersion/aspnetcore-runtime-$SpecificVersion-win-$CLIArchitecture.zip" - } - elseif ($Runtime -eq "windowsdesktop") { - $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/windowsdesktop-runtime-$SpecificVersion-win-$CLIArchitecture.zip" - } - elseif (-not $Runtime) { - $PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-sdk-$SpecificVersion-win-$CLIArchitecture.zip" - } - else { - throw "Invalid value for `$Runtime" - } - - Say-Verbose "Constructed primary named payload URL: $PayloadURL" - - return $PayloadURL -} - -function Get-LegacyDownload-Link([string]$AzureFeed, [string]$SpecificVersion, [string]$CLIArchitecture) { - Say-Invocation $MyInvocation - - if (-not $Runtime) { - $PayloadURL = "$AzureFeed/Sdk/$SpecificVersion/dotnet-dev-win-$CLIArchitecture.$SpecificVersion.zip" - } - elseif ($Runtime -eq "dotnet") { - $PayloadURL = "$AzureFeed/Runtime/$SpecificVersion/dotnet-win-$CLIArchitecture.$SpecificVersion.zip" - } - else { - return $null - } - - Say-Verbose "Constructed legacy named payload URL: $PayloadURL" - - return $PayloadURL -} - -function Get-User-Share-Path() { - Say-Invocation $MyInvocation - - $InstallRoot = $env:DOTNET_INSTALL_DIR - if (!$InstallRoot) { - $InstallRoot = "$env:LocalAppData\Microsoft\dotnet" - } - return $InstallRoot -} - -function Resolve-Installation-Path([string]$InstallDir) { - Say-Invocation $MyInvocation - - if ($InstallDir -eq "") { - return Get-User-Share-Path - } - return $InstallDir -} - -function Is-Dotnet-Package-Installed([string]$InstallRoot, [string]$RelativePathToPackage, [string]$SpecificVersion) { - Say-Invocation $MyInvocation - - $DotnetPackagePath = Join-Path -Path $InstallRoot -ChildPath $RelativePathToPackage | Join-Path -ChildPath $SpecificVersion - Say-Verbose "Is-Dotnet-Package-Installed: DotnetPackagePath=$DotnetPackagePath" - return Test-Path $DotnetPackagePath -PathType Container -} - -function Get-Absolute-Path([string]$RelativeOrAbsolutePath) { - # Too much spam - # Say-Invocation $MyInvocation - - return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($RelativeOrAbsolutePath) -} - -function Get-Path-Prefix-With-Version($path) { - $match = [regex]::match($path, $VersionRegEx) - if ($match.Success) { - return $entry.FullName.Substring(0, $match.Index + $match.Length) - } - - return $null -} - -function Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package([System.IO.Compression.ZipArchive]$Zip, [string]$OutPath) { - Say-Invocation $MyInvocation - - $ret = @() - foreach ($entry in $Zip.Entries) { - $dir = Get-Path-Prefix-With-Version $entry.FullName - if ($dir -ne $null) { - $path = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $dir) - if (-Not (Test-Path $path -PathType Container)) { - $ret += $dir - } - } - } - - $ret = $ret | Sort-Object | Get-Unique - - $values = ($ret | foreach { "$_" }) -join ";" - Say-Verbose "Directories to unpack: $values" - - return $ret -} - -# Example zip content and extraction algorithm: -# Rule: files if extracted are always being extracted to the same relative path locally -# .\ -# a.exe # file does not exist locally, extract -# b.dll # file exists locally, override only if $OverrideFiles set -# aaa\ # same rules as for files -# ... -# abc\1.0.0\ # directory contains version and exists locally -# ... # do not extract content under versioned part -# abc\asd\ # same rules as for files -# ... -# def\ghi\1.0.1\ # directory contains version and does not exist locally -# ... # extract content -function Extract-Dotnet-Package([string]$ZipPath, [string]$OutPath) { - Say-Invocation $MyInvocation - - Load-Assembly -Assembly System.IO.Compression.FileSystem - Set-Variable -Name Zip - try { - $Zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) - - $DirectoriesToUnpack = Get-List-Of-Directories-And-Versions-To-Unpack-From-Dotnet-Package -Zip $Zip -OutPath $OutPath - - foreach ($entry in $Zip.Entries) { - $PathWithVersion = Get-Path-Prefix-With-Version $entry.FullName - if (($PathWithVersion -eq $null) -Or ($DirectoriesToUnpack -contains $PathWithVersion)) { - $DestinationPath = Get-Absolute-Path $(Join-Path -Path $OutPath -ChildPath $entry.FullName) - $DestinationDir = Split-Path -Parent $DestinationPath - $OverrideFiles=$OverrideNonVersionedFiles -Or (-Not (Test-Path $DestinationPath)) - if ((-Not $DestinationPath.EndsWith("\")) -And $OverrideFiles) { - New-Item -ItemType Directory -Force -Path $DestinationDir | Out-Null - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $DestinationPath, $OverrideNonVersionedFiles) - } - } - } - } - finally { - if ($Zip -ne $null) { - $Zip.Dispose() - } - } -} - -function DownloadFile($Source, [string]$OutPath) { - if ($Source -notlike "http*") { - # Using System.IO.Path.GetFullPath to get the current directory - # does not work in this context - $pwd gives the current directory - if (![System.IO.Path]::IsPathRooted($Source)) { - $Source = $(Join-Path -Path $pwd -ChildPath $Source) - } - $Source = Get-Absolute-Path $Source - Say "Copying file from $Source to $OutPath" - Copy-Item $Source $OutPath - return - } - - $Stream = $null - - try { - $Response = GetHTTPResponse -Uri $Source - $Stream = $Response.Content.ReadAsStreamAsync().Result - $File = [System.IO.File]::Create($OutPath) - $Stream.CopyTo($File) - $File.Close() - } - finally { - if ($Stream -ne $null) { - $Stream.Dispose() - } - } -} - -function Prepend-Sdk-InstallRoot-To-Path([string]$InstallRoot, [string]$BinFolderRelativePath) { - $BinPath = Get-Absolute-Path $(Join-Path -Path $InstallRoot -ChildPath $BinFolderRelativePath) - if (-Not $NoPath) { - $SuffixedBinPath = "$BinPath;" - if (-Not $env:path.Contains($SuffixedBinPath)) { - Say "Adding to current process PATH: `"$BinPath`". Note: This change will not be visible if PowerShell was run as a child process." - $env:path = $SuffixedBinPath + $env:path - } else { - Say-Verbose "Current process PATH already contains `"$BinPath`"" - } - } - else { - Say "Binaries of dotnet can be found in $BinPath" - } -} - -$CLIArchitecture = Get-CLIArchitecture-From-Architecture $Architecture -$SpecificVersion = Get-Specific-Version-From-Version -AzureFeed $AzureFeed -Channel $Channel -Version $Version -JSonFile $JSonFile -$DownloadLink = Get-Download-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture -$LegacyDownloadLink = Get-LegacyDownload-Link -AzureFeed $AzureFeed -SpecificVersion $SpecificVersion -CLIArchitecture $CLIArchitecture - -$InstallRoot = Resolve-Installation-Path $InstallDir -Say-Verbose "InstallRoot: $InstallRoot" -$ScriptName = $MyInvocation.MyCommand.Name - -if ($DryRun) { - Say "Payload URLs:" - Say "Primary named payload URL: $DownloadLink" - if ($LegacyDownloadLink) { - Say "Legacy named payload URL: $LegacyDownloadLink" - } - $RepeatableCommand = ".\$ScriptName -Version `"$SpecificVersion`" -InstallDir `"$InstallRoot`" -Architecture `"$CLIArchitecture`"" - if ($Runtime -eq "dotnet") { - $RepeatableCommand+=" -Runtime `"dotnet`"" - } - elseif ($Runtime -eq "aspnetcore") { - $RepeatableCommand+=" -Runtime `"aspnetcore`"" - } - foreach ($key in $MyInvocation.BoundParameters.Keys) { - if (-not (@("Architecture","Channel","DryRun","InstallDir","Runtime","SharedRuntime","Version") -contains $key)) { - $RepeatableCommand+=" -$key `"$($MyInvocation.BoundParameters[$key])`"" - } - } - Say "Repeatable invocation: $RepeatableCommand" - exit 0 -} - -if ($Runtime -eq "dotnet") { - $assetName = ".NET Core Runtime" - $dotnetPackageRelativePath = "shared\Microsoft.NETCore.App" -} -elseif ($Runtime -eq "aspnetcore") { - $assetName = "ASP.NET Core Runtime" - $dotnetPackageRelativePath = "shared\Microsoft.AspNetCore.App" -} -elseif ($Runtime -eq "windowsdesktop") { - $assetName = ".NET Core Windows Desktop Runtime" - $dotnetPackageRelativePath = "shared\Microsoft.WindowsDesktop.App" -} -elseif (-not $Runtime) { - $assetName = ".NET Core SDK" - $dotnetPackageRelativePath = "sdk" -} -else { - throw "Invalid value for `$Runtime" -} - -# Check if the SDK version is already installed. -$isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion -if ($isAssetInstalled) { - Say "$assetName version $SpecificVersion is already installed." - Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath - exit 0 -} - -New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null - -$installDrive = $((Get-Item $InstallRoot).PSDrive.Name); -$diskInfo = Get-PSDrive -Name $installDrive -if ($diskInfo.Free / 1MB -le 100) { - Say "There is not enough disk space on drive ${installDrive}:" - exit 0 -} - -$ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) -Say-Verbose "Zip path: $ZipPath" - -$DownloadFailed = $false -Say "Downloading link: $DownloadLink" -try { - DownloadFile -Source $DownloadLink -OutPath $ZipPath -} -catch { - Say "Cannot download: $DownloadLink" - if ($LegacyDownloadLink) { - $DownloadLink = $LegacyDownloadLink - $ZipPath = [System.IO.Path]::combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName()) - Say-Verbose "Legacy zip path: $ZipPath" - Say "Downloading legacy link: $DownloadLink" - try { - DownloadFile -Source $DownloadLink -OutPath $ZipPath - } - catch { - Say "Cannot download: $DownloadLink" - $DownloadFailed = $true - } - } - else { - $DownloadFailed = $true - } -} - -if ($DownloadFailed) { - throw "Could not find/download: `"$assetName`" with version = $SpecificVersion`nRefer to: https://aka.ms/dotnet-os-lifecycle for information on .NET Core support" -} - -Say "Extracting zip from $DownloadLink" -Extract-Dotnet-Package -ZipPath $ZipPath -OutPath $InstallRoot - -# Check if the SDK version is installed; if not, fail the installation. -$isAssetInstalled = $false - -# if the version contains "RTM" or "servicing"; check if a 'release-type' SDK version is installed. -if ($SpecificVersion -Match "rtm" -or $SpecificVersion -Match "servicing") { - $ReleaseVersion = $SpecificVersion.Split("-")[0] - Say-Verbose "Checking installation: version = $ReleaseVersion" - $isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $ReleaseVersion -} - -# Check if the SDK version is installed. -if (!$isAssetInstalled) { - Say-Verbose "Checking installation: version = $SpecificVersion" - $isAssetInstalled = Is-Dotnet-Package-Installed -InstallRoot $InstallRoot -RelativePathToPackage $dotnetPackageRelativePath -SpecificVersion $SpecificVersion -} - -if (!$isAssetInstalled) { - throw "`"$assetName`" with version = $SpecificVersion failed to install with an unknown error." -} - -Remove-Item $ZipPath - -Prepend-Sdk-InstallRoot-To-Path -InstallRoot $InstallRoot -BinFolderRelativePath $BinFolderRelativePath - -Say "Installation finished" -exit 0 From ec69d980a3fb60d3d645b23bb09337372d7fb96a Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 3 Jun 2026 13:15:44 +1000 Subject: [PATCH 11/12] update README.md --- windows.ltsc2025/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/windows.ltsc2025/README.md b/windows.ltsc2025/README.md index 59eb888..5b5b3db 100644 --- a/windows.ltsc2025/README.md +++ b/windows.ltsc2025/README.md @@ -24,6 +24,8 @@ ## Installed Software +- .NET SDK 10.0.300 +- .NET Framework 4.8.1 - Argo CD CLI 3.4.2 - Aws CLI 2.34.53 - Aws Iam Authenticator 0.7.16 From ba449dd5a1ef6849df3dddbb8eee11a5db029760 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Wed, 3 Jun 2026 16:57:00 +1000 Subject: [PATCH 12/12] align to recommended list --- windows.ltsc2025/Dockerfile | 32 +++++++++++++++---- windows.ltsc2025/README.md | 13 +++++--- windows.ltsc2025/scripts/update_path.cmd | 2 +- .../spec/windows.ltsc2025.tests.ps1 | 27 ++++++++++++++-- 4 files changed, 59 insertions(+), 15 deletions(-) diff --git a/windows.ltsc2025/Dockerfile b/windows.ltsc2025/Dockerfile index 604dd79..d85c425 100644 --- a/windows.ltsc2025/Dockerfile +++ b/windows.ltsc2025/Dockerfile @@ -6,24 +6,28 @@ SHELL ["powershell", "-Command"] ARG 7Zip_Version=26.0.0 ARG Argo_Cli_Version=3.4.2 ARG Aws_Cli_Version=2.34.53 -ARG Aws_Iam_Authenticator_Version=0.7.16 +ARG Aws_Iam_Authenticator_Version=0.7.17 ARG Aws_Powershell_Version=5.0.218 ARG Azure_Cli_Version=2.86.0 ARG Azure_Powershell_Version=15.6.1 ARG Eks_Cli_Version=0.226.0 ARG Git_Version=2.54.0 -ARG Google_Cloud_Cli_Version=569.0.0 -ARG Helm_Version=3.21.0 +ARG Google_Cloud_Cli_Version=566.0.0 +ARG Helm_Version=3.20.1 ARG Java_Jdk_Version=25.0.0.1 -ARG Kubectl_Version=1.36.1 +ARG Kubectl_Version=1.35.1 +ARG Kubectl_Versions=1.32.12,1.33.8,1.34.4,1.35.1 ARG Kubelogin_Version=0.2.17 ARG Node_Version=24.16.0 +ARG Nuget_Cli_Version=7.6.0 ARG Octopus_Cli_Legacy_Version=9.1.7 ARG Octopus_Cli_Version=2.21.1 ARG Octopus_Client_Version=21.11.2726 ARG Powershell_Version=7.6.1 ARG Python_Version=3.14.5 ARG ScriptCs_Version=0.17.1 +ARG Service_Fabric_Version=10.1.2338.9590 +ARG Service_Fabric_Sdk_Version=7.1.2338 ARG Terraform_Version=1.15.4 # Install Choco @@ -66,8 +70,11 @@ RUN Install-Module -Force -Name Az -AllowClobber -Scope AllUsers -MaximumVersion RUN choco install nodejs-lts -y --version $Env:Node_Version --no-progress # Install kubectl -RUN Invoke-WebRequest "https://dl.k8s.io/release/v${Env:Kubectl_Version}/bin/windows/amd64/kubectl.exe" -OutFile .\kubectl.exe; ` - mv .\kubectl.exe C:\Windows\system32\; +RUN New-Item -ItemType Directory -Path C:\kubectl -Force | Out-Null; ` + foreach ($v in ($Env:Kubectl_Versions -split ',')) { ` + Invoke-WebRequest "https://dl.k8s.io/release/v$v/bin/windows/amd64/kubectl.exe" -OutFile "C:\kubectl\kubectl-$v.exe"; ` + } ` + Copy-Item "C:\kubectl\kubectl-$($Env:Kubectl_Version).exe" C:\Windows\system32\kubectl.exe; # Install Kubelogin RUN choco install azure-kubelogin --version $Env:Kubelogin_Version --no-progress -y @@ -110,6 +117,19 @@ RUN Install-Package Octopus.Client -source https://www.nuget.org/api/v2 -SkipDep # Install eksctl RUN choco install eksctl -y --version $Env:Eks_Cli_Version --no-progress +# Install NuGet CLI +RUN choco install nuget.commandline -y --version $Env:Nuget_Cli_Version --no-progress + +# Install Microsoft Service Fabric runtime +RUN Invoke-WebRequest "https://download.microsoft.com/download/b/8/a/b8a2fb98-0ec1-41e5-be98-9d8b5abf7856/MicrosoftServiceFabric.${Env:Service_Fabric_Version}.exe" -OutFile sf-runtime.exe; ` + Start-Process .\sf-runtime.exe -ArgumentList '/accepteula', '/force', '/quiet' -Wait; ` + Remove-Item sf-runtime.exe + +# Install Microsoft Service Fabric SDK +RUN Invoke-WebRequest "https://download.microsoft.com/download/b/8/a/b8a2fb98-0ec1-41e5-be98-9d8b5abf7856/MicrosoftServiceFabricSDK.${Env:Service_Fabric_Sdk_Version}.msi" -OutFile sf-sdk.msi; ` + Start-Process msiexec.exe -ArgumentList '/i', 'sf-sdk.msi', '/quiet', '/norestart' -Wait; ` + Remove-Item sf-sdk.msi + # PowerShell 7 is provided by the .NET SDK base image (see Powershell_Version ARG) # Install Git diff --git a/windows.ltsc2025/README.md b/windows.ltsc2025/README.md index 5b5b3db..567eb02 100644 --- a/windows.ltsc2025/README.md +++ b/windows.ltsc2025/README.md @@ -28,18 +28,21 @@ - .NET Framework 4.8.1 - Argo CD CLI 3.4.2 - Aws CLI 2.34.53 -- Aws Iam Authenticator 0.7.16 +- Aws Iam Authenticator 0.7.17 - Aws PowerShell Modules 5.0.218 - Azure CLI 2.86.0 - Azure PowerShell Modules 15.6.1 - Eksctl 0.226.0 -- Google Cloud CLI 569.0.0 -- Google Cloud GKE auth plugin 569.0.0-0 -- Helm 3.21.0 +- Google Cloud CLI 566.0.0 +- Google Cloud GKE auth plugin 566.0.0-0 +- Helm 3.20.1 - Java Jdk 25.0.0.1 -- Kubectl 1.36.1 +- Kubectl 1.35.1 (also 1.34.4, 1.33.8, 1.32.12 in C:\kubectl) - Kubelogin (azure-kubelogin) 0.2.17 +- Microsoft Service Fabric 10.1.2338.9590 +- Microsoft Service Fabric SDK 7.1.2338 - Node 24.16.0 +- Nuget CLI 7.6.0 - Octopus CLI Legacy 9.1.7 - Octopus CLI 2.21.1 - Octopus Client 21.11.2726 diff --git a/windows.ltsc2025/scripts/update_path.cmd b/windows.ltsc2025/scripts/update_path.cmd index 322e4b2..48b17e4 100644 --- a/windows.ltsc2025/scripts/update_path.cmd +++ b/windows.ltsc2025/scripts/update_path.cmd @@ -1,2 +1,2 @@ -setx /M path "%PATH%;C:\Users\ContainerAdministrator\AppData\Local\Microsoft\dotnet;C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.21.11.2726\lib\net462\Octopus.Client.dll" +setx /M path "%PATH%;C:\kubectl;C:\Program Files\PackageManagement\NuGet\Packages\Octopus.Client.21.11.2726\lib\net462\Octopus.Client.dll" diff --git a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 index 74d3200..b23e7df 100644 --- a/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 +++ b/windows.ltsc2025/spec/windows.ltsc2025.tests.ps1 @@ -61,17 +61,24 @@ Describe 'installed dependencies' { } It 'has kubectl installed' { - kubectl version --client | Select-String -Pattern "1.36.1" | Should -BeLike "Client Version: v1.36.1" + kubectl version --client | Select-String -Pattern "1.35.1" | Should -BeLike "Client Version: v1.35.1" $LASTEXITCODE | Should -be 0 } + It 'has multiple kubectl versions available' { + foreach ($v in @('1.32.12', '1.33.8', '1.34.4', '1.35.1')) { + Test-Path "C:\kubectl\kubectl-$v.exe" | Should -Be $true + (& "C:\kubectl\kubectl-$v.exe" version --client) | Select-String -Pattern $v | Should -BeLike "*v$v" + } + } + It 'has kubelogin installed' { kubelogin --version | Select-Object -First 1 -Skip 1 | Should -match 'v0.2.17' $LASTEXITCODE | Should -be 0 } It 'has helm installed' { - helm version | Should -Match '3.21.0' + helm version | Should -Match '3.20.1' $LASTEXITCODE | Should -be 0 } @@ -86,7 +93,7 @@ Describe 'installed dependencies' { } It 'has gcloud installed' { - gcloud --version | Select-String -Pattern "569.0.0" | Should -BeLike "Google Cloud SDK 569.0.0" + gcloud --version | Select-String -Pattern "566.0.0" | Should -BeLike "Google Cloud SDK 566.0.0" $LASTEXITCODE | Should -be 0 } @@ -134,4 +141,18 @@ Describe 'installed dependencies' { $LASTEXITCODE | Should -be 0 $output | Should -Match '3.4.2' } + + It 'has nuget cli installed' { + $output = & nuget help + $LASTEXITCODE | Should -be 0 + $output | Select-Object -First 1 | Should -Match '7.6.0' + } + + It 'has Microsoft Service Fabric runtime installed' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Service Fabric' -Name FabricVersion).FabricVersion | Should -Match '10.1.2338' + } + + It 'has Microsoft Service Fabric SDK installed' { + Test-Path 'C:\Program Files\Microsoft SDKs\Service Fabric' | Should -Be $true + } }