From 045089da73b9ad318605e4aae28728391f916fd5 Mon Sep 17 00:00:00 2001 From: Cal Luy Date: Tue, 26 May 2026 10:41:18 +1000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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