diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 index 9c876a4..53ae984 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-FolderStructure.ps1 @@ -26,7 +26,13 @@ function New-FolderStructure { [switch] $replaceFiles, [Parameter(Mandatory = $false)] - [int] $maxRetryCount = 10 + [int] $maxRetryCount = 10, + + [Parameter(Mandatory = $false)] + [int] $retryIntervalSeconds = 3, + + [Parameter(Mandatory = $false)] + [int] $httpRequestTimeoutSeconds ) if ($PSCmdlet.ShouldProcess("ALZ-Terraform module configuration", "modify")) { @@ -54,7 +60,20 @@ function New-FolderStructure { } } else { - $releaseTag = Get-GithubRelease -githubRepoUrl $url -targetDirectory $targetDirectory -moduleSourceFolder $sourceFolder -moduleTargetFolder $targetFolder -release $release -releaseArtifactName $releaseArtifactName -maxRetryCount $maxRetryCount + $releaseParams = @{ + githubRepoUrl = $url + targetDirectory = $targetDirectory + moduleSourceFolder = $sourceFolder + moduleTargetFolder = $targetFolder + release = $release + releaseArtifactName = $releaseArtifactName + maxRetryCount = $maxRetryCount + retryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $releaseParams["httpRequestTimeoutSeconds"] = $httpRequestTimeoutSeconds + } + $releaseTag = Get-GithubRelease @releaseParams $path = Join-Path $targetDirectory $targetFolder $releaseTag } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 index 9405f44..b697f01 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 @@ -25,7 +25,11 @@ function New-ModuleSetup { [Parameter(Mandatory = $false)] [switch]$autoApprove, [Parameter(Mandatory = $false)] - [int]$maxRetryCount = 10 + [int]$maxRetryCount = 10, + [Parameter(Mandatory = $false)] + [int]$retryIntervalSeconds = 3, + [Parameter(Mandatory = $false)] + [int]$httpRequestTimeoutSeconds ) if ($PSCmdlet.ShouldProcess("Check and get module", "modify")) { @@ -44,21 +48,36 @@ function New-ModuleSetup { if(-not [string]::IsNullOrWhiteSpace($moduleOverrideFolderPath)) { Write-Verbose "Using module override folder path, skipping version checks." - return New-FolderStructure ` - -targetDirectory $targetDirectory ` - -url $url ` - -release $desiredRelease ` - -releaseArtifactName $releaseArtifactName ` - -targetFolder $targetFolder ` - -sourceFolder $sourceFolder ` - -overrideSourceDirectoryPath $moduleOverrideFolderPath ` - -replaceFiles:$replaceFiles.IsPresent ` - -maxRetryCount $maxRetryCount + $folderParams = @{ + targetDirectory = $targetDirectory + url = $url + release = $desiredRelease + releaseArtifactName = $releaseArtifactName + targetFolder = $targetFolder + sourceFolder = $sourceFolder + overrideSourceDirectoryPath = $moduleOverrideFolderPath + replaceFiles = $replaceFiles.IsPresent + maxRetryCount = $maxRetryCount + retryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $folderParams["httpRequestTimeoutSeconds"] = $httpRequestTimeoutSeconds + } + return New-FolderStructure @folderParams } $latestReleaseTag = $null try { - $latestResult = Get-GithubReleaseTag -githubRepoUrl $url -release "latest" -maxRetryCount $maxRetryCount + $releaseTagParams = @{ + githubRepoUrl = $url + release = "latest" + maxRetryCount = $maxRetryCount + retryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $releaseTagParams["httpRequestTimeoutSeconds"] = $httpRequestTimeoutSeconds + } + $latestResult = Get-GithubReleaseTag @releaseTagParams $latestReleaseTag = $latestResult.ReleaseTag Write-Verbose "Latest available $targetFolder version: $latestReleaseTag" } catch { @@ -136,16 +155,22 @@ function New-ModuleSetup { Write-ToConsoleLog "Downloading $targetFolder module version $desiredRelease" -IsSuccess } - $versionAndPath = New-FolderStructure ` - -targetDirectory $targetDirectory ` - -url $url ` - -release $desiredRelease ` - -releaseArtifactName $releaseArtifactName ` - -targetFolder $targetFolder ` - -sourceFolder $sourceFolder ` - -overrideSourceDirectoryPath $moduleOverrideFolderPath ` - -replaceFiles:$replaceFiles.IsPresent ` - -maxRetryCount $maxRetryCount + $downloadFolderParams = @{ + targetDirectory = $targetDirectory + url = $url + release = $desiredRelease + releaseArtifactName = $releaseArtifactName + targetFolder = $targetFolder + sourceFolder = $sourceFolder + overrideSourceDirectoryPath = $moduleOverrideFolderPath + replaceFiles = $replaceFiles.IsPresent + maxRetryCount = $maxRetryCount + retryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $downloadFolderParams["httpRequestTimeoutSeconds"] = $httpRequestTimeoutSeconds + } + $versionAndPath = New-FolderStructure @downloadFolderParams Write-Verbose "New version: $($versionAndPath.releaseTag) at path: $($versionAndPath.path)" diff --git a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 index 0e147ff..c4f07ec 100644 --- a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 @@ -49,14 +49,31 @@ function Get-GithubRelease { [Parameter(Mandatory = $false, HelpMessage = "Maximum number of retries for transient GitHub API errors.")] [int] - $maxRetryCount = 10 + $maxRetryCount = 10, + + [Parameter(Mandatory = $false, HelpMessage = "Seconds to wait between retries for transient HTTP request errors.")] + [int] + $retryIntervalSeconds = 3, + + [Parameter(Mandatory = $false, HelpMessage = "Timeout in seconds for HTTP requests.")] + [int] + $httpRequestTimeoutSeconds ) $parentDirectory = $targetDirectory $targetPath = Join-Path $targetDirectory $moduleTargetFolder # Get the release tag and data from GitHub - $releaseResult = Get-GithubReleaseTag -githubRepoUrl $githubRepoUrl -release $release -maxRetryCount $maxRetryCount + $releaseTagParams = @{ + githubRepoUrl = $githubRepoUrl + release = $release + maxRetryCount = $maxRetryCount + retryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $releaseTagParams["httpRequestTimeoutSeconds"] = $httpRequestTimeoutSeconds + } + $releaseResult = Get-GithubReleaseTag @releaseTagParams $releaseTag = $releaseResult.ReleaseTag $releaseData = $releaseResult.ReleaseData @@ -100,7 +117,16 @@ function Get-GithubRelease { Write-Verbose "===> Downloading the release artifact $releaseArtifactUrl from the GitHub repository $repoOrgPlusRepo" - Invoke-GitHubApiRequest -Uri $releaseArtifactUrl -OutputFile $targetPathForZip -MaxRetryCount $maxRetryCount -RetryIntervalSeconds 3 + $downloadParams = @{ + Uri = $releaseArtifactUrl + OutputFile = $targetPathForZip + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $downloadParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + Invoke-GitHubApiRequest @downloadParams if(!(Test-Path $targetPathForZip)) { Write-ToConsoleLog "Failed to download the release $releaseTag from the GitHub repository $repoOrgPlusRepo" -IsError diff --git a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 index 7353263..5abd631 100644 --- a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 @@ -33,7 +33,15 @@ function Get-GithubReleaseTag { [Parameter(Mandatory = $false, HelpMessage = "Maximum number of retries for transient GitHub API errors.")] [int] - $maxRetryCount = 10 + $maxRetryCount = 10, + + [Parameter(Mandatory = $false, HelpMessage = "Seconds to wait between retries for transient HTTP request errors.")] + [int] + $retryIntervalSeconds = 3, + + [Parameter(Mandatory = $false, HelpMessage = "Timeout in seconds for HTTP requests.")] + [int] + $httpRequestTimeoutSeconds ) # Split Repo URL into parts @@ -48,7 +56,16 @@ function Get-GithubReleaseTag { } # Query the GitHub API - $response = Invoke-GitHubApiRequest -Uri $repoReleaseUrl -SkipHttpErrorCheck -MaxRetryCount $maxRetryCount -RetryIntervalSeconds 3 + $apiParams = @{ + Uri = $repoReleaseUrl + SkipHttpErrorCheck = $true + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $apiParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + $response = Invoke-GitHubApiRequest @apiParams $releaseData = $response.Result $statusCode = $response.StatusCode diff --git a/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 b/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 index e65dd18..d57ed14 100644 --- a/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 +++ b/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 @@ -1,4 +1,4 @@ -#################################### +#################################### # Invoke-GitHubApiRequest.ps1 # #################################### # Version: 0.1.0 @@ -65,6 +65,9 @@ function Invoke-GitHubApiRequest { [Parameter(Mandatory = $false, HelpMessage = "If specified, downloads the response to this file path.")] [string] $OutputFile, + [Parameter(Mandatory = $false, HelpMessage = "Timeout in seconds for the HTTP request.")] + [int] $TimeoutSec, + [Parameter(Mandatory = $false, HelpMessage = "If specified, does not throw on HTTP error status codes.")] [switch] $SkipHttpErrorCheck ) @@ -87,49 +90,46 @@ function Invoke-GitHubApiRequest { Write-Verbose "GitHub CLI is not installed. Proceeding without authentication." } - $isDownload = -not [string]::IsNullOrEmpty($OutputFile) - $transientStatusCodes = @(408, 429, 500, 502, 503, 504) - $maxAttempts = $MaxRetryCount + 1 - - for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - try { - if ($isDownload) { - Invoke-WebRequest -Uri $Uri -Method $Method -Headers $headers -OutFile $OutputFile -ErrorAction Stop - return - } - - if ($SkipHttpErrorCheck) { - $result = Invoke-RestMethod -Uri $Uri -Method $Method -Headers $headers -SkipHttpErrorCheck -StatusCodeVariable "responseStatusCode" + # Build parameters for the generic retry cmdlet + $retryParams = @{ + Uri = $Uri + Method = $Method + MaxRetryCount = $MaxRetryCount + RetryIntervalSeconds = $RetryIntervalSeconds + } - $code = [int]$responseStatusCode + if ($PSBoundParameters.ContainsKey("TimeoutSec")) { + $retryParams["TimeoutSec"] = $TimeoutSec + } - if ($code -in $transientStatusCodes -and $attempt -lt $maxAttempts) { - Write-Warning "Request to $Uri returned status $code (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..." - Start-Sleep -Seconds $RetryIntervalSeconds - continue - } + if ($headers.Count -gt 0) { + $retryParams["Headers"] = $headers + } - return @{ - Result = $result - StatusCode = $code - } - } + # File download — delegate directly + if (-not [string]::IsNullOrEmpty($OutputFile)) { + Invoke-HttpRequestWithRetry @retryParams -OutFile $OutputFile + return + } - return (Invoke-RestMethod -Uri $Uri -Method $Method -Headers $headers -ErrorAction Stop) - } catch { - $responseCode = $null - if ($_.Exception.Response) { - $responseCode = [int]$_.Exception.Response.StatusCode - } + # API call with SkipHttpErrorCheck — parse JSON and return Result/StatusCode hashtable + if ($SkipHttpErrorCheck) { + $response = Invoke-HttpRequestWithRetry @retryParams -SkipHttpErrorCheck -ReturnStatusCode - $isTransient = $responseCode -in $transientStatusCodes + $parsed = $null + if (-not [string]::IsNullOrWhiteSpace($response.Result.Content)) { + $parsed = $response.Result.Content | ConvertFrom-Json + } - if ($isTransient -and $attempt -lt $maxAttempts) { - Write-Warning "Request to $Uri failed with status $responseCode (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..." - Start-Sleep -Seconds $RetryIntervalSeconds - } else { - throw - } + return @{ + Result = $parsed + StatusCode = $response.StatusCode } } + + # Standard API call — parse JSON and return the object + $response = Invoke-HttpRequestWithRetry @retryParams + if (-not [string]::IsNullOrWhiteSpace($response.Content)) { + return ($response.Content | ConvertFrom-Json) + } } diff --git a/src/ALZ/Private/Shared/Invoke-HttpRequestWithRetry.ps1 b/src/ALZ/Private/Shared/Invoke-HttpRequestWithRetry.ps1 new file mode 100644 index 0000000..b17c98c --- /dev/null +++ b/src/ALZ/Private/Shared/Invoke-HttpRequestWithRetry.ps1 @@ -0,0 +1,172 @@ +#################################### +# Invoke-HttpRequestWithRetry.ps1 # +#################################### +# Version: 0.1.0 + +<# +.SYNOPSIS +Invokes an HTTP request with automatic retry logic for transient errors. + +.DESCRIPTION +Makes HTTP requests using Invoke-WebRequest or Invoke-RestMethod with +automatic retry for transient HTTP errors (408, 429, 500, 502, 503, 504). + +.PARAMETER Uri +The URI to send the request to. + +.PARAMETER Method +The HTTP method for the request. Defaults to GET. + +.PARAMETER MaxRetryCount +Maximum number of retries for transient errors. Defaults to 10. + +.PARAMETER RetryIntervalSeconds +Seconds to wait between retries. Defaults to 3. + +.PARAMETER OutFile +If specified, downloads the response to this file path using Invoke-WebRequest. + +.PARAMETER SkipHttpErrorCheck +If specified, does not throw on HTTP error status codes. +Returns the response object without error. + +.PARAMETER TimeoutSec +Timeout in seconds for the HTTP request. If not specified, uses the default. + +.PARAMETER Body +The body of the request. + +.PARAMETER ContentType +The content type of the request body. + +.PARAMETER Headers +Additional headers to include in the request. + +.PARAMETER ReturnStatusCode +If specified alongside SkipHttpErrorCheck, returns a hashtable with +Result and StatusCode properties (similar to Invoke-GitHubApiRequest). + +.EXAMPLE +Invoke-HttpRequestWithRetry -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20" + +.EXAMPLE +Invoke-HttpRequestWithRetry -Uri "https://example.com/file.zip" -OutFile "./file.zip" + +.EXAMPLE +Invoke-HttpRequestWithRetry -Uri "https://example.com" -Method Head -SkipHttpErrorCheck -MaxRetryCount 0 + +.NOTES +# Release notes 25/03/2026 - V0.1.0: +- Initial release. +#> + +function Invoke-HttpRequestWithRetry { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true, Position = 1, HelpMessage = "The URI to send the request to.")] + [string] $Uri, + + [Parameter(Mandatory = $false, HelpMessage = "The HTTP method for the request.")] + [string] $Method = "GET", + + [Parameter(Mandatory = $false, HelpMessage = "Maximum number of retries for transient errors.")] + [int] $MaxRetryCount = 10, + + [Parameter(Mandatory = $false, HelpMessage = "Seconds to wait between retries.")] + [int] $RetryIntervalSeconds = 3, + + [Parameter(Mandatory = $false, HelpMessage = "If specified, downloads the response to this file path.")] + [string] $OutFile, + + [Parameter(Mandatory = $false, HelpMessage = "If specified, does not throw on HTTP error status codes.")] + [switch] $SkipHttpErrorCheck, + + [Parameter(Mandatory = $false, HelpMessage = "Timeout in seconds for the HTTP request.")] + [int] $TimeoutSec, + + [Parameter(Mandatory = $false, HelpMessage = "The body of the request.")] + [object] $Body, + + [Parameter(Mandatory = $false, HelpMessage = "The content type of the request body.")] + [string] $ContentType, + + [Parameter(Mandatory = $false, HelpMessage = "Additional headers to include in the request.")] + [hashtable] $Headers, + + [Parameter(Mandatory = $false, HelpMessage = "If specified, returns a hashtable with Result and StatusCode.")] + [switch] $ReturnStatusCode + ) + + $isDownload = -not [string]::IsNullOrEmpty($OutFile) + $transientStatusCodes = @(408, 429, 500, 502, 503, 504) + $maxAttempts = $MaxRetryCount + 1 + + # Build common parameters + $commonParams = @{ + Uri = $Uri + Method = $Method + ErrorAction = "Stop" + } + + if ($PSBoundParameters.ContainsKey("TimeoutSec")) { + $commonParams["TimeoutSec"] = $TimeoutSec + } + + if ($PSBoundParameters.ContainsKey("Body")) { + $commonParams["Body"] = $Body + } + + if ($PSBoundParameters.ContainsKey("ContentType")) { + $commonParams["ContentType"] = $ContentType + } + + if ($PSBoundParameters.ContainsKey("Headers")) { + $commonParams["Headers"] = $Headers + } + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + if ($isDownload) { + Invoke-WebRequest @commonParams -OutFile $OutFile + return + } + + if ($SkipHttpErrorCheck) { + $response = Invoke-WebRequest @commonParams -SkipHttpErrorCheck -UseBasicParsing + + $code = [int]$response.StatusCode + + if ($code -in $transientStatusCodes -and $attempt -lt $maxAttempts) { + Write-Warning "Request to $Uri returned status $code (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..." + Start-Sleep -Seconds $RetryIntervalSeconds + continue + } + + if ($ReturnStatusCode) { + return @{ + Result = $response + StatusCode = $code + } + } + + return $response + } + + return (Invoke-WebRequest @commonParams -UseBasicParsing) + } catch { + $responseCode = $null + if ($_.Exception.Response) { + $responseCode = [int]$_.Exception.Response.StatusCode + } + + $isTransient = $responseCode -in $transientStatusCodes + + if ($isTransient -and $attempt -lt $maxAttempts) { + Write-Warning "Request to $Uri failed with status $responseCode (attempt $attempt of $maxAttempts). Retrying in $RetryIntervalSeconds seconds..." + Start-Sleep -Seconds $RetryIntervalSeconds + } else { + throw + } + } + } +} diff --git a/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 b/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 index 1b38700..7e7dca9 100644 --- a/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-AlzModule.ps1 @@ -11,6 +11,9 @@ function Test-AlzModule { $hasFailure = $false $currentScope = "CurrentUser" + Write-ToConsoleLog "Checking ALZ module installation..." + Write-Verbose "Checking ALZ module installation" + $importedModule = Get-Module -Name ALZ $isDevelopmentModule = ($null -ne $importedModule -and $importedModule.Version -eq "0.1.0") diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 index ebeeef1..e311f12 100644 --- a/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-AzureCli.ps1 @@ -10,6 +10,7 @@ function Test-AzureCli { $azCliInstalledButNotLoggedIn = $false # Check if Azure CLI is installed + Write-ToConsoleLog "Checking Azure CLI installation..." Write-Verbose "Checking Azure CLI installation" $azCliPath = Get-Command az -ErrorAction SilentlyContinue if ($azCliPath) { diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 index a3b1a5a..6a61c62 100644 --- a/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-AzureDevOpsCli.ps1 @@ -5,6 +5,7 @@ function Test-AzureDevOpsCli { $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking Azure CLI installation for Azure DevOps..." Write-Verbose "Checking Azure CLI installation for Azure DevOps" $azCliPath = Get-Command az -ErrorAction SilentlyContinue diff --git a/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 b/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 index a0b7ad6..e9a7645 100644 --- a/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-AzureEnvironmentVariable.ps1 @@ -6,6 +6,7 @@ function Test-AzureEnvironmentVariable { $hasFailure = $false $envVarsValid = $false + Write-ToConsoleLog "Checking Azure environment variables..." Write-Verbose "Checking Azure environment variables" $nonAzCliEnvVars = @( "ARM_CLIENT_ID", diff --git a/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 index 339000b..705ac2f 100644 --- a/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-GitHubCli.ps1 @@ -5,6 +5,7 @@ function Test-GitHubCli { $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking GitHub CLI installation..." Write-Verbose "Checking GitHub CLI installation" $ghCliPath = Get-Command gh -ErrorAction SilentlyContinue diff --git a/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 b/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 index 94c236d..7b6686d 100644 --- a/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-GitInstallation.ps1 @@ -5,6 +5,7 @@ function Test-GitInstallation { $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking Git installation..." Write-Verbose "Checking Git installation" $gitPath = Get-Command git -ErrorAction SilentlyContinue diff --git a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 index 8e3439a..2700ff1 100644 --- a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 @@ -1,10 +1,22 @@ function Test-NetworkConnectivity { [CmdletBinding()] - param() + param( + [Parameter(Mandatory = $false)] + [int] $HttpRequestMaxRetryCount = 0, + + [Parameter(Mandatory = $false)] + [int] $HttpRequestRetryIntervalSeconds = 3, + + [Parameter(Mandatory = $false)] + [int] $HttpRequestTimeoutSeconds = 10 + ) $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking network connectivity to required endpoints..." + Write-Verbose "Checking network connectivity to required endpoints" + $endpoints = @( @{ Uri = "https://api.github.com"; Description = "GitHub API (release lookups)" }, @{ Uri = "https://github.com"; Description = "GitHub (module downloads)" }, @@ -18,9 +30,9 @@ function Test-NetworkConnectivity { Write-Verbose "Testing network connectivity to $($endpoint.Uri)" try { if ($endpoint.Uri -eq "https://api.github.com") { - Invoke-GitHubApiRequest -Uri $endpoint.Uri -Method Head -SkipHttpErrorCheck -MaxRetryCount 0 | Out-Null + Invoke-GitHubApiRequest -Uri $endpoint.Uri -Method Head -SkipHttpErrorCheck -MaxRetryCount $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds -TimeoutSec $HttpRequestTimeoutSeconds | Out-Null } else { - Invoke-WebRequest -Uri $endpoint.Uri -Method Head -TimeoutSec 10 -SkipHttpErrorCheck -ErrorAction Stop -UseBasicParsing | Out-Null + Invoke-HttpRequestWithRetry -Uri $endpoint.Uri -Method Head -TimeoutSec $HttpRequestTimeoutSeconds -SkipHttpErrorCheck -MaxRetryCount $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds | Out-Null } $results += @{ message = "Network connectivity to $($endpoint.Description) ($($endpoint.Uri)) is available." diff --git a/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 b/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 index abc9f10..ace277d 100644 --- a/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-PowerShellVersion.ps1 @@ -5,6 +5,7 @@ function Test-PowerShellVersion { $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking PowerShell version..." Write-Verbose "Checking PowerShell version" $powerShellVersionTable = $PSVersionTable $powerShellVersion = $powerShellVersionTable.PSVersion.ToString() diff --git a/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 b/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 index 737bb03..8ca0a81 100644 --- a/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-YamlModule.ps1 @@ -10,6 +10,7 @@ function Test-YamlModule { $results = @() $hasFailure = $false + Write-ToConsoleLog "Checking powershell-yaml module installation..." Write-Verbose "Checking powershell-yaml module installation" $yamlModule = Get-InstalledPSResource -Name powershell-yaml 2> $null | Select-Object -Property Name, Version | Sort-Object Version -Descending | Select-Object -First 1 if($null -eq $yamlModule) { diff --git a/src/ALZ/Private/Tools/Get-HCLParserTool.ps1 b/src/ALZ/Private/Tools/Get-HCLParserTool.ps1 index 450453c..6ad6a03 100644 --- a/src/ALZ/Private/Tools/Get-HCLParserTool.ps1 +++ b/src/ALZ/Private/Tools/Get-HCLParserTool.ps1 @@ -8,7 +8,13 @@ function Get-HCLParserTool { [string] $toolVersion, [Parameter(Mandatory = $false)] - [int] $maxRetryCount = 10 + [int] $maxRetryCount = 10, + + [Parameter(Mandatory = $false)] + [int] $retryIntervalSeconds = 3, + + [Parameter(Mandatory = $false)] + [int] $httpRequestTimeoutSeconds ) if ($PSCmdlet.ShouldProcess("Download Terraform Tools", "modify")) { @@ -32,7 +38,16 @@ function Get-HCLParserTool { $uri = "https://github.com/tmccombs/hcl2json/releases/download/$($toolVersion)/$($toolFileName)" Write-Verbose "Downloading Terraform HCL parser Tool from $uri" - Invoke-GitHubApiRequest -Uri $uri -OutputFile $toolFilePath -MaxRetryCount $maxRetryCount + $downloadParams = @{ + Uri = $uri + OutputFile = $toolFilePath + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $downloadParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + Invoke-GitHubApiRequest @downloadParams } if($osArchitecture.os -ne "windows") { diff --git a/src/ALZ/Private/Tools/Get-TerraformTool.ps1 b/src/ALZ/Private/Tools/Get-TerraformTool.ps1 index 5cc8a12..57c7515 100644 --- a/src/ALZ/Private/Tools/Get-TerraformTool.ps1 +++ b/src/ALZ/Private/Tools/Get-TerraformTool.ps1 @@ -4,13 +4,27 @@ function Get-TerraformTool { [Parameter(Mandatory = $false)] [string]$version = "latest", [Parameter(Mandatory = $false)] - [string]$toolsPath = ".\terraform" + [string]$toolsPath = ".\terraform", + [Parameter(Mandatory = $false)] + [int]$maxRetryCount = 10, + [Parameter(Mandatory = $false)] + [int]$retryIntervalSeconds = 3, + [Parameter(Mandatory = $false)] + [int]$httpRequestTimeoutSeconds ) $release = $null if($version -eq "latest") { - $versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20" + $httpParams = @{ + Uri = "https://api.releases.hashicorp.com/v1/releases/terraform?limit=20" + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $httpParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + $versionResponse = Invoke-HttpRequestWithRetry @httpParams if($versionResponse.StatusCode -ne "200") { throw "Unable to query Terraform version, please check your internet connection and try again..." } @@ -19,7 +33,15 @@ function Get-TerraformTool { $version = $releases[0].version Write-Verbose "Latest version of Terraform is $version" } else { - $versionResponse = Invoke-WebRequest -Uri "https://api.releases.hashicorp.com/v1/releases/terraform/$($version)" + $httpParams = @{ + Uri = "https://api.releases.hashicorp.com/v1/releases/terraform/$($version)" + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $httpParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + $versionResponse = Invoke-HttpRequestWithRetry @httpParams if($versionResponse.StatusCode -ne "200") { throw "Unable to query Terraform version, please check the supplied version and try again..." } @@ -55,7 +77,16 @@ function Get-TerraformTool { New-Item -ItemType Directory -Path $toolsPath| Out-String | Write-Verbose } - Invoke-WebRequest -Uri $url -OutFile "$zipfilePath" | Out-String | Write-Verbose + $downloadParams = @{ + Uri = $url + OutFile = "$zipfilePath" + MaxRetryCount = $maxRetryCount + RetryIntervalSeconds = $retryIntervalSeconds + } + if ($PSBoundParameters.ContainsKey("httpRequestTimeoutSeconds")) { + $downloadParams["TimeoutSec"] = $httpRequestTimeoutSeconds + } + Invoke-HttpRequestWithRetry @downloadParams | Out-String | Write-Verbose Expand-Archive -Path $zipfilePath -DestinationPath $unzipdir diff --git a/src/ALZ/Private/Tools/Test-Tooling.ps1 b/src/ALZ/Private/Tools/Test-Tooling.ps1 index 49a65fc..1efccd5 100644 --- a/src/ALZ/Private/Tools/Test-Tooling.ps1 +++ b/src/ALZ/Private/Tools/Test-Tooling.ps1 @@ -5,7 +5,16 @@ function Test-Tooling { [ValidateSet("PowerShell", "Git", "AzureCli", "AzureEnvVars", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "YamlModule", "YamlModuleAutoInstall", "GitHubCli", "AzureDevOpsCli", "NetworkConnectivity")] [string[]]$Checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion"), [Parameter(Mandatory = $false)] - [switch]$destroy + [switch]$destroy, + + [Parameter(Mandatory = $false)] + [int]$HttpRequestMaxRetryCount = 0, + + [Parameter(Mandatory = $false)] + [int]$HttpRequestRetryIntervalSeconds = 3, + + [Parameter(Mandatory = $false)] + [int]$HttpRequestTimeoutSeconds = 10 ) $checkResults = @() @@ -93,7 +102,7 @@ function Test-Tooling { # Check Network Connectivity if ($Checks -contains "NetworkConnectivity") { - $result = Test-NetworkConnectivity + $result = Test-NetworkConnectivity -HttpRequestMaxRetryCount $HttpRequestMaxRetryCount -HttpRequestRetryIntervalSeconds $HttpRequestRetryIntervalSeconds -HttpRequestTimeoutSeconds $HttpRequestTimeoutSeconds $checkResults += $result.Results if ($result.HasFailure) { $hasFailure = $true } } diff --git a/src/ALZ/Public/Deploy-Accelerator.ps1 b/src/ALZ/Public/Deploy-Accelerator.ps1 index 6ca158f..b5c033f 100644 --- a/src/ALZ/Public/Deploy-Accelerator.ps1 +++ b/src/ALZ/Public/Deploy-Accelerator.ps1 @@ -198,11 +198,30 @@ function Deploy-Accelerator { [Parameter( Mandatory = $false, - HelpMessage = "[OPTIONAL] Maximum number of retries for transient GitHub API errors. Defaults to 10. Environment variable: ALZ_github_max_retry_count. Config file input: github_max_retry_count." + HelpMessage = "[OPTIONAL] Maximum number of retries for transient HTTP request errors. Defaults to 10. Environment variable: ALZ_http_request_max_retry_count. Config file input: http_request_max_retry_count." )] + [Alias("hrmrc")] + [Alias("httpRequestMaxRetryCount")] [Alias("gmrc")] [Alias("githubMaxRetryCount")] - [int] $github_max_retry_count = 10 + [Alias("github_max_retry_count")] + [int] $http_request_max_retry_count = 10, + + [Parameter( + Mandatory = $false, + HelpMessage = "[OPTIONAL] Seconds to wait between retries for transient HTTP request errors. Defaults to 3. Environment variable: ALZ_http_request_retry_interval_seconds. Config file input: http_request_retry_interval_seconds." + )] + [Alias("hrris")] + [Alias("httpRequestRetryIntervalSeconds")] + [int] $http_request_retry_interval_seconds = 3, + + [Parameter( + Mandatory = $false, + HelpMessage = "[OPTIONAL] Timeout in seconds for HTTP requests. If not specified, uses the default timeout. Environment variable: ALZ_http_request_timeout_seconds. Config file input: http_request_timeout_seconds." + )] + [Alias("hrts")] + [Alias("httpRequestTimeoutSeconds")] + [int] $http_request_timeout_seconds ) $ProgressPreference = "SilentlyContinue" @@ -242,9 +261,9 @@ function Deploy-Accelerator { # Check software requirements first before any prompting $toolingResult = $null if ($skip_requirements_check.IsPresent) { - Write-ToConsoleLog "WARNING: Skipping the software requirements check..." -IsWarning + Write-ToConsoleLog "Skipping the software requirements check..." -IsWarning } else { - Write-ToConsoleLog "Checking the software requirements for the Accelerator..." + Write-ToConsoleLog "Checking the software and connectivity requirements for the Accelerator..." $checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AlzModule") if (-not $needsFolderStructureSetup) { $checks += "AzureLogin" @@ -261,7 +280,16 @@ function Deploy-Accelerator { if (-not $skip_internet_checks.IsPresent) { $checks += "NetworkConnectivity" } - $toolingResult = Test-Tooling -Checks $checks -destroy:$destroy.IsPresent + $toolingParams = @{ + Checks = $checks + destroy = $destroy.IsPresent + HttpRequestMaxRetryCount = $http_request_max_retry_count + HttpRequestRetryIntervalSeconds = $http_request_retry_interval_seconds + } + if ($PSBoundParameters.ContainsKey("http_request_timeout_seconds")) { + $toolingParams["HttpRequestTimeoutSeconds"] = $http_request_timeout_seconds + } + $toolingResult = Test-Tooling @toolingParams } # If az cli is installed but not logged in, prompt for tenant ID and login with device code @@ -328,8 +356,27 @@ function Deploy-Accelerator { Write-ToConsoleLog "Skipping Terraform tool check as you used the skipInternetCheck parameter. Please ensure you have the most recent version of Terraform installed" -IsWarning } else { Write-ToConsoleLog "Checking you have the latest version of Terraform installed..." -IsSuccess - Get-TerraformTool -version "latest" -toolsPath $toolsPath - $hclParserToolPath = Get-HCLParserTool -toolVersion "v0.6.0" -toolsPath $toolsPath -maxRetryCount $github_max_retry_count + $terraformToolParams = @{ + version = "latest" + toolsPath = $toolsPath + maxRetryCount = $http_request_max_retry_count + retryIntervalSeconds = $http_request_retry_interval_seconds + } + if ($PSBoundParameters.ContainsKey("http_request_timeout_seconds")) { + $terraformToolParams["httpRequestTimeoutSeconds"] = $http_request_timeout_seconds + } + Get-TerraformTool @terraformToolParams + + $hclParserToolParams = @{ + toolVersion = "v0.6.0" + toolsPath = $toolsPath + maxRetryCount = $http_request_max_retry_count + retryIntervalSeconds = $http_request_retry_interval_seconds + } + if ($PSBoundParameters.ContainsKey("http_request_timeout_seconds")) { + $hclParserToolParams["httpRequestTimeoutSeconds"] = $http_request_timeout_seconds + } + $hclParserToolPath = Get-HCLParserTool @hclParserToolParams } # Get User Inputs from the input config file @@ -397,18 +444,25 @@ function Deploy-Accelerator { $inputConfig.bootstrap_module_override_folder_path.Value = Join-Path $HOME $inputConfig.bootstrap_module_override_folder_path.Value.Replace("~/", "") } - $versionAndPath = New-ModuleSetup ` - -targetDirectory $inputConfig.output_folder_path.Value ` - -targetFolder $bootstrapTargetFolder ` - -sourceFolder $inputConfig.bootstrap_source_folder.Value ` - -url $inputConfig.bootstrap_module_url.Value ` - -release $inputConfig.bootstrap_module_version.Value ` - -releaseArtifactName $inputConfig.bootstrap_module_release_artifact_name.Value ` - -moduleOverrideFolderPath $inputConfig.bootstrap_module_override_folder_path.Value ` - -skipInternetChecks $inputConfig.skip_internet_checks.Value ` - -replaceFile:$inputConfig.replace_files.Value ` - -upgrade:$inputConfig.upgrade.Value ` - -autoApprove:$inputConfig.auto_approve.Value + $bootstrapModuleSetupParams = @{ + targetDirectory = $inputConfig.output_folder_path.Value + targetFolder = $bootstrapTargetFolder + sourceFolder = $inputConfig.bootstrap_source_folder.Value + url = $inputConfig.bootstrap_module_url.Value + release = $inputConfig.bootstrap_module_version.Value + releaseArtifactName = $inputConfig.bootstrap_module_release_artifact_name.Value + moduleOverrideFolderPath = $inputConfig.bootstrap_module_override_folder_path.Value + skipInternetChecks = $inputConfig.skip_internet_checks.Value + replaceFiles = $inputConfig.replace_files.Value + upgrade = $inputConfig.upgrade.Value + autoApprove = $inputConfig.auto_approve.Value + maxRetryCount = $http_request_max_retry_count + retryIntervalSeconds = $http_request_retry_interval_seconds + } + if ($PSBoundParameters.ContainsKey("http_request_timeout_seconds")) { + $bootstrapModuleSetupParams["httpRequestTimeoutSeconds"] = $http_request_timeout_seconds + } + $versionAndPath = New-ModuleSetup @bootstrapModuleSetupParams $bootstrapReleaseTag = $versionAndPath.releaseTag $bootstrapPath = $versionAndPath.path @@ -459,18 +513,25 @@ function Deploy-Accelerator { $inputConfig.starter_module_override_folder_path.Value = Join-Path $HOME $inputConfig.starter_module_override_folder_path.Value.Replace("~/", "") } - $versionAndPath = New-ModuleSetup ` - -targetDirectory $inputConfig.output_folder_path.Value ` - -targetFolder $starterModuleTargetFolder ` - -sourceFolder $starterModuleSourceFolder ` - -url $starterModuleUrl ` - -release $inputConfig.starter_module_version.Value ` - -releaseArtifactName $starterReleaseArtifactName ` - -moduleOverrideFolderPath $inputConfig.starter_module_override_folder_path.Value ` - -skipInternetChecks $inputConfig.skip_internet_checks.Value ` - -replaceFile:$inputConfig.replace_files.Value ` - -upgrade:$inputConfig.upgrade.Value ` - -autoApprove:$inputConfig.auto_approve.Value + $starterModuleSetupParams = @{ + targetDirectory = $inputConfig.output_folder_path.Value + targetFolder = $starterModuleTargetFolder + sourceFolder = $starterModuleSourceFolder + url = $starterModuleUrl + release = $inputConfig.starter_module_version.Value + releaseArtifactName = $starterReleaseArtifactName + moduleOverrideFolderPath = $inputConfig.starter_module_override_folder_path.Value + skipInternetChecks = $inputConfig.skip_internet_checks.Value + replaceFiles = $inputConfig.replace_files.Value + upgrade = $inputConfig.upgrade.Value + autoApprove = $inputConfig.auto_approve.Value + maxRetryCount = $http_request_max_retry_count + retryIntervalSeconds = $http_request_retry_interval_seconds + } + if ($PSBoundParameters.ContainsKey("http_request_timeout_seconds")) { + $starterModuleSetupParams["httpRequestTimeoutSeconds"] = $http_request_timeout_seconds + } + $versionAndPath = New-ModuleSetup @starterModuleSetupParams $starterReleaseTag = $versionAndPath.releaseTag $starterPath = $versionAndPath.path diff --git a/src/ALZ/Public/Test-AcceleratorRequirement.ps1 b/src/ALZ/Public/Test-AcceleratorRequirement.ps1 index 0e0698a..c067c01 100644 --- a/src/ALZ/Public/Test-AcceleratorRequirement.ps1 +++ b/src/ALZ/Public/Test-AcceleratorRequirement.ps1 @@ -23,7 +23,37 @@ function Test-AcceleratorRequirement { HelpMessage = "[OPTIONAL] Specifies which checks to run. Valid values: PowerShell, Git, AzureCli, AzureEnvVars, AzureCliOrEnvVars, AzureLogin, AlzModule, AlzModuleVersion, YamlModule, YamlModuleAutoInstall, GitHubCli, AzureDevOpsCli, NetworkConnectivity" )] [ValidateSet("PowerShell", "Git", "AzureCli", "AzureEnvVars", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "YamlModule", "YamlModuleAutoInstall", "GitHubCli", "AzureDevOpsCli", "NetworkConnectivity")] - [string[]]$Checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "NetworkConnectivity") + [string[]]$Checks = @("PowerShell", "Git", "AzureCliOrEnvVars", "AzureLogin", "AlzModule", "AlzModuleVersion", "NetworkConnectivity"), + + [Parameter( + Mandatory = $false, + HelpMessage = "[OPTIONAL] Maximum number of retries for transient HTTP request errors during network connectivity checks. Defaults to 0." + )] + [Alias("hrmrc")] + [Alias("httpRequestMaxRetryCount")] + [int] $http_request_max_retry_count = 0, + + [Parameter( + Mandatory = $false, + HelpMessage = "[OPTIONAL] Seconds to wait between retries for transient HTTP request errors during network connectivity checks. Defaults to 3." + )] + [Alias("hrris")] + [Alias("httpRequestRetryIntervalSeconds")] + [int] $http_request_retry_interval_seconds = 3, + + [Parameter( + Mandatory = $false, + HelpMessage = "[OPTIONAL] Timeout in seconds for HTTP requests during network connectivity checks. Defaults to 10." + )] + [Alias("hrts")] + [Alias("httpRequestTimeoutSeconds")] + [int] $http_request_timeout_seconds = 10 ) - Test-Tooling -Checks $Checks + $toolingParams = @{ + Checks = $Checks + HttpRequestMaxRetryCount = $http_request_max_retry_count + HttpRequestRetryIntervalSeconds = $http_request_retry_interval_seconds + HttpRequestTimeoutSeconds = $http_request_timeout_seconds + } + Test-Tooling @toolingParams } diff --git a/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 b/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 index 354576f..53cb338 100644 --- a/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 +++ b/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 @@ -20,7 +20,7 @@ InModuleScope 'ALZ' { Context 'All endpoints are reachable' { BeforeAll { - Mock -CommandName Invoke-WebRequest -MockWith { + Mock -CommandName Invoke-HttpRequestWithRetry -MockWith { [PSCustomObject]@{ StatusCode = 200 } } Mock -CommandName Invoke-GitHubApiRequest -MockWith { @@ -51,7 +51,7 @@ InModuleScope 'ALZ' { Mock -CommandName Invoke-GitHubApiRequest -MockWith { throw "Unable to connect to the remote server" } - Mock -CommandName Invoke-WebRequest -MockWith { + Mock -CommandName Invoke-HttpRequestWithRetry -MockWith { [PSCustomObject]@{ StatusCode = 200 } } } @@ -83,7 +83,7 @@ InModuleScope 'ALZ' { Context 'All endpoints are unreachable' { BeforeAll { - Mock -CommandName Invoke-WebRequest -MockWith { + Mock -CommandName Invoke-HttpRequestWithRetry -MockWith { throw "Network unreachable" } Mock -CommandName Invoke-GitHubApiRequest -MockWith { @@ -110,7 +110,7 @@ InModuleScope 'ALZ' { It 'checks all endpoints and does not stop at the first failure' { $result = Test-NetworkConnectivity - Should -Invoke -CommandName Invoke-WebRequest -Times 5 -Scope It + Should -Invoke -CommandName Invoke-HttpRequestWithRetry -Times 5 -Scope It Should -Invoke -CommandName Invoke-GitHubApiRequest -Times 1 -Scope It } }