Private/Invoke-AzRestJson.ps1
|
function Invoke-AzRestJson { <# .SYNOPSIS Internal helper that invokes 'az rest' and safely parses the JSON response. .DESCRIPTION Wraps 'az rest' to centralise error handling, LASTEXITCODE checks, and ConvertFrom-Json failure handling. Returns a uniform result object so callers no longer have to duplicate the same guard pattern. Captures stderr via 2>&1 so that non-JSON error text returned by the Azure CLI never reaches ConvertFrom-Json, which would otherwise throw an uncaught parse error under Set-StrictMode. .PARAMETER Uri Full ARM URI, e.g. https://management.azure.com/<resourceId>?api-version=... .PARAMETER Method HTTP method (GET, POST, PATCH, PUT, DELETE). Defaults to GET. .PARAMETER Body Optional JSON body string. Written to a temp file and passed via @file to avoid shell escaping issues. .PARAMETER Headers Optional extra headers (array of 'Name=Value' strings). .OUTPUTS PSCustomObject with: Ok (bool), Data (parsed JSON or $null), Error (string or $null) #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Uri, [Parameter(Mandatory = $false)] [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD')] [string]$Method = 'GET', [Parameter(Mandatory = $false)] [string]$Body, [Parameter(Mandatory = $false)] [string[]]$Headers ) $tempBodyFile = $null $prevPyEncoding = $env:PYTHONIOENCODING try { # Force Azure CLI (Python) to write UTF-8 to stdout/stderr regardless of the # host console code page. Without this, any non-cp1252 character in the ARM # response (seen in updateRuns error text, localised health messages, etc.) # causes the CLI to emit a stderr warning line like # "WARNING: Unable to encode the output with cp1252 encoding. Unsupported characters are discarded." # which, when captured via 2>&1, gets prepended to the JSON and breaks # ConvertFrom-Json. That previously manifested as silently-dropped update # runs / available updates for affected clusters. # # NOTE: az.cmd launches python with the -I (isolated) flag, which causes # python to ignore PYTHONIOENCODING / PYTHONUTF8 (-I implies -E). The # env-var assignment below is therefore best-effort defence-in-depth and # is not, on its own, sufficient. The hard fix is the --only-show-errors # CLI flag added to $azArgs below: it suppresses the encode warning at # source, keeping the captured stderr/stdout streams clean. Reference: # https://github.com/Azure/azure-cli/issues/14426 (jiasli's recommended # workaround), https://github.com/Azure/azure-cli/issues/28497 # (confirmation that az uses python -I and PYTHONIOENCODING is ignored). $env:PYTHONIOENCODING = 'utf-8' # --only-show-errors mutes ALL CLI-level warnings, including the cp1252 # encode warning. Characters that fail to encode are still replaced # silently, but for ARM cluster/update payloads (timestamps, GUIDs, # status enums, resource IDs - all ASCII) this is a non-issue. Any # genuine error from the CLI (auth failures, 4xx/5xx ARM responses, # invalid args) still surfaces normally. $azArgs = @('rest', '--method', $Method, '--uri', $Uri, '--only-show-errors') if ($PSBoundParameters.ContainsKey('Body') -and $Body) { $tempBodyFile = [System.IO.Path]::GetTempFileName() Write-Utf8NoBomFile -Path $tempBodyFile -Content $Body $azArgs += @('--body', "@$tempBodyFile") if (-not $Headers) { $Headers = @('Content-Type=application/json') } } if ($Headers) { foreach ($h in $Headers) { $azArgs += @('--headers', $h) } } $raw = & az @azArgs 2>&1 $exit = $LASTEXITCODE # Split merged stdout+stderr by stream type. Stderr lines (Python warnings, # deprecation notices) surface as ErrorRecord objects when using 2>&1; # stdout lines surface as strings. We only pass the string stream to # ConvertFrom-Json so a stray stderr warning can never corrupt JSON. $stderrLines = @($raw | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) $stdoutLines = @($raw | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) # Mid-run token expiry: detect 401 / ExpiredAuthenticationToken in the # CLI error text, force a token refresh, and retry the original call # exactly once. This avoids breaking long-running fleet operations when # the cached access token crosses its expiry during the run. if ($exit -ne 0) { $errText = (($stderrLines + $stdoutLines) | Out-String) $is401 = ($errText -match '\b401\b' -or $errText -match 'ExpiredAuthenticationToken' -or $errText -match 'InvalidAuthenticationToken' -or $errText -match 'AuthenticationFailed') if ($is401) { Write-Verbose "Invoke-AzRestJson: detected 401 / token-expiry on $Method $Uri; refreshing access token and retrying once." try { # Forces the CLI to refresh the cached bearer token. $null = & az account get-access-token --resource 'https://management.azure.com/' --output none 2>&1 } catch { Write-Verbose "Invoke-AzRestJson: token refresh failed: $($_.Exception.Message)" } $raw = & az @azArgs 2>&1 $exit = $LASTEXITCODE $stderrLines = @($raw | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) $stdoutLines = @($raw | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) } } if ($exit -ne 0) { return [PSCustomObject]@{ Ok = $false Data = $null Error = (ConvertTo-ScrubbedCliOutput -Text ((($stderrLines + $stdoutLines) | Out-String).Trim())) } } # Success path: parse JSON from stdout only (empty body is OK for PATCH/DELETE) $rawText = ($stdoutLines | Out-String).Trim() if ([string]::IsNullOrWhiteSpace($rawText)) { return [PSCustomObject]@{ Ok = $true; Data = $null; Error = $null } } try { $parsed = $rawText | ConvertFrom-Json -ErrorAction Stop return [PSCustomObject]@{ Ok = $true; Data = $parsed; Error = $null } } catch { return [PSCustomObject]@{ Ok = $false Data = $null Error = "JSON parse failure: $($_.Exception.Message); raw: $(ConvertTo-ScrubbedCliOutput -Text $rawText.Substring(0, [Math]::Min(500, $rawText.Length)))" } } } finally { if ($tempBodyFile -and (Test-Path -LiteralPath $tempBodyFile)) { Remove-Item -LiteralPath $tempBodyFile -Force -ErrorAction SilentlyContinue -WhatIf:$false } # Restore caller's prior PYTHONIOENCODING (may have been $null/unset). if ($null -eq $prevPyEncoding) { Remove-Item Env:PYTHONIOENCODING -ErrorAction SilentlyContinue -WhatIf:$false } else { $env:PYTHONIOENCODING = $prevPyEncoding } } } |