Public/Save-EvergreenApp.ps1
|
function Save-EvergreenApp { <# .EXTERNALHELP Evergreen-help.xml #> [OutputType([System.Management.Automation.PSObject])] [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = "Path")] [Alias("sea")] param ( [Parameter( Mandatory = $true, Position = 0, ValueFromPipeline, HelpMessage = "Pass an application object from Get-EvergreenApp.")] [ValidateNotNull()] [System.Management.Automation.PSObject] $InputObject, [Parameter( Mandatory = $false, Position = 1, ValueFromPipelineByPropertyName, HelpMessage = "Specify a top-level directory path where the application installers will be saved into.", ParameterSetName = "Path")] [Parameter( Mandatory = $false, ValueFromPipelineByPropertyName, ParameterSetName = "CustomPath")] [System.IO.FileInfo] $Path, [Parameter( Mandatory = $false, Position = 1, ValueFromPipelineByPropertyName, HelpMessage = "Specify a single directory path where all application installers will be saved into.", ParameterSetName = "CustomPath")] [Parameter( Mandatory = $false, ValueFromPipelineByPropertyName, ParameterSetName = "Path")] [Alias("LiteralPath")] [System.IO.FileInfo] $CustomPath, [Parameter(Mandatory = $false, Position = 2)] [System.String] $Proxy, [Parameter(Mandatory = $false, Position = 3)] [System.Management.Automation.PSCredential] $ProxyCredential = [System.Management.Automation.PSCredential]::Empty, [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $UserAgent = (Get-EvergreenUserAgent), [Parameter(Mandatory = $false)] [System.Management.Automation.SwitchParameter] $Force, [Parameter(Mandatory = $false)] [System.Management.Automation.SwitchParameter] $NoProgress, [Parameter()] [System.Management.Automation.SwitchParameter] $VerifyChecksum, [Parameter()] [ValidateRange(0, 10)] [System.Int32] $RetryCount = 3, [Parameter()] [ValidateRange(0, 60)] [System.Int32] $RetryDelaySeconds = 2, [Parameter()] [ValidateRange(1, 3600)] [System.Int32] $MaximumRetryDelaySeconds = 300, [Parameter()] [ValidateRange(1, 600)] [System.Int32] $ConnectionTimeoutSeconds = 30, [Parameter()] [ValidateRange(1, 86400)] [System.Int32] $OperationTimeoutSeconds = 300 ) begin { if ($PSBoundParameters.ContainsKey("ProxyCredential") -and -not($PSBoundParameters.ContainsKey("Proxy"))) { throw [System.ArgumentException]::New("ProxyCredential requires Proxy.") } if ( $PSVersionTable.PSVersion -lt [System.Version]"7.4" -and $PSBoundParameters.ContainsKey("OperationTimeoutSeconds") ) { Write-Warning -Message "OperationTimeoutSeconds is only supported by Invoke-WebRequest in PowerShell 7.4 and later." } # Disable the Invoke-WebRequest progress bar for faster downloads if ($PSBoundParameters.ContainsKey("Verbose") -and !($PSBoundParameters.ContainsKey("NoProgress"))) { $ProgressPreference = [System.Management.Automation.ActionPreference]::continue } else { $ProgressPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue } } process { # Loop through each object and download to the target path foreach ($Object in $InputObject) { #region Validate the URI property and find the output filename if ([System.Boolean]($Object.URI)) { Write-Verbose -Message "URL: $($Object.URI)." if ([System.Boolean]($Object.FileName)) { $OutFile = $Object.FileName } elseif ([System.Boolean]($Object.URI)) { $OutFile = Split-Path -Path $Object.URI -Leaf } } else { throw [System.Management.Automation.PropertyNotFoundException] "InputObject does not have valid URI property." } if ( [System.String]::IsNullOrWhiteSpace([System.String]$OutFile) -or [System.IO.Path]::IsPathRooted($OutFile) -or $OutFile -in @(".", "..") -or $OutFile -match '[/\\]' -or $OutFile.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0 ) { throw [System.ArgumentException]::New("InputObject contains an invalid file name: '$OutFile'.") } $ExpectedHashes = @() if ($VerifyChecksum) { foreach ($algorithm in @("SHA1", "SHA256", "SHA512")) { $hashProperty = $Object.PSObject.Properties[$algorithm] if ($null -ne $hashProperty -and -not [System.String]::IsNullOrWhiteSpace([System.String]$hashProperty.Value)) { $ExpectedHashes += [PSCustomObject]@{ Algorithm = $algorithm Value = ([System.String]$hashProperty.Value).Trim() } } } } $TestFileChecksums = { param ([System.String] $FilePath, [PSObject[]] $Hashes) $mismatchedAlgorithms = @() foreach ($expectedHash in $Hashes) { try { $actualHash = (Get-FileHash -LiteralPath $FilePath -Algorithm $expectedHash.Algorithm -ErrorAction "Stop").Hash } catch { $actualHash = $null } if ($actualHash -ine $expectedHash.Value) { $mismatchedAlgorithms += $expectedHash.Algorithm } } if ($mismatchedAlgorithms.Count -gt 0) { Write-Warning -Message "Checksum verification failed for '$FilePath' using: $($mismatchedAlgorithms -join ', ')." return $false } Write-Verbose -Message "Checksum verification passed for '$FilePath' using: $(($Hashes.Algorithm) -join ', ')." return $true } if ( -not [System.String]::IsNullOrWhiteSpace([System.String]$Path) -and -not [System.String]::IsNullOrWhiteSpace([System.String]$CustomPath) ) { throw [System.ArgumentException]::New("Specify either Path or CustomPath, not both.") } $UseCustomPath = -not [System.String]::IsNullOrWhiteSpace([System.String]$CustomPath) $RequestedPath = if ($UseCustomPath) { $CustomPath } else { $Path } if ([System.String]::IsNullOrWhiteSpace([System.String]$RequestedPath)) { throw [System.ArgumentException]::New("A path must be provided with -Path or -CustomPath.") } $NewPath = [System.IO.Path]::GetFullPath([System.String]$RequestedPath) if (-not (Test-Path -LiteralPath $NewPath -PathType "Container")) { if (Test-Path -LiteralPath $NewPath -PathType "Leaf") { throw [System.IO.IOException]::New("The destination path is a file: $NewPath.") } if ($PSCmdlet.ShouldProcess($NewPath, "Create directory")) { [System.IO.Directory]::CreateDirectory($NewPath) | Out-Null } } # Handle the output path depending on whether -Path or -CustomPath are used if ($UseCustomPath) { $OutPath = $NewPath } else { if ([System.Boolean]($Object.Version)) { $OutPath = New-EvergreenPath -InputObject $Object -Path $NewPath } else { throw [System.Management.Automation.PropertyNotFoundException] "InputObject does not have valid Version property." } } $OutPath = [System.IO.Path]::GetFullPath([System.String]$OutPath) $DownloadFile = [System.IO.Path]::GetFullPath((Join-Path -Path $OutPath -ChildPath $OutFile)) $OutPathPrefix = $OutPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar $comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } if (-not $DownloadFile.StartsWith($OutPathPrefix, $comparison)) { throw [System.ArgumentException]::New("The destination file resolves outside the output directory: $DownloadFile.") } if ($Force -or -not (Test-Path -LiteralPath $DownloadFile -PathType "Leaf")) { if ($PSCmdlet.ShouldProcess($DownloadFile, "Download")) { $TemporaryFile = Join-Path -Path $OutPath -ChildPath ".evergreen-$([System.Guid]::NewGuid().ToString('N')).download" $params = @{ Uri = $Object.URI OutFile = $TemporaryFile UseBasicParsing = $true UserAgent = $UserAgent ErrorAction = "Stop" } if ($PSBoundParameters.ContainsKey("Proxy")) { $params.Proxy = $Proxy } if ($PSBoundParameters.ContainsKey("ProxyCredential")) { $params.ProxyCredential = $ProxyCredential } if ($PSVersionTable.PSVersion -ge [System.Version]"7.4") { $params.ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds $params.OperationTimeoutSeconds = $OperationTimeoutSeconds } else { $params.TimeoutSec = $ConnectionTimeoutSeconds } $supportsResume = $PSVersionTable.PSVersion -ge [System.Version]"6.1" if ($supportsResume) { $params.Resume = $true } Write-Verbose -Message "Downloading to: $DownloadFile." $securityProtocol = [System.Net.ServicePointManager]::SecurityProtocol try { [System.Net.ServicePointManager]::SecurityProtocol = $securityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 $attempt = 0 $maximumAttempts = $RetryCount + 1 $downloaded = $false $checksumFailed = $false while (-not $downloaded) { $attempt++ try { if (-not $supportsResume -and (Test-Path -LiteralPath $TemporaryFile -PathType "Leaf")) { Remove-Item -LiteralPath $TemporaryFile -Force -ErrorAction "Stop" } Invoke-WebRequest @params if (-not (Test-Path -LiteralPath $TemporaryFile -PathType "Leaf")) { throw [System.IO.IOException]::New("The download completed without creating a file: $TemporaryFile.") } if ($VerifyChecksum -and $ExpectedHashes.Count -gt 0 -and -not (& $TestFileChecksums -FilePath $TemporaryFile -Hashes $ExpectedHashes)) { $checksumFailed = $true break } if ($VerifyChecksum -and $ExpectedHashes.Count -eq 0) { Write-Verbose -Message "No supported checksum properties found for '$DownloadFile'; skipping checksum verification." } $downloaded = $true } catch { $requestError = $_ $response = $requestError.Exception.Response $statusCode = $null $retryAfterHeader = $null if ($null -ne $response) { try { $statusCode = [System.Int32]$response.StatusCode } catch { Write-Warning -Message "Failed to parse status code from response." } try { $retryAfterHeader = [System.String]$response.Headers["Retry-After"] } catch { Write-Warning -Message "Failed to parse Retry-After header from response." } } $retryableStatusCodes = @(408, 429, 500, 502, 503, 504) $retryableExceptionTypes = @( "System.IO.IOException", "System.Net.Http.HttpRequestException", "System.Net.Sockets.SocketException", "System.Net.WebException", "System.Threading.Tasks.TaskCanceledException", "System.TimeoutException" ) $isNetworkFailure = $false $currentException = $requestError.Exception while ($null -ne $currentException) { if ($currentException.GetType().FullName -in $retryableExceptionTypes) { $isNetworkFailure = $true break } $currentException = $currentException.InnerException } $isRetryable = $isNetworkFailure -or ($statusCode -in $retryableStatusCodes) if (-not $isRetryable -or $attempt -ge $maximumAttempts) { throw } if (Test-Path -LiteralPath $TemporaryFile -PathType "Leaf") { Remove-Item -LiteralPath $TemporaryFile -Force -ErrorAction "SilentlyContinue" } $delaySeconds = [System.Int32][System.Math]::Min( $MaximumRetryDelaySeconds, $RetryDelaySeconds * [System.Math]::Pow(2, $attempt - 1) ) $retryAfterSeconds = 0 $parsedRetryAfterSeconds = 0 if ([System.Int32]::TryParse($retryAfterHeader, [ref]$parsedRetryAfterSeconds)) { $retryAfterSeconds = [System.Math]::Max(0, $parsedRetryAfterSeconds) } else { $retryAfterDate = [System.DateTimeOffset]::MinValue if ([System.DateTimeOffset]::TryParse($retryAfterHeader, [ref]$retryAfterDate)) { $retryAfterSeconds = [System.Math]::Max(0, [System.Math]::Ceiling(($retryAfterDate - [System.DateTimeOffset]::UtcNow).TotalSeconds)) } } if ($retryAfterSeconds -gt 0) { $delaySeconds = [System.Int32][System.Math]::Min( $MaximumRetryDelaySeconds, [System.Math]::Max($delaySeconds, $retryAfterSeconds) ) } Write-Verbose -Message "Download attempt $attempt failed. Retrying in $delaySeconds second(s). Error: $($requestError.Exception.Message)" if ($delaySeconds -gt 0) { Start-Sleep -Seconds $delaySeconds } } } if (-not $checksumFailed) { Move-Item -LiteralPath $TemporaryFile -Destination $DownloadFile -Force:$Force -ErrorAction "Stop" } } finally { [System.Net.ServicePointManager]::SecurityProtocol = $securityProtocol if (Test-Path -LiteralPath $TemporaryFile -PathType "Leaf") { Remove-Item -LiteralPath $TemporaryFile -Force -ErrorAction "SilentlyContinue" } } if ($checksumFailed) { continue } Write-Verbose -Message "Successfully downloaded: $DownloadFile." Write-Output -InputObject (Get-Item -LiteralPath $DownloadFile) } } elseif (Test-Path -LiteralPath $DownloadFile -PathType "Leaf") { if ($VerifyChecksum -and $ExpectedHashes.Count -gt 0 -and -not (& $TestFileChecksums -FilePath $DownloadFile -Hashes $ExpectedHashes)) { continue } if ($VerifyChecksum -and $ExpectedHashes.Count -eq 0) { Write-Verbose -Message "No supported checksum properties found for '$DownloadFile'; skipping checksum verification." } Write-Verbose -Message "File exists: $DownloadFile." Write-Output -InputObject (Get-Item -LiteralPath $DownloadFile) } } } end { Write-Verbose -Message "Complete." } } |