From 1a67e1d3b635a6c3e0244bde7792155cf7e4573d Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 23 Apr 2026 16:38:36 +0100 Subject: [PATCH 1/2] fix: handle expired gh CLI tokens and macOS restricted file access - Retry GitHub API calls without auth on 401/403 when gh CLI token is rejected, with clear warning pointing users to 'gh auth login'. - Emit actionable error in Get-GithubReleaseTag for 401/403 instead of the misleading 'check your internet connection' message. - Flag 401/403 from api.github.com in Test-NetworkConnectivity with guidance on refreshing gh CLI credentials. - Add -Force to Get-Item, Get-ChildItem, and Get-Content calls across the module to avoid macOS access errors on protected paths. - Note that 'Querying Azure for management groups, subscriptions, and regions...' can take up to 30 seconds. --- src/ALZ/ALZ.psm1 | 1 + .../Edit-ALZConfigurationFilesInPlace.ps1 | 4 +- .../Private/Config-Helpers/Get-ALZConfig.ps1 | 6 +-- .../Config-Helpers/Get-AzureRegionData.ps1 | 2 +- .../Get-AcceleratorFolderConfiguration.ps1 | 2 +- .../Get-AzureContext.ps1 | 6 +-- .../Get-ModuleVersionData.ps1 | 2 +- .../New-Bootstrap.ps1 | 6 +-- .../New-ModuleSetup.ps1 | 2 +- .../Request-ALZConfigurationValue.ps1 | 4 +- .../Request-AcceleratorConfigurationInput.ps1 | 2 +- .../Set-ModuleVersionData.ps1 | 2 +- src/ALZ/Private/Shared/Get-GithubRelease.ps1 | 6 +-- .../Private/Shared/Get-GithubReleaseTag.ps1 | 6 +++ .../Shared/Invoke-GitHubApiRequest.ps1 | 49 ++++++++++++++++++- .../Tools/Checks/Test-NetworkConnectivity.ps1 | 14 +++++- .../Public/New-AcceleratorFolderStructure.ps1 | 2 +- .../Public/Remove-AzureDevOpsAccelerator.ps1 | 2 +- src/ALZ/Public/Remove-GitHubAccelerator.ps1 | 2 +- src/ALZ/Public/Remove-PlatformLandingZone.ps1 | 2 +- 20 files changed, 93 insertions(+), 29 deletions(-) diff --git a/src/ALZ/ALZ.psm1 b/src/ALZ/ALZ.psm1 index f676807e..698265b1 100644 --- a/src/ALZ/ALZ.psm1 +++ b/src/ALZ/ALZ.psm1 @@ -4,6 +4,7 @@ Write-Verbose "Discovering Public & Private src." $itemSplat = @{ Filter = '*.ps1' Recurse = $true + Force = $true ErrorAction = 'Stop' } try { diff --git a/src/ALZ/Private/Config-Helpers/Edit-ALZConfigurationFilesInPlace.ps1 b/src/ALZ/Private/Config-Helpers/Edit-ALZConfigurationFilesInPlace.ps1 index 3111bcbd..7de05d57 100644 --- a/src/ALZ/Private/Config-Helpers/Edit-ALZConfigurationFilesInPlace.ps1 +++ b/src/ALZ/Private/Config-Helpers/Edit-ALZConfigurationFilesInPlace.ps1 @@ -15,12 +15,12 @@ function Edit-ALZConfigurationFilesInPlace { foreach ($location in $locations) { $bicepModules = Join-Path $alzEnvironmentDestination $location - $files += @(Get-ChildItem -Path $bicepModules -Recurse -Filter *.parameters.*.json) + $files += @(Get-ChildItem -Path $bicepModules -Recurse -Filter *.parameters.*.json -Force) } foreach ($file in $files) { Write-Verbose "Checking Bicep parameter file: $($file.Name)" - $bicepConfiguration = Get-Content $file.FullName | ConvertFrom-Json -AsHashtable + $bicepConfiguration = Get-Content $file.FullName -Force | ConvertFrom-Json -AsHashtable $modified = $false foreach ($configKey in $configuration.PsObject.Properties) { diff --git a/src/ALZ/Private/Config-Helpers/Get-ALZConfig.ps1 b/src/ALZ/Private/Config-Helpers/Get-ALZConfig.ps1 index 4e23bc84..76bd022d 100644 --- a/src/ALZ/Private/Config-Helpers/Get-ALZConfig.ps1 +++ b/src/ALZ/Private/Config-Helpers/Get-ALZConfig.ps1 @@ -18,11 +18,11 @@ function Get-ALZConfig { } # Import the config and transform it to a PowerShell object - $extension = (Get-Item -Path $configFilePath).Extension.ToLower() + $extension = (Get-Item -Path $configFilePath -Force).Extension.ToLower() $config = $null if ($extension -eq ".yml" -or $extension -eq ".yaml") { try { - $config = [PSCustomObject](Get-Content -Path $configFilePath | ConvertFrom-Yaml -Ordered) + $config = [PSCustomObject](Get-Content -Path $configFilePath -Force | ConvertFrom-Yaml -Ordered) } catch { $errorMessage = "Failed to parse YAML inputs. Please check the YAML file for errors and try again. $_" Write-ToConsoleLog $errorMessage -IsError @@ -31,7 +31,7 @@ function Get-ALZConfig { } elseif ($extension -eq ".json") { try { - $config = [PSCustomObject](Get-Content -Path $configFilePath | ConvertFrom-Json) + $config = [PSCustomObject](Get-Content -Path $configFilePath -Force | ConvertFrom-Json) } catch { $errorMessage = "Failed to parse JSON inputs. Please check the JSON file for errors and try again. $_" Write-ToConsoleLog $errorMessage -IsError diff --git a/src/ALZ/Private/Config-Helpers/Get-AzureRegionData.ps1 b/src/ALZ/Private/Config-Helpers/Get-AzureRegionData.ps1 index c473cf5d..c695a0d6 100644 --- a/src/ALZ/Private/Config-Helpers/Get-AzureRegionData.ps1 +++ b/src/ALZ/Private/Config-Helpers/Get-AzureRegionData.ps1 @@ -49,7 +49,7 @@ function Get-AzureRegionData { Invoke-Terraform -moduleFolderPath $regionFolder -autoApprove -output "regions_and_zones" -outputFilePath $outputFilePath -silent - $json = Get-Content $outputFilePath + $json = Get-Content $outputFilePath -Force $regionsAndZones = ConvertFrom-Json $json $zonesSupport = @() diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 index 8c24ac45..00e86c91 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AcceleratorFolderConfiguration.ps1 @@ -67,7 +67,7 @@ function Get-AcceleratorFolderConfiguration { # Try to read and validate inputs.yaml try { - $inputsContent = Get-Content -Path $inputsYamlPath -Raw + $inputsContent = Get-Content -Path $inputsYamlPath -Raw -Force $inputsYaml = $inputsContent | ConvertFrom-Yaml $result.InputsContent = $inputsContent diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 index 2e9f8f02..41a208a3 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-AzureContext.ps1 @@ -40,11 +40,11 @@ function Get-AzureContext { # Check if valid cache exists if (Test-Path $cacheFilePath) { - $cacheFile = Get-Item $cacheFilePath + $cacheFile = Get-Item $cacheFilePath -Force $cacheAge = (Get-Date) - $cacheFile.LastWriteTime if ($cacheAge.TotalHours -lt $cacheExpirationHours) { try { - $cachedContext = Get-Content -Path $cacheFilePath -Raw | ConvertFrom-Json -AsHashtable + $cachedContext = Get-Content -Path $cacheFilePath -Raw -Force | ConvertFrom-Json -AsHashtable Write-ToConsoleLog "Using cached Azure context (cached $([math]::Round($cacheAge.TotalMinutes)) minutes ago). Use -clearCache to refresh." Write-ToConsoleLog "Found $($cachedContext.ManagementGroups.Count) management groups, $($cachedContext.Subscriptions.Count) subscriptions, and $($cachedContext.Regions.Count) regions" return $cachedContext @@ -60,7 +60,7 @@ function Get-AzureContext { Regions = @() } - Write-ToConsoleLog "Querying Azure for management groups, subscriptions, and regions..." + Write-ToConsoleLog "Querying Azure for management groups, subscriptions, and regions... (this can take up to 30 seconds)" try { # Get the current tenant ID diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-ModuleVersionData.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-ModuleVersionData.ps1 index e839489a..f1bf9e4e 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-ModuleVersionData.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Get-ModuleVersionData.ps1 @@ -12,7 +12,7 @@ function Get-ModuleVersionData { $dataFilePath = Join-Path $targetDirectory ".alz-version-data.json" if (Test-Path $dataFilePath) { - $data = Get-Content $dataFilePath | ConvertFrom-Json + $data = Get-Content $dataFilePath -Force | ConvertFrom-Json $versionKey = "$($moduleType)Version" return $data.$versionKey } diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 index 3c28fdd7..1f1d30c3 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-Bootstrap.ps1 @@ -132,7 +132,7 @@ function New-Bootstrap { $bootstrapParameters = [PSCustomObject]@{} Write-Verbose "Getting the bootstrap configuration..." - $terraformFiles = Get-ChildItem -Path $bootstrapModulePath -Filter "*.tf" -File + $terraformFiles = Get-ChildItem -Path $bootstrapModulePath -Filter "*.tf" -File -Force foreach ($terraformFile in $terraformFiles) { $bootstrapParameters = Convert-HCLVariablesToInputConfig -targetVariableFile $terraformFile.FullName -hclParserToolPath $hclParserToolPath -appendToObject $bootstrapParameters } @@ -145,7 +145,7 @@ function New-Bootstrap { if ($hasStarter) { Write-Verbose "Getting the starter configuration..." if ($iac -eq "terraform") { - $terraformFiles = Get-ChildItem -Path $starterRootModuleFolderPath -Filter "*.tf" -File + $terraformFiles = Get-ChildItem -Path $starterRootModuleFolderPath -Filter "*.tf" -File -Force foreach ($terraformFile in $terraformFiles) { $starterParameters = Convert-HCLVariablesToInputConfig -targetVariableFile $terraformFile.FullName -hclParserToolPath $hclParserToolPath -appendToObject $starterParameters } @@ -215,7 +215,7 @@ function New-Bootstrap { if ($iac -eq "terraform") { if ($starterFoldersToRetain.Length -gt 0) { Write-Verbose "Removing unwanted folders from the starter module..." - $folders = Get-ChildItem -Path $starterModulePath -Directory + $folders = Get-ChildItem -Path $starterModulePath -Directory -Force foreach ($folder in $folders) { if ($starterFoldersToRetain -notcontains $folder.Name) { Write-Verbose "Removing folder: $($folder.FullName)" diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 index fad1292c..08e5d793 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/New-ModuleSetup.ps1 @@ -177,7 +177,7 @@ function New-ModuleSetup { if (!$firstRun) { Write-Verbose "Checking for state files at: $previousStatePath" - $previousStateFiles = Get-ChildItem $previousVersionPath -Filter "terraform.tfstate" -Recurse | Select-Object -First 1 | ForEach-Object { $_.FullName } + $previousStateFiles = Get-ChildItem $previousVersionPath -Filter "terraform.tfstate" -Recurse -Force | Select-Object -First 1 | ForEach-Object { $_.FullName } if ($previousStateFiles.Count -gt 0) { foreach ($stateFile in $previousStateFiles) { diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 index e17a8842..17cb8b96 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-ALZConfigurationValue.ps1 @@ -178,7 +178,7 @@ function Request-ALZConfigurationValue { Write-ToConsoleLog "Schema file not found at $schemaPath. Proceeding without descriptions." -IsWarning $schema = $null } else { - $schema = Get-Content -Path $schemaPath -Raw | ConvertFrom-Json + $schema = Get-Content -Path $schemaPath -Raw -Force | ConvertFrom-Json } # Define the configuration files to process @@ -192,7 +192,7 @@ function Request-ALZConfigurationValue { Write-ToConsoleLog "For more information, see: https://aka.ms/alz/acc/phase0" # Read the raw content to preserve comments and ordering - $inputsYamlContent = Get-Content -Path $inputsYamlPath -Raw + $inputsYamlContent = Get-Content -Path $inputsYamlPath -Raw -Force $inputsConfig = $inputsYamlContent | ConvertFrom-Yaml -Ordered $inputsUpdated = $false $sensitiveEnvVars = @{} diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 index 670153e4..9ec52c70 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Request-AcceleratorConfigurationInput.ps1 @@ -162,7 +162,7 @@ function Request-AcceleratorConfigurationInput { # Prompt for scenario number (Terraform only) if ($selectedIacType -eq "terraform") { $scenariosJsonPath = Join-Path $PSScriptRoot "TerraformScenarios.json" - $scenarioOptions = Get-Content -Path $scenariosJsonPath -Raw | ConvertFrom-Json + $scenarioOptions = Get-Content -Path $scenariosJsonPath -Raw -Force | ConvertFrom-Json $selectedScenarioNumber = Read-MenuSelection ` -Title "Select the Terraform scenario (see https://aka.ms/alz/acc/scenarios):" ` diff --git a/src/ALZ/Private/Deploy-Accelerator-Helpers/Set-ModuleVersionData.ps1 b/src/ALZ/Private/Deploy-Accelerator-Helpers/Set-ModuleVersionData.ps1 index 807eb926..d42bdbb3 100644 --- a/src/ALZ/Private/Deploy-Accelerator-Helpers/Set-ModuleVersionData.ps1 +++ b/src/ALZ/Private/Deploy-Accelerator-Helpers/Set-ModuleVersionData.ps1 @@ -17,7 +17,7 @@ function Set-ModuleVersionData { # Load existing data or create new if (Test-Path $dataFilePath) { - $data = Get-Content $dataFilePath | ConvertFrom-Json + $data = Get-Content $dataFilePath -Force | ConvertFrom-Json } else { $data = [PSCustomObject]@{ bootstrapVersion = $null diff --git a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 index c4f07eca..14474904 100644 --- a/src/ALZ/Private/Shared/Get-GithubRelease.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubRelease.ps1 @@ -101,7 +101,7 @@ function Get-GithubRelease { Write-Verbose "===> Checking if any content exists inside of $targetVersionPath" - $contentTargetVersionPath = Get-ChildItem -Path $targetVersionPath -Recurse -ErrorAction SilentlyContinue + $contentTargetVersionPath = Get-ChildItem -Path $targetVersionPath -Recurse -Force -ErrorAction SilentlyContinue if ($null -eq $contentTargetVersionPath) { Write-Verbose "===> Pulling and extracting release $releaseTag into $targetVersionPath" @@ -139,7 +139,7 @@ function Get-GithubRelease { $extractedSubFolder = $targetPathForExtractedZip if($releaseArtifactName -eq "") { - $extractedSubFolder = (Get-ChildItem -Path $targetPathForExtractedZip -Directory).FullName + $extractedSubFolder = (Get-ChildItem -Path $targetPathForExtractedZip -Directory -Force).FullName } Write-Verbose "===> Copying all extracted contents into $targetVersionPath from $($extractedSubFolder)/$moduleSourceFolder/*." @@ -157,7 +157,7 @@ function Get-GithubRelease { $envFilePath = Join-Path -Path $parentDirectory -ChildPath ".env" if (Test-Path $envFilePath) { Write-Verbose "===> Replacing the .env file release version with $releaseTag" - (Get-Content $envFilePath) -replace "UPSTREAM_RELEASE_VERSION=.*", "UPSTREAM_RELEASE_VERSION=$releaseTag" | Set-Content $envFilePath + (Get-Content $envFilePath -Force) -replace "UPSTREAM_RELEASE_VERSION=.*", "UPSTREAM_RELEASE_VERSION=$releaseTag" | Set-Content $envFilePath -Force } return $releaseTag diff --git a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 index 00e2123a..e143afe3 100644 --- a/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 +++ b/src/ALZ/Private/Shared/Get-GithubReleaseTag.ps1 @@ -76,6 +76,12 @@ function Get-GithubReleaseTag { throw "The release $release does not exist in the GitHub repository $githubRepoUrl - $repoReleaseUrl" } + if ($statusCode -eq 401 -or $statusCode -eq 403) { + $message = "Unable to query repository version from $repoReleaseUrl. HTTP status code: $statusCode (Forbidden/Unauthorized). This is most often caused by an expired or invalid GitHub CLI authentication token, or by GitHub API rate limiting on anonymous requests. If you have the GitHub CLI (gh) installed, run 'gh auth login' (or 'gh auth logout' followed by 'gh auth login') to refresh your credentials, then re-run the command. If you do not use the GitHub CLI, wait a few minutes for the rate limit to reset before retrying." + Write-ToConsoleLog $message -IsError + throw $message + } + if ($statusCode -ne 200) { Write-ToConsoleLog "Unable to query repository version from $repoReleaseUrl. HTTP status code: $statusCode" -IsError throw "Unable to query repository version, please check your internet connection and try again..." diff --git a/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 b/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 index d57ed148..43fc2f7e 100644 --- a/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 +++ b/src/ALZ/Private/Shared/Invoke-GitHubApiRequest.ps1 @@ -74,6 +74,7 @@ function Invoke-GitHubApiRequest { # Build auth headers from gh CLI if available $headers = @{} + $usedGhToken = $false $ghCommand = Get-Command "gh" -ErrorAction SilentlyContinue if ($null -ne $ghCommand) { $null = & gh auth status 2>&1 @@ -81,6 +82,7 @@ function Invoke-GitHubApiRequest { $token = & gh auth token 2>&1 if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($token)) { $headers["Authorization"] = "Bearer $($token.Trim())" + $usedGhToken = $true Write-Verbose "GitHub CLI authentication token found. Using authenticated requests." } } else { @@ -106,9 +108,37 @@ function Invoke-GitHubApiRequest { $retryParams["Headers"] = $headers } + # If the gh CLI token is rejected (401/403), the token is likely expired or + # has insufficient scopes. Drop the Authorization header so the next attempt + # is anonymous (subject to lower rate limits) and warn the user to refresh + # their gh credentials with `gh auth login`. + function Disable-AuthAndWarn { + param([int] $StatusCode) + Write-ToConsoleLog "GitHub API request to $Uri was rejected with status $StatusCode while using the GitHub CLI authentication token. The token may be expired or have insufficient scopes. Retrying without authentication. To resolve this permanently, run 'gh auth login' (or 'gh auth logout' followed by 'gh auth login') to refresh your GitHub CLI credentials." -IsWarning + $retryParams.Remove("Headers") + } + + function Get-StatusCodeFromError { + param($ErrorRecord) + if ($ErrorRecord.Exception.Response) { + return [int]$ErrorRecord.Exception.Response.StatusCode + } + return $null + } + # File download — delegate directly if (-not [string]::IsNullOrEmpty($OutputFile)) { - Invoke-HttpRequestWithRetry @retryParams -OutFile $OutputFile + try { + Invoke-HttpRequestWithRetry @retryParams -OutFile $OutputFile + } catch { + $statusCode = Get-StatusCodeFromError $_ + if ($usedGhToken -and ($statusCode -eq 401 -or $statusCode -eq 403)) { + Disable-AuthAndWarn -StatusCode $statusCode + Invoke-HttpRequestWithRetry @retryParams -OutFile $OutputFile + } else { + throw + } + } return } @@ -116,6 +146,11 @@ function Invoke-GitHubApiRequest { if ($SkipHttpErrorCheck) { $response = Invoke-HttpRequestWithRetry @retryParams -SkipHttpErrorCheck -ReturnStatusCode + if ($usedGhToken -and ($response.StatusCode -eq 401 -or $response.StatusCode -eq 403)) { + Disable-AuthAndWarn -StatusCode $response.StatusCode + $response = Invoke-HttpRequestWithRetry @retryParams -SkipHttpErrorCheck -ReturnStatusCode + } + $parsed = $null if (-not [string]::IsNullOrWhiteSpace($response.Result.Content)) { $parsed = $response.Result.Content | ConvertFrom-Json @@ -128,7 +163,17 @@ function Invoke-GitHubApiRequest { } # Standard API call — parse JSON and return the object - $response = Invoke-HttpRequestWithRetry @retryParams + try { + $response = Invoke-HttpRequestWithRetry @retryParams + } catch { + $statusCode = Get-StatusCodeFromError $_ + if ($usedGhToken -and ($statusCode -eq 401 -or $statusCode -eq 403)) { + Disable-AuthAndWarn -StatusCode $statusCode + $response = Invoke-HttpRequestWithRetry @retryParams + } else { + throw + } + } if (-not [string]::IsNullOrWhiteSpace($response.Content)) { return ($response.Content | ConvertFrom-Json) } diff --git a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 index 2700ff13..9d475b86 100644 --- a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 @@ -30,7 +30,19 @@ 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 $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds -TimeoutSec $HttpRequestTimeoutSeconds | Out-Null + $response = Invoke-GitHubApiRequest -Uri $endpoint.Uri -Method Head -SkipHttpErrorCheck -MaxRetryCount $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds -TimeoutSec $HttpRequestTimeoutSeconds + $statusCode = $null + if ($null -ne $response) { + $statusCode = $response.StatusCode + } + if ($statusCode -eq 401 -or $statusCode -eq 403) { + $results += @{ + message = "GitHub API ($($endpoint.Uri)) returned HTTP $statusCode. This is most often caused by an expired or invalid GitHub CLI authentication token, or by GitHub API rate limiting. If you have the GitHub CLI (gh) installed, run 'gh auth login' (or 'gh auth logout' followed by 'gh auth login') to refresh your credentials. Otherwise wait a few minutes for the rate limit to reset before retrying." + result = "Failure" + } + $hasFailure = $true + continue + } } else { Invoke-HttpRequestWithRetry -Uri $endpoint.Uri -Method Head -TimeoutSec $HttpRequestTimeoutSeconds -SkipHttpErrorCheck -MaxRetryCount $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds | Out-Null } diff --git a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 index ed43b0dc..feb27201 100644 --- a/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 +++ b/src/ALZ/Public/New-AcceleratorFolderStructure.ps1 @@ -131,7 +131,7 @@ function New-AcceleratorFolderStructure { # Copy the platform landing zone configuration files based on scenario number or specific file path if ($repo.hasScenarios) { $scenariosJsonPath = Join-Path $PSScriptRoot ".." "Private" "Deploy-Accelerator-Helpers" "TerraformScenarios.json" - $scenarioOptions = Get-Content -Path $scenariosJsonPath -Raw | ConvertFrom-Json + $scenarioOptions = Get-Content -Path $scenariosJsonPath -Raw -Force | ConvertFrom-Json $scenarios = @{} foreach ($scenario in $scenarioOptions) { $scenarios[[int]$scenario.value] = $scenario.path diff --git a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 index 00a67aae..81896db5 100644 --- a/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 +++ b/src/ALZ/Public/Remove-AzureDevOpsAccelerator.ps1 @@ -341,7 +341,7 @@ function Remove-AzureDevOpsAccelerator { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning - $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw + $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw -Force Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } diff --git a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 index 2e0eb077..0c8c3dab 100644 --- a/src/ALZ/Public/Remove-GitHubAccelerator.ps1 +++ b/src/ALZ/Public/Remove-GitHubAccelerator.ps1 @@ -397,7 +397,7 @@ function Remove-GitHubAccelerator { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning - $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw + $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw -Force Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } diff --git a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 index 23793322..a9035c1f 100644 --- a/src/ALZ/Public/Remove-PlatformLandingZone.ps1 +++ b/src/ALZ/Public/Remove-PlatformLandingZone.ps1 @@ -1329,7 +1329,7 @@ function Remove-PlatformLandingZone { if($PlanMode) { Write-ToConsoleLog "Plan mode enabled, no changes were made." -IsWarning - $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw + $planLogContents = Get-Content -Path $TempLogFileForPlan -Raw -Force Write-ToConsoleLog @("Plan mode log contents:", $planLogContents) -Color Gray Remove-Item -Path $TempLogFileForPlan -Force } From 224a11b133d3884e4ec4bc182751634b3d7fa0b9 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Thu, 23 Apr 2026 16:40:46 +0100 Subject: [PATCH 2/2] test(connectivity): probe the accelerator-bootstrap-modules latest release endpoint Adds a targeted GitHub API connectivity check against the URL the accelerator actually calls at runtime, so an expired gh CLI token or repo-specific 403 surfaces from the pre-flight check rather than mid- deploy. --- .../Tools/Checks/Test-NetworkConnectivity.ps1 | 15 ++++++++------- .../Private/Test-NetworkConnectivity.Tests.ps1 | 12 ++++++------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 index 9d475b86..bd291185 100644 --- a/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 +++ b/src/ALZ/Private/Tools/Checks/Test-NetworkConnectivity.ps1 @@ -18,18 +18,19 @@ function Test-NetworkConnectivity { 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)" }, - @{ Uri = "https://api.releases.hashicorp.com"; Description = "HashiCorp Releases API (Terraform version)" }, - @{ Uri = "https://releases.hashicorp.com"; Description = "HashiCorp Releases (Terraform binary download)" }, - @{ Uri = "https://management.azure.com"; Description = "Azure Management API" }, - @{ Uri = "https://www.powershellgallery.com"; Description = "PowerShell Gallery (module installs/updates)" } + @{ Uri = "https://api.github.com"; Description = "GitHub API (root)" }, + @{ Uri = "https://api.github.com/repos/Azure/accelerator-bootstrap-modules/releases/latest"; Description = "GitHub API (accelerator-bootstrap-modules latest release)" }, + @{ Uri = "https://github.com"; Description = "GitHub (module downloads)" }, + @{ Uri = "https://api.releases.hashicorp.com"; Description = "HashiCorp Releases API (Terraform version)" }, + @{ Uri = "https://releases.hashicorp.com"; Description = "HashiCorp Releases (Terraform binary download)" }, + @{ Uri = "https://management.azure.com"; Description = "Azure Management API" }, + @{ Uri = "https://www.powershellgallery.com"; Description = "PowerShell Gallery (module installs/updates)" } ) foreach ($endpoint in $endpoints) { Write-Verbose "Testing network connectivity to $($endpoint.Uri)" try { - if ($endpoint.Uri -eq "https://api.github.com") { + if ($endpoint.Uri.StartsWith("https://api.github.com")) { $response = Invoke-GitHubApiRequest -Uri $endpoint.Uri -Method Head -SkipHttpErrorCheck -MaxRetryCount $HttpRequestMaxRetryCount -RetryIntervalSeconds $HttpRequestRetryIntervalSeconds -TimeoutSec $HttpRequestTimeoutSeconds $statusCode = $null if ($null -ne $response) { diff --git a/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 b/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 index 53cb338a..bacf9531 100644 --- a/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 +++ b/src/Tests/Unit/Private/Test-NetworkConnectivity.Tests.ps1 @@ -40,9 +40,9 @@ InModuleScope 'ALZ' { } } - It 'returns one result per endpoint (6 total)' { + It 'returns one result per endpoint (7 total)' { $result = Test-NetworkConnectivity - $result.Results.Count | Should -Be 6 + $result.Results.Count | Should -Be 7 } } @@ -64,7 +64,7 @@ InModuleScope 'ALZ' { It 'returns a Failure result for the unreachable endpoint' { $result = Test-NetworkConnectivity $failureResults = @($result.Results | Where-Object { $_.result -eq "Failure" }) - $failureResults.Count | Should -Be 1 + $failureResults.Count | Should -Be 2 } It 'includes the error message in the Failure result' { @@ -103,15 +103,15 @@ InModuleScope 'ALZ' { } } - It 'returns one result per endpoint (6 total)' { + It 'returns one result per endpoint (7 total)' { $result = Test-NetworkConnectivity - $result.Results.Count | Should -Be 6 + $result.Results.Count | Should -Be 7 } It 'checks all endpoints and does not stop at the first failure' { $result = Test-NetworkConnectivity Should -Invoke -CommandName Invoke-HttpRequestWithRetry -Times 5 -Scope It - Should -Invoke -CommandName Invoke-GitHubApiRequest -Times 1 -Scope It + Should -Invoke -CommandName Invoke-GitHubApiRequest -Times 2 -Scope It } } }