$ErrorActionPreference = "Stop" $Separator = "--------------------------------------------------------------------------------------------------------------------------------" $DefaultDownloadFolder = "C:\Downloads" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 ##################################################################################################### # Start-Setup ##################################################################################################### <# .SYNOPSIS Sets up the context for the build script to work. .DESCRIPTION Prints out disk size information and sets up the downloaded content folder. #> function Start-Setup { Write-Host $Separator Trace-Message "Starting installation" Trace-Message "Checking disk space" gwmi win32_logicaldisk | Format-Table DeviceId, MediaType, {$_.Size /1GB}, {$_.FreeSpace /1GB} Trace-Message "Creating download location C:\Downloads" New-Item -Path $DefaultDownloadFolder -ItemType Container -ErrorAction SilentlyContinue } ##################################################################################################### # Stop-Setup ##################################################################################################### <# .SYNOPSIS Shuts down the build script. .DESCRIPTION Deletes the downloaded content folder. Cleans the contents of the TEMP folder. Prints out a list of the installed software on the image by querying WMIC. .PARAMETER PreserveDownloads Preserves the downloaded content folder. .PARAMETER PreserveTemp Preserves the temp folder contents. #> function Stop-Setup { param ( [Parameter(Mandatory=$false)] [switch]$PreserveDownloads, [Parameter(Mandatory=$false)] [switch]$PreserveTemp ) Write-Host $Separator if (-not $PreserveDownloads) { Trace-Message "Deleting download location C:\Downloads" Remove-Item -Path "C:\Downloads" -Recurse -ErrorAction SilentlyContinue } if (-not $PreserveTemp) { Reset-TempFolders } Trace-Message "Checking disk space" gwmi win32_logicaldisk | Format-Table DeviceId, MediaType, {$_.Size /1GB}, {$_.FreeSpace /1GB} Trace-Message "Listing installed 32-bit software" Get-ItemProperty HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* | Select-Object DisplayName,DisplayVersion,Publisher,InstallDate | Sort-Object DisplayName,DisplayVersion,Publisher,InstallDate |out-string -width 300 Trace-Message "Listing installed 64-bit software" Get-ItemProperty HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\* | Select-Object DisplayName,DisplayVersion,Publisher,InstallDate | Sort-Object DisplayName,DisplayVersion,Publisher,InstallDate | out-string -width 300 Trace-Message "Finished installation." Write-Host $Separator } ##################################################################################################### # Get-File ##################################################################################################### <# .SYNOPSIS Downloads a file from a URL to the downloaded contents folder. .DESCRIPTION Fetches the contents of a file from a URL to the downloaded contents folder (C:\Downloads). If a specific FilePath is specified, then skips the cache folder and downloads to the specified path. .PARAMETER Url The URL of the content to fetch. .PARAMETER FileName The name of the file to write the fetched content to. .OUTPUTS The full path to the downloaded file. #> function Get-File { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Url, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$FileName ) Write-Host $Separator $file = [System.IO.Path]::Combine("C:\Downloads", $FileName) Trace-Message "Downloading from $Url to file $File" Invoke-WebRequest -Uri $Url -UseBasicParsing -OutFile $file -UserAgent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; .NET CLR 1.0.3705;)" Trace-Message "Finished download" Write-Host $Separator return $file } ##################################################################################################### # Add-EnvironmentVariable ##################################################################################################### <# .SYNOPSIS Defines a new or redefines an existing environment variable. .DESCRIPTION There are many ways to set environment variables. However, the default mechanisms do not work when the change has to be persisted. This implementation writes the change into the registry, invokes the .NET SetEnvironmentVariable method with Machine scope and then invokes setx /m to force persistence of the change. .PARAMETER Name The name of the environment variable. .PARAMETER Value The value of the environment variable. .NOTES This does NOT work with PATH. #> function Add-EnvironmentVariable { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory=$true)] [string]$Value ) Write-Host $Separator Trace-Message "Setting environment variable $name := $value" Set-Item -Path Env:$Name -Value $Value New-Item -Path "HKLM:\System\CurrentControlSet\Control\Session Manager\Environment" -ItemType String -Force -Name $Name -Value $Value [System.Environment]::SetEnvironmentVariable($Name, $Value, [EnvironmentVariableTarget]::Machine) &setx.exe /m $Name $Value Write-Host $Separator } ##################################################################################################### # Update-Path ##################################################################################################### <# .SYNOPSIS Redefines the PATH. .DESCRIPTION There are many ways to set environment variables. However, the default mechanisms do not work when the change has to be persisted. This implementation writes the change into the registry, invokes the .NET SetEnvironmentVariable method with Machine scope and then invokes setx /m to force persistence of the change. .PARAMETER PathNodes An array of changes to the PATH. These values are appended to the existing value of PATH at the end. .NOTES This does NOT seem to work at all in Windows containers. Yet to be tested on RS5, but definitely did not work in RS1 through RS4. #> function Update-Path { param ( [Parameter(Mandatory=$true)] [string[]]$PathNodes ) Write-Host $Separator $NodeToAppend=$null $path = $env:Path Trace-Message "Current value of PATH := $path" Trace-Message "Appending $Update to PATH" if (!$path.endswith(";")) { $path = $path + ";" } foreach ($PathNode in $PathNodes) { if (!$PathNode.endswith(";")) { $PathNode = $PathNode + ";" } $NodesToAppend += $PathNode } # add the new nodes $path = $path + $NodesToAppend #prettify it because there is some cruft from base images and or path typos i.e. foo;; $path = $path -replace ";+",";" #pull these in a hack until remove nodes is implemented $path = $path.Replace("C:\Program Files\NuGet;","") $path = $path.Replace("C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin;","") $path = $path.Replace("C:\Program Files (x86)\Microsoft Visual Studio\2019\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow;","") #and set it Trace-Message "Setting PATH to $path" [System.Environment]::SetEnvironmentVariable("PATH", $path, [EnvironmentVariableTarget]::Machine) Write-Host $Separator } ##################################################################################################### # Add-WindowsFeature ##################################################################################################### <# .SYNOPSIS Simple wrapper around the Install-WindowsFeature cmdlet. .DESCRIPTION A simple wrapper around the Install-WindowsFeature cmdlet that writes log lines and data to help trace what happened. .PARAMETER Name The name of the feature to install. .PARAMETER SourceString The full -Source parameter with location to pass into install-WindowsFeature #> function Add-WindowsFeature { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$SourceLocation=$null ) Write-Host $Separator Trace-Message "Installing Windows feature $Name" if ($SourceLocation) { Install-WindowsFeature -Name $Name -Source $SourceLocation -IncludeAllSubFeature -IncludeManagementTools -Restart:$false -Confirm:$false } else { Install-WindowsFeature -Name $Name -IncludeAllSubFeature -IncludeManagementTools -Restart:$false -Confirm:$false } Trace-Message "Finished installing Windows feature $Name" Write-Host $Separator } ##################################################################################################### # Remove-WindowsFeature ##################################################################################################### <# .SYNOPSIS Simple wrapper around the Uninstall-WindowsFeature cmdlet. .DESCRIPTION A simple wrapper around the Uninstall-WindowsFeature cmdlet that writes log lines and data to help trace what happened. .PARAMETER Name The name of the feature to uninstall. #> function Remove-WindowsFeature { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name ) Write-Host $Separator Trace-Message "Removing Windows feature $Name" Uninstall-WindowsFeature -Name $Name -IncludeManagementTools -Restart:$false -Confirm:$false Trace-Message "Finished removing Windows feature $Name" Write-Host $Separator } ##################################################################################################### # Install-FromMSI ##################################################################################################### <# .SYNOPSIS Executes a Microsoft Installer package (MSI) in quiet mode. .DESCRIPTION Uses the msiexec tool with the appropriate arguments to execute the specified installer package in quiet non-interactive mode with full verbose logging enabled. .PARAMETER Path The full path to the installer package file. .PARAMETER Arguments The optioal arguments to pass to the MSI installer package. .PARAMETER IgnoreExitCodes An array of exit codes to ignore. By default 3010 is always ignored because that indicates a restart is required. Docker layers are an implied restart. In other scenarios such as image builds or local runs, a restart can be easily triggered by the invoking script or user. .PARAMETER IgnoreFailures Flag to force all failures (including actual failing exit codes) to be ignored. Notably 1603 is a very common one that indicates that an actual error occurred. #> function Install-FromMSI { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [string[]]$Arguments, [Parameter(Mandatory=$false)] [int[]]$IgnoreExitCodes, [switch]$IgnoreFailures ) Write-Host $Separator if (-not (Test-Path $Path)) { throw "CDPXERROR: Could not find the MSI installer package at $Path" } $fileNameOnly = [System.IO.Path]::GetFileNameWithoutExtension($Path) $log = [System.IO.Path]::Combine($env:TEMP, $fileNameOnly + ".log") $args = "/quiet /qn /norestart /lv! `"$log`" /i `"$Path`" $Arguments" Trace-Message "Installing from $Path" Trace-Message "Running msiexec.exe $args" $ex = Start-ExternalProcess -Path "msiexec.exe" -Arguments $args if ($ex -eq 3010) { Trace-Message "Install from $Path exited with code 3010. Ignoring since that is just indicating restart required." Write-Host $Separator return } elseif ($ex -ne 0) { foreach ($iex in $IgnoreExitCodes) { if ($ex -eq $iex) { Trace-Message "Install from $Path succeeded with exit code $ex" Write-Host $Separator return } } Trace-Error "Failed to install from $Path. Process exited with code $ex" if (-not $IgnoreFailures) { throw "Failed to install from $Path. Process exited with code $ex" } } } ##################################################################################################### # Install-FromEXE ##################################################################################################### <# .SYNOPSIS Executes any arbitrary executable installer. .DESCRIPTION A simple wrapper function to kick off an executable installer and handle failures, logging etc. .PARAMETER Path The path to the installer package file. .PARAMETER Arguments The optioal arguments to pass to the installer package. .PARAMETER IgnoreExitCodes An array of exit codes to ignore. By default 3010 is always ignored because that indicates a restart is required. Docker layers are an implied restart. In other scenarios such as image builds or local runs, a restart can be easily triggered by the invoking script or user. .PARAMETER IgnoreFailures Flag to force all failures (including actual failing exit codes) to be ignored. Notably 1603 is a very common one that indicates that an actual error occurred. #> function Install-FromEXE { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [int[]]$IgnoreExitCodes, [Parameter(Mandatory=$false)] [string[]]$Arguments, [switch]$IgnoreFailures ) Write-Host $Separator Trace-Message "Running $Path" $ex = Start-ExternalProcess -Path $Path -Arguments $Arguments if ($ex -eq 3010) { Trace-Message "Install from $Path exited with code 3010. Ignoring since that is just indicating restart required." Write-Host $Separator return } elseif ($ex -ne 0) { foreach ($iex in $IgnoreExitCodes) { if ($ex -eq $iex) { Trace-Message "Install from $Path succeeded with exit code $ex" Write-Host $Separator return } } Trace-Error "Failed to install from $Path. Process exited with code $ex" if (-not $IgnoreFailures) { throw "Failed to install from $Path. Process exited with code $ex" } } } ##################################################################################################### # Install-FromInnoSetup ##################################################################################################### <# .SYNOPSIS A shorthand function for running a Inno Setup installer package with the appropriate options. .DESCRIPTION Inno Setup installer packages can be run in silent mode with the options /VERYSILENT /NORESTART /CLOSEAPPLICATIONS /TYPE=full. In most cases, these options are the same for every Inno Setup installer. This function is hence a short hand for Inno Setup. .PARAMETER Path The path to the Inno Setup installer package file. .PARAMETER Arguments The optioal arguments to pass to the installer package. .PARAMETER IgnoreExitCodes An array of exit codes to ignore. .PARAMETER IgnoreFailures Flag to force all failures (including actual failing exit codes) to be ignored. #> function Install-FromInnoSetup { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [int[]]$IgnoreExitCodes, [Parameter(Mandatory=$false)] [string[]]$Arguments, [switch]$IgnoreFailures ) $fileNameOnly = [System.IO.Path]::GetFileNameWithoutExtension($Path) $logName = $fileNameOnly + ".log" $logFile = Join-Path $Env:TEMP -ChildPath $logName $args = "/QUIET /SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /NOICONS /TYPE=full /LOG `"$logFile`" " $args += $Arguments Install-FromEXE -Path $Path -Arguments $args -IgnoreExitCodes $IgnoreExitCodes -IgnoreFailures:$IgnoreFailures } ##################################################################################################### # Install-FromDevToolsInstaller ##################################################################################################### <# .SYNOPSIS A shorthand function for running a DevDiv Tools installer package with the appropriate options. .DESCRIPTION DevDiv Tools installer packages can be run in silent mode with the options /quiet /install /norestart. In most cases, these options are the same for every DevDiv Tools installer. This function is hence a short hand for DevDiv Tools installer packages. .PARAMETER Path The path to the DevDiv Tools installer package file. .PARAMETER Arguments The optional arguments to pass to the installer package. .PARAMETER IgnoreExitCodes An array of exit codes to ignore. 3010 is added by default by this function. .PARAMETER IgnoreFailures Flag to force all failures (including actual failing exit codes) to be ignored. #> function Install-FromDevDivToolsInstaller { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [int[]]$IgnoreExitCodes, [Parameter(Mandatory=$false)] [string[]]$Arguments, [switch]$IgnoreFailures ) $fileNameOnly = [System.IO.Path]::GetFileNameWithoutExtension($Path) $logName = $fileNameOnly + ".log" $logFile = Join-Path $Env:TEMP -ChildPath $logName $args = "/QUIET /INSTALL /NORESTART `"$logFile`" " $args += $Arguments $iec = (3010) $iec += $IgnoreExitCodes Install-FromEXE -Path $Path -Arguments $args -IgnoreExitCodes $iec -IgnoreFailures:$IgnoreFailures } ##################################################################################################### # Install-FromChocolatey ##################################################################################################### <# .SYNOPSIS Installs a Chocolatey package. .DESCRIPTION Installs a package using Chocolatey in silent mode with no prompts. .PARAMETER Name The name of the package to install. #> function Install-FromChocolatey { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name ) Write-Host $Separator Write-Host "Installing chocolatey package $Name" Start-ExternalProcess -Path "C:\ProgramData\chocolatey\bin\choco.exe" -Arguments @("install","-y",$Name) Write-Host $Separator } ##################################################################################################### # Install-FromEXEAsyncWithDevenvKill ##################################################################################################### <# .SYNOPSIS Starts an installer asynchronously and waits in the background for rogue child processes and kills them after letting them finish. .DESCRIPTION Visual Studio installers start a number of child processes. Notable amongst them is the devenv.exe process that attempts to initialize the VS IDE. Containers do not support UIs so this part hangs. There might be other related processes such as msiexec as well that hang. Invariable, these child processes complete quite fast, but never exit potentially becuase they are attempting to display some UI and hang. This helper function will kick off the installer and then monitor the task list to find those child processes by name and then it will kill them. .PARAMETER Path .PARAMETER StuckProcessNames .PARAMETER IgnoreExitCodes .PARAMETER IgnoreFailures .PARAMETER Arguments .PARAMETER WaitMinutes #> function Install-FromEXEAsyncWithDevenvKill { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$true)] [string[]]$StuckProcessNames, [Parameter(Mandatory=$false)] [int[]]$IgnoreExitCodes, [Parameter()] [switch]$IgnoreFailures, [Parameter(Mandatory=$false)] [ValidateRange(1, [int]::MaxValue)] [int]$WaitMinutes = 5, [string[]]$Arguments ) Write-Host $Separator Trace-Message "Running $Path with $Arguments" $process = Start-Process $Path -PassThru -Verbose -NoNewWindow -ArgumentList $Arguments $pid = $process.Id $pn = [System.IO.Path]::GetFileNameWithoutExtension($Path) Trace-Message "Started EXE asynchronously. Process ID is $pid" Wait-ForProcess -Process $process -Minutes $WaitMinutes Trace-Message "Walking task list and killing any processes in the stuck process list $StuckProcessNames" foreach ($stuckProcessName in $StuckProcessNames) { Stop-ProcessByName -Name $stuckProcessName -WaitBefore 3 -WaitAfter 3 } Trace-Message "Also killing any rogue msiexec processes" Stop-ProcessByName -Name "msiexec" -WaitBefore 3 -WaitAfter 3 Wait-WithMessage -Message "Waiting for process with ID $pid launched from $Path to finish now that children have been killed off" -Minutes 2 Stop-ProcessByName -Name $pn -WaitBefore 3 -WaitAfter 3 $ex = $process.ExitCode; if ($ex -eq 0) { Trace-Message "Install from $Path succeeded with exit code 0" Write-Host $Separator return } foreach ($iex in $ignoreExitCodes) { if ($ex -eq $iex) { Trace-Message "Install from $Path succeeded with exit code $ex" Write-Host $Separator return; } } Trace-Error "Failed to install from $Path. Process exited with code $ex" if (-not $IgnoreFailures) { throw "CDPXERROR: Failed to install from $Path. Process exited with exit code $ex" } } ##################################################################################################### # Confirm-PresenceOfVisualStudioErrorLogFile ##################################################################################################### <# .SYNOPSIS Throws an exception if a known Visual Studio installation error log file is found. .DESCRIPTION Visual Studio installers do not exit with appropriate error codes in case of component install failures. Often, any errors are indicated by the presence of a non-zero size error log file in the TEMP folder. This function checks for the existence of such files and throws an exception if any are found. .PARAMETER Path The folder in which to check for the presence of the error log files. Defaults to $Env:TEMP .PARAMETER Filter The filename filter to apply to search for error log files. .PARAMETER ThrowIfExists If set, then fails if an error log file is found on disk even if the size is zero. Defaults to false. .PARAMETER ThrowIfNotEmpty If set, then fails if an error log file is found on disk and its size is non-zero. Defaults to true. #> function Confirm-PresenceOfVisualStudioErrorLogFile { param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Filter, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$Path = $Env:TEMP, [Parameter(Mandatory = $false)] [switch]$ThrowIfExists = $false, [Parameter(Mandatory = $false)] [switch]$ThrowIfNotEmpty = $true ) if (Test-Path $Path) { Trace-Message "Checking if error log files matching the filter $Filter exist in $Path" Get-ChildItem -Path $Path -Filter $Filter | ForEach-Object { $file = $_.FullName $len = $_.Length Trace-Warning "Found error log file $file with size $len" if ($ThrowIfExists) { throw "CDPXERROR: At least one error log file $file matching $Filter was found in $Path." } if ($ThrowIfNotEmpty -and ($len -gt 0)) { throw "At least one non-empty log file $file matching $filter was found in $folder" } } } else { Trace-Warning "Folder $Path does not exist. Skipping checks." } } ##################################################################################################### # Stop-ProcessByName ##################################################################################################### <# .SYNOPSIS Kills all processes with a given name. .DESCRIPTION Some installers start multiple instances of other applications to perform various post-installer or initialization actions. The most notable is devenv.exe. This function provides a mechanism to brute force kill all such instances. .PARAMETER Name The name of the process to kill. .PARAMETER WaitBefore The optional number of minutes to wait before killing the process. This provides time for the process to finish its processes. .PARAMETER WaitAfter The optional number of minutes to wait after killing the process. This provides time for the process to exit and any handles to expire. #> function Stop-ProcessByName { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory=$false)] [ValidateRange(1, [int]::MaxValue)] [int]$WaitBefore = 3, [Parameter(Mandatory=$false)] [ValidateRange(1, [int]::MaxValue)] [int]$WaitAfter = 3 ) Wait-WithMessage -Message "Waiting for $WaitBefore minutes before killing all processes named $processName" -Minutes $WaitBefore &tasklist /v $count = 0 Get-Process -Name $Name -ErrorAction SilentlyContinue | ForEach-Object { $process = $_ Trace-Warning "Killing process with name $Name and ID $($process.Id)" $process.Kill() ++$count } Trace-Warning "Killed $count processes with name $Name" Wait-WithMessage -Message "Waiting for $WaitAfter minutes after killing all processes named $Name" -Minutes $WaitAfter &tasklist /v } ##################################################################################################### # Wait-WithMessage ##################################################################################################### <# .SYNOPSIS Performs a synchronous sleep. .DESCRIPTION Some asynchronous and other operations require a wait time before assuming a failure. This function forces the caller to sleep. The sleep is performed in 1-minute intervals and a message is printed on each wakeup. .PARAMETER Message The message to print after each sleep period. .PARAMETER Minutes The number of minutes to sleep. #> function Wait-WithMessage { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter(Mandatory=$true)] [ValidateRange(1, [int]::MaxValue)] [int]$Minutes ) $elapsed = 0 while ($true) { if ($elapsed -ge $Minutes) { Write-Host "Done waiting for $elapsed minutes" break } Trace-Message $Message Start-Sleep -Seconds 60 ++$elapsed } } ##################################################################################################### # Wait-WithMessageAndMonitor ##################################################################################################### <# .SYNOPSIS Performs a synchronous sleep and on each wakeup runs a script block that may contain some monitoring code. .DESCRIPTION Some asynchronous and other operations require a wait time before assuming a failure. This function forces the caller to sleep. The sleep is performed in 1-minute intervals and a message is printed and a script block is run on each wakeup. .PARAMETER Message The message to print after each sleep period. .PARAMETER Block The script block to run after each sleep period. .PARAMETER Minutes The number of minutes to sleep. #> function Wait-WithMessageAndMonitor { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter(Mandatory=$true)] [ValidateNotNull()] [ScriptBlock]$Monitor, [Parameter(Mandatory=$true)] [ValidateRange(1, [int]::MaxValue)] [int]$Minutes ) $elapsed = 0 while ($true) { if ($elapsed -ge $Minutes) { Write-Host "Done waiting for $elapsed minutes" break } Trace-Message $Message Start-Sleep -Seconds 60 $Monitor.Invoke() ++$elapsed } } ##################################################################################################### # Reset-TempFolders ##################################################################################################### <# .SYNOPSIS Deletes the contents of well known temporary folders. .DESCRIPTION Installing lots of software can leave the TEMP folder built up with crud. This function wipes the well known temp folders $Env:TEMP and C:\Windows\TEMP of all contentes. The folders are preserved however. #> function Reset-TempFolders { try { Trace-Message "Wiping contents of the $($Env:TEMP) and C:\Windows\TEMP folders." Get-ChildItem -Directory -Path $Env:TEMP | ForEach-Object { $p = $_.FullName Trace-Message "Removing temporary file $p" Remove-Item -Recurse -Force -Path $p -ErrorAction SilentlyContinue } Get-ChildItem -File -Path $Env:TEMP | ForEach-Object { $p = $_.FullName Trace-Message "Removing temporary file $p" Remove-Item -Force -Path $_.FullName -ErrorAction SilentlyContinue } Get-ChildItem -Directory -Path "C:\Windows\Temp" | ForEach-Object { $p = $_.FullName Trace-Message "Removing temporary file $p" Remove-Item -Recurse -Force -Path $_.FullName -ErrorAction SilentlyContinue } Get-ChildItem -File -Path "C:\Windows\Temp" | ForEach-Object { $p = $_.FullName Trace-Message "Removing temporary file $p" Remove-Item -Force -Path $_.FullName -ErrorAction SilentlyContinue } } catch { Trace-Warning "Errors occurred while trying to clean up temporary folders." $_.Exception | Format-List } finally { Trace-Message "Cleaned up temporary folders at $Env:TEMP and C:\Windows\Temp" } } ##################################################################################################### # Confirm-FileHash ##################################################################################################### <# .SYNOPSIS Verifies the content hash of downloaded content. .DESCRIPTION By default computes the SHA256 hash of downloaded content and compares it against a given hash assuming it to be a SHA256 hash as well. .PARAMETER FileName The name of the file. If the IsFullPath switch is not specified, assumes a file within the downloaded content cache. .PARAMETER ExpectedHash The expected hash value of the content. .PARAMETER Algorithm The optional hash algorithm to hash. Defaults to SHA256. .OUTPUTS #> function Confirm-FileHash { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$ExpectedHash, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$Algorithm = "sha256" ) Trace-Message "Verifying content hash for file $Path" $exists = Test-Path -Path $Path -PathType Leaf if (-not $exists) { throw "CDPXERROR: Failed to find file $Path in order to verify hash." } $hash = Get-FileHash $Path -Algorithm $Algorithm if ($hash.Hash -ne $ExpectedHash) { throw "File $Path hash $hash.Hash did not match expected hash $expectedHash" } } ##################################################################################################### # Start-ExternalProcess ##################################################################################################### <# .SYNOPSIS Executes an external application .DESCRIPTION PowerShell does not deal well with applications or scripts that write to standard error. This wrapper function handles starting the process, waiting for output and then captures the standard output/error streams and reports them without writing them to stderr. .PARAMETER Path The path to the application to run. .PARAMETER Arguments The array of arguments to pass to the external application. .OUTPUTS Returns the exit code that the application exited with. #> function Start-ExternalProcess { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [string[]]$Arguments ) Trace-Message "Executing application: $Path $Arguments" $guid = [System.Guid]::NewGuid().ToString("N") $errLogFileName = -join($guid, "-stderr.log") $outLogFileName = -join($guid, "-stdout.log") $errLogFile = Join-Path -Path $Env:TEMP -ChildPath $errLogFileName $outLogFile = Join-Path -Path $Env:TEMP -ChildPath $outLogFileName $workDir = [System.IO.Path]::GetDirectoryName($Path) [System.Diagnostics.Process]$process = $null if (($Arguments -ne $null) -and ($Arguments.Length -gt 0)) { $process = Start-Process -FilePath $Path -ArgumentList $Arguments -NoNewWindow -PassThru -RedirectStandardError $errLogFile -RedirectStandardOutput $outLogFile } else { $process = Start-Process -FilePath $Path -NoNewWindow -PassThru -RedirectStandardError $errLogFile -RedirectStandardOutput $outLogFile } $handle = $process.Handle $pid = $process.Id $ex = 0 Trace-Message -Message "Started process from $Path with PID $pid (and cached handle $handle)" while ($true) { Trace-Message -Message "Waiting for PID $pid to exit ..." if ($process.HasExited) { Trace-Message -Message "PID $pid has exited!" break } Sleep -Seconds 60 } Trace-Message "STDERR ---------------------------" Get-Content $errLogFile | Write-Host Trace-Message "STDOUT ---------------------------" Get-Content $outLogFile | Write-Host $ex = $process.ExitCode if ($ex -eq $null) { Trace-Warning -Message "The process $pid returned a null or invalid exit code value. Assuming and returning 0" $ex = 0 } else { Trace-Message "Process $pid exited with exit code $ex" } return $ex } ##################################################################################################### # Run-ExternalProcessWithWaitAndKill ##################################################################################################### <# .SYNOPSIS Executes an external application, waits for a specified amount of time and then kills it. .DESCRIPTION Some applications get stuck when running for the first time. This function starts the application, then waits and then kills it so that a subsequent run can succeed. .PARAMETER Path The path to the application to run. .PARAMETER Arguments The array of arguments to pass to the external application. .PARAMETER Minutes The amount of time to wait in minutes before killing the external application. .OUTPUTS The exit code if one is available from the process. #> function Run-ExternalProcessWithWaitAndKill { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory=$false)] [string[]]$Arguments, [Parameter(Mandatory=$false)] [ScriptBlock]$Monitor, [Parameter(Mandatory=$false)] [ValidateRange(1, [int]::MaxValue)] [int]$Minutes ) Trace-Message "Executing application: $Path $Arguments. Will wait $Minutes minutes before killing it." $guid = [System.Guid]::NewGuid().ToString("N") $errLogFileName = -join($guid, "-stderr.log") $outLogFileName = -join($guid, "-stdout.log") $errLogFile = Join-Path -Path $Env:TEMP -ChildPath $errLogFileName $outLogFile = Join-Path -Path $Env:TEMP -ChildPath $outLogFileName $workDir = [System.IO.Path]::GetDirectoryName($Path) [System.Diagnostics.Process]$process = $null if (-not $Arguments) { $process = Start-Process -FilePath $Path -NoNewWindow -PassThru -RedirectStandardError $errLogFile -RedirectStandardOutput $outLogFile } else { $process = Start-Process -FilePath $Path -ArgumentList $Arguments -NoNewWindow -PassThru -RedirectStandardError $errLogFile -RedirectStandardOutput $outLogFile } $handle = $process.Handle $pid = $process.Id $ex = 0 Trace-Message -Message "Started process from $Path with PID $pid (and cached handle $handle)" $exited = Wait-ForProcess -Process $process -Minutes $Minutes -Monitor $Monitor if (-not $exited) { Trace-Warning "CDPXERROR: Process with ID $pid failed to exit within $Minutes minutes. Killing it." try { $process.Kill() Trace-Warning "Killed PID $pid" } catch { Trace-Warning "Exception raised while attempting to kill PID $pid. Perhaps the process has already exited." $_.Exception | Format-List } } else { $ex = $process.ExitCode Trace-Message "Application $Path exited with exit code $ex" } Trace-Message "STDERR ---------------------------" Get-Content $errLogFile | Write-Host Trace-Message "STDOUT ---------------------------" Get-Content $outLogFile | Write-Host if ($ex -eq $null) { Trace-Warning -Message "The process $pid returned a null or invalid exit code value. Assuming and returning 0" return 0 } return $ex } ##################################################################################################### # Wait-ForProcess ##################################################################################################### <# .SYNOPSIS Waits for a previously started process until it exits or there is a timeout. .DESCRIPTION Waits for a started process until it exits or a certain amount of time has elapsed. .PARAMETER Process The [System.Process] project to wait for. .PARAMETER Minutes The amount of time to wait for in minutes. .PARAMETER Monitor An optional script block that will be run after each wait interval. #> function Wait-ForProcess { param ( [Parameter(Mandatory=$true)] [ValidateNotNull()] [System.Diagnostics.Process]$Process, [Parameter(Mandatory=$true)] [ValidateRange(1, [int]::MaxValue)] [int]$Minutes = 10, [Parameter(Mandatory=$false)] [ScriptBlock]$Monitor ) $waitTime = $Minutes $handle = $process.Handle $pid = $Process.Id while ($waitTime -gt 0) { Trace-Message -Message "Waiting for process with ID $pid to exit in $waitTime minutes." if ($Process.HasExited) { $ex = $Process.ExitCode Trace-Message "Process with ID $pid has already exited with exit code $ex" return $true } Sleep -Seconds 60 if ($Monitor) { try { Trace-Message "Invoking monitor script: $Monitor" $Monitor.Invoke() } catch { Trace-Warning "Exception occurred invoking monitoring script" $_.Exception | Format-List } } --$waitTime } return $false } ##################################################################################################### # Trace-Message ##################################################################################################### <# .SYNOPSIS Logs an informational message to the console. .DESCRIPTION Writes a message to the console with the current timestamp and an information tag. .PARAMETER Message The message to write. #> function Trace-Message { param ( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string]$Message ) $Message = $Message -replace "##vso", "__VSO_DISALLOWED" $timestamp = Get-Date Write-Host "[INFO] [$timestamp] $Message" } ##################################################################################################### # Trace-Warning ##################################################################################################### <# .SYNOPSIS Logs a warning message to the console. .DESCRIPTION Writes a warning to the console with the current timestamp and a warning tag. .PARAMETER Message The warning to write. #> function Trace-Warning { param ( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string]$Message ) $timestamp = Get-Date $Message = $Message -replace "##vso", "__VSO_DISALLOWED" Write-Host "[WARN] [$timestamp] $Message" -ForegroundColor Yellow Write-Host "##vso[task.logissue type=warning]$Message" } ##################################################################################################### # Trace-Error ##################################################################################################### <# .SYNOPSIS Logs an error message to the console. .DESCRIPTION Writes an error to the console with the current timestamp and an error tag. .PARAMETER Message The error to write. #> function Trace-Error { param ( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string]$Message ) $timestamp = Get-Date $Message = $Message -replace "##vso", "__VSO_DISALLOWED" Write-Host "[ERROR] [$timestamp] $Message" -ForegroundColor Red Write-Host "##vso[task.logissue type=error]$Message" } ##################################################################################################### # Expand-ArchiveWith7Zip ##################################################################################################### <# .SYNOPSIS Uses 7-Zip to expand an archive instead of the standard Expand-Archive cmdlet. .DESCRIPTION The Expand-Archive cmdlet is slow compared to using 7-Zip directly. This function assumes that 7-Zip is installed at C:\7-Zip. .PARAMETER -Source The path to the archive file. .PARAMETER -Destination The folder to expand into. .PARAMETER ToolPath The path to where the 7z.exe tool is available. #> function Expand-ArchiveWith7Zip { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Source, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$Destination, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$ToolPath = "C:\7-Zip\7z.exe", [Parameter(Mandatory=$false)] [switch]$IgnoreFailures=$false ) Write-Host $Separator if (-not $ToolPath) { throw "CDPXERROR: The 7-Zip tool was not found at $ToolPath." } if (-not (Test-Path $Source)) { throw "CDPXERROR: The specified archive file $Source could not be found." } if (-not $Destination) { $sourceDir = [System.IO.Path]::GetDirectoryName($Source); $Destination = $sourceDir Trace-Message "No destination was specified so the default location $Destination was chosen." } Trace-Message "Uncompressing archive $Source into folder $Destination using 7-Zip at $ToolPath" Install-FromEXE -Path $ToolPath -Arguments "x -aoa -y `"$Source`" -o`"$Destination`"" -IgnoreFailures:$IgnoreFailures Trace-Message "Successfully uncompressed archive at $Source into $Destination" Write-Host $Separator } ##################################################################################################### # Get-BlobPackageFromBase ##################################################################################################### <# .SYNOPSIS Uses AzCopy to download a blob package from blob store. .DESCRIPTION Some very large content such as Visual Studio offline installer files are stored in a CDPX hosted blob store. This method fetches the contents of such blob packages using AzCopy. #> function Get-BlobPackageFromBase { param ( [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$ContainerName, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$nodePath, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$downloadPath="C:\Downloads" ) Write-Host $Separator [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $Env:AZCOPY_LOG_LOCATION = $Env:TEMP $url = Get-BlobPackageBaseUrl -ContainerName $ContainerName Trace-Message "Invoking AzCopy CLI to download package $Name version $Version to $Path from $url" $Arguments = @("copy", $url, $downloadPath, "--recursive", "--include-path $nodePath", "--include-pattern *") Run-ExternalProcessWithWaitAndKill -Path "C:\AzCopy\azcopy.exe" -Arguments $Arguments -Minutes 30 Trace-Message "Finished downloading blob package" Write-Host $Separator return $Path } ##################################################################################################### # Get-BlobPackageFromEdge ##################################################################################################### <# .SYNOPSIS Uses a HTTP/S request to download a blob package from CDN. .DESCRIPTION Some content such as third party OSS or free software are hosted on a CDPX hosted blob store which is replicated to a CDN. This function fetches the blob package from the CDN. #> function Get-BlobPackageFromEdge { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Version, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$FileName, [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$Path="C:\Downloads", [Parameter(Mandatory=$false)] [ValidateNotNullOrEmpty()] [string]$ContainerName ) Write-Host $Separator [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $url = Get-BlobPackageEdgeUrl -Name $Name -Version $Version -Container $ContainerName Trace-Message "Downloading blob package $Name and $Version from $url" $path = Get-File -Url $url -FileName $FileName Trace-Message "Finished downloading blob package to $FileName" Write-Host $Separator return $path } ##################################################################################################### # Enum HostEnvironment ##################################################################################################### enum HostEnvironment { Dev Test Prod } ##################################################################################################### # Get-HostEnvironment ##################################################################################################### <# .SYNOPSIS Uses some heuristics about the underlying host to determine what kind of environment the host is in. .DESCRIPTION Leverages CDPX host naming conventions to determine if a host is a test or production host. If neither is true, this function always assumes that the host is a developer box. .OUTPUTS An instance of the enumeration HostEnvironment. #> function Get-HostEnvironment { $ctrHost = $Env:TEMP_CONTAINER_HOST_NAME if ($ctrHost) { if ($ctrHost.StartsWith("XWT")) { Trace-Message -Message "Running on CDPX test host." return [HostEnvironment]::Test } elseif ($ctrHost.StartsWith("XWP")) { Trace-Message -Message "Running on CDPX prod host." return [HostEnvironment]::Prod } } Trace-Message "Unsure what kind of CDPX environment underlying host `"$ctrHost`" is in. Assuming development box." return [HostEnvironment]::Dev } ##################################################################################################### # Get-BlobContainerName ##################################################################################################### <# .SYNOPSIS Returns the container name to use for blob packages. .DESCRIPTION Returns a OS specific container name within which blob packages specific to that OS are stored. .OUTPUTS Returns a lower case string that is the container name within the blob store in which blob packages are stored. #> function Get-BlobContainerName { if ($Env:os -eq "Windows_NT") { return "windows" } elseif ($Env:OS -eq "Linux") { return "linux" } throw "CDPXERROR: Only supported operating systems are Windows and Linux. Unknown OS $($Env:OS)" } ##################################################################################################### # Get-BlobAccountName ##################################################################################################### <# .SYNOPSIS Returns the base storage account in which blob packages are stored. .DESCRIPTION Returns an environment specific base storage account in which blob packages are stored. .OUTPUTS Returns a string that is an environment specific value for the blob storage account in which blob packages are stored. #> function Get-BlobAccountName { $hostEnv = Get-HostEnvironment $hostEnvStr = $hostEnv.ToString().ToLowerInvariant() $prefix = "cxswdist" $accountName = $prefix + $hostEnvStr Trace-Warning "Currently overriding blob storage account to cxswdisttest for all host environments." return "cxswdisttest" } ##################################################################################################### # Get-PackageFullName ##################################################################################################### <# .SYNOPSIS Gets the full name of a blob or universal package that can be downloaded by the functions in this module. .DESCRIPTION Given a package name and a version, returns a full name to the package for use with AzCopy or Az UPack CLI. The returned version is packagename-packageversion in lower case. .OUTPUTS The name of the package to use with blob store. #> function Get-PackageFullName { param ( [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Name, [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string]$Version ) $packageFullName = -join($Name, "-", $Version) return $packageFullName.ToLowerInvariant() } ##################################################################################################### # Get-LatestInstalledNetFrameworkVersion ##################################################################################################### <# .SYNOPSIS Gets the latest installed version of the .NET Framework. .DESCRIPTION Retrieves information from the registry based on the documentation at this link: https://docs.microsoft.com/en-us/dotnet/framework/migration-guide/how-to-determine-which-versions-are-installed#net_b. Returns the entire child object from the registry. .OUTPUTS The child registry entry for the .NET framework installation. #> function Get-LatestInstalledNetFrameworkVersion { Trace-Message -Message "Retrieving latest installed .NET Framework version from registry entry: HKLM:`\SOFTWARE`\Microsoft`\NET Framework Setup`\NDP`\v4`\Full" $item = Get-ChildItem HKLM:"\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" return $item } ##################################################################################################### # Run-VisualStudioInstallerProcessMonitor ##################################################################################################### <# .SYNOPSIS Monitors progress of Visual Studio installation. .DESCRIPTION Checks if VS installer processes named vs_installer or vs_enteprise are still running. Returns true if processes with those names were found. Otherwise returns false. In addition, lists all dd_setup* log files found in $Env:TEMP where VS installers traditionally place log files. Finally, if any error log files are present, prints out the contents of those files. .OUTPUTS True if VS installer or bootstrapper processes are still running. Otherwise false. #> function Run-VisualStudioInstallerProcessMonitor { Write-Host $Separator $processes = Get-Process $numTotalProcesses = 0 $numVSIProcesses = 0 $processes | ForEach-Object { $process = $_ $handle = $process.Handle $pid = $process.Id $ppath = $process.Path if ($process.Name.StartsWith("vs_installer") -or $process.Name.StartsWith("vs_enterprise")) { $numVSIProcesses++ Trace-Message -Message "Found VS Installer process with PID $pid launched from $ppath" } ++$numTotalProcesses } Trace-Message "Total processes: $numTotalProcesses. VS Installer processes: $numVSIProcesses" $setupLogs = Get-ChildItem $Env:TEMP -Filter "dd_setup*.log" $setupLogs | Write-Host $setupLogs | ForEach-Object { $setupLog = $_ $setupLogPath = $setupLog.FullName if ($setupLog.Name.Contains("errors")) { Trace-Message "Contents of VS installer error log: $setupLogPath" Get-Content -Path $setupLogPath | Write-Host } } Write-Host $Separator if ($numVSIProcesses -gt 0) { return $true } return $false } ##################################################################################################### # Monitor-VisualStudioInstallation ##################################################################################################### <# #> function Monitor-VisualStudioInstallation { param ( [Parameter(Mandatory=$true)] [ValidateRange(1, [int]::MaxValue)] [int]$WaitBefore, [Parameter(Mandatory=$true)] [ValidateRange(1, [int]::MaxValue)] [int]$WaitAfter ) $minutes = $WaitBefore while ($minutes -gt 0) { Trace-Message -Message "WAITING for VS installer kickoff." Run-VisualStudioInstallerProcessMonitor Sleep -Seconds 60 --$minutes } $minutes = $WaitAfter while ($minutes -gt 0) { Trace-Message -Message "WAITING for VS installer kickoff." $ex = Run-VisualStudioInstallerProcessMonitor if (-not $ex) { Trace-Message -Message "DONE Looks like VS installer processes are no longer running." break } Sleep -Seconds 120 --$minutes } }