Private/Show-AACError.ps1
|
function Show-AACError { <# .SYNOPSIS Shows a command's failure as a Spectre.Console panel, and returns the error record the command stops with. .DESCRIPTION Every exported command starts with trap { $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } so a failure anywhere inside it - its own checks, Azure, an export - ends the same way. At an interactive console, a red panel: ╭─ ✗ Show-AACCost failed ─────────────────────────────────────────╮ │ Azure refused the request (403): The client ... does not have │ │ authorization to perform action 'Microsoft.CostManagement/...' │ │ │ │ Step Reading the costs of Contoso Prod │ │ Fix Your account needs a role that allows this ... │ ╰─────────────────────────────────────────────────────────────────╯ the step is the progress line that was running (Invoke-AACProgress records it on the exception), and the fix a hint: the one the code that raised the error put in the exception's Data['AACHint'] (e.g. the tables that do exist), else one for the usual causes: not signed in, no access, not found, throttling, the network, a file that's open elsewhere. Then the command stops with a clean terminating error: its own name and the message, pointing at the caller's line rather than a line inside the module - so try/catch, $Error and -ErrorVariable work as with any cmdlet, and scripts and CI logs (no panel there) get the same message. An error the module didn't mean to raise - a null, a missing property or index under strict mode - is a bug in the module. Its message then says so, with where it happened and where to report it, instead of the bare .NET message. #> [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet ) $exception = $ErrorRecord.Exception $command = $Cmdlet.MyInvocation.MyCommand.Name # Already handled by a command this one called: pass it on as it is. if ($exception.Data.Contains('AACHandled')) { return $ErrorRecord } # Errors PowerShell raises for code, not for circumstances: a bug here. $bugIds = 'PropertyNotFoundStrict', 'VariableIsUndefined', 'InvokeMethodOnNull', 'NullArray', 'IndexOutOfRange', 'MethodNotFound', 'CannotIndex', 'NullArrayIndex', 'InvalidCastFromStringToInteger', 'ParameterBindingFailed' $errorId = ([string]$ErrorRecord.FullyQualifiedErrorId -split ',')[0] $isBug = $errorId -in $bugIds -or $exception -is [System.NullReferenceException] -or $exception -is [System.IndexOutOfRangeException] -or $exception -is [System.InvalidCastException] -or $exception -is [System.Management.Automation.PropertyNotFoundException] $status = if ($exception.Data.Contains('StatusCode')) { [int]$exception.Data['StatusCode'] } else { 0 } $step = if ($exception.Data.Contains('AACStep')) { [string]$exception.Data['AACStep'] } else { '' } $message = $exception.Message.Trim() # Where in the module it happened: the innermost frame of the script # stack, relative to the module folder. $where = '' $frame = @("$($ErrorRecord.ScriptStackTrace)" -split '\r?\n' | Where-Object { $_ -match '^at (.+?), (.+): line (\d+)$' } | Select-Object -First 1) if ($frame.Count -and $frame[0] -match '^at (.+?), (.+): line (\d+)$') { $file = $Matches[2] if ($script:AACModuleRoot -and $file.StartsWith($script:AACModuleRoot, [System.StringComparison]::OrdinalIgnoreCase)) { $file = $file.Substring($script:AACModuleRoot.Length).TrimStart('\', '/') } $where = '{0}:{1}' -f $file, $Matches[3] } $version = $Cmdlet.MyInvocation.MyCommand.Module.Version $issues = 'https://github.com/ChendrayanV/Azure.Admin.Console/issues' $hint = if ($exception.Data.Contains('AACHint') -and $exception.Data['AACHint']) { # The code that raised the error knows best what to do about it. [string]$exception.Data['AACHint'] } elseif ($isBug) { "This is a bug in Azure.Admin.Console $version, not in your Azure setup. Please report it at $issues with this message." } elseif ($message -match 'Connect-AAC') { '' } elseif ($status -eq 401) { 'Azure no longer accepts the sign-in. Run Connect-AAC again.' } elseif ($status -eq 403) { 'Your account needs a role that allows this: Reader on the subscriptions for most commands, Cost Management Reader for Show-AACCost, Log Analytics Reader for Invoke-AACApplicationInsightQuery.' } elseif ($status -eq 404) { 'Check the name, and the subscription (-SubscriptionId) it is in.' } elseif ($status -eq 429) { 'Azure is throttling requests. Wait a minute, then try again - or narrow the scope with -SubscriptionId.' } elseif ($status -ge 500) { 'Azure had a problem of its own. Try again in a few minutes.' } elseif ($exception -is [System.Net.Http.HttpRequestException] -or $exception.InnerException -is [System.Net.Http.HttpRequestException] -or $exception -is [System.Net.Sockets.SocketException]) { 'Azure could not be reached. Check the network connection and proxy.' } elseif ($exception -is [System.IO.IOException] -or $exception -is [System.UnauthorizedAccessException]) { 'The file could not be written. Close it if it is open (a PDF viewer, Excel), or choose another path.' } else { '' } $title = if ($isBug) { "Azure.Admin.Console hit an internal error: $message" } elseif ($status) { "Azure refused the request ($status): $message" } else { $message } if (Test-AACErrorPanel -Cmdlet $Cmdlet) { $escape = { param([string] $Text) [Spectre.Console.Markup]::Escape($Text) } $grid = [Spectre.Console.Grid]::new() $labelColumn = [Spectre.Console.GridColumn]::new() $labelColumn.NoWrap = $true $grid.AddColumn($labelColumn) | Out-Null $grid.AddColumn([Spectre.Console.GridColumn]::new()) | Out-Null $addRow = { param([string] $Label, [string] $Value, [string] $Style) $grid.AddRow([Spectre.Console.Rendering.IRenderable[]]@( [Spectre.Console.Markup]::new("[grey58]$Label[/]"), [Spectre.Console.Markup]::new("[$Style]$(& $escape $Value)[/]"))) | Out-Null } if ($step) { & $addRow 'Step' $step 'grey85' } if ($isBug -and $where) { & $addRow 'Where' $where 'grey58' } if ($hint) { & $addRow 'Fix' $hint 'deepskyblue1' } $body = [System.Collections.Generic.List[Spectre.Console.Rendering.IRenderable]]::new() $body.Add([Spectre.Console.Markup]::new("[red1]$(& $escape $title)[/]")) if ($step -or ($isBug -and $where) -or $hint) { $body.Add([Spectre.Console.Text]::new('')) $body.Add($grid) } $panel = [Spectre.Console.Panel]::new([Spectre.Console.Rows]::new($body)) $panel.Border = [Spectre.Console.BoxBorder]::Rounded $panel.BorderStyle = [Spectre.Console.Style]::Parse('red1') $panel.Header = [Spectre.Console.PanelHeader]::new("[red1] $((Get-AACGlyph).Cross) $(& $escape $command) failed [/]") $panel.Expand = $true [Spectre.Console.AnsiConsole]::Write($panel) } # The record the command stops with: the message a script or log needs # on its own, the original exception inside. $text = $title if ($isBug -and $where) { $text += " (at $where)" } if ($hint) { $text += " $hint" } $clean = [System.Exception]::new($text, $exception) foreach ($key in $exception.Data.Keys) { $clean.Data[$key] = $exception.Data[$key] } $clean.Data['AACHandled'] = $true $category = if ($isBug) { [System.Management.Automation.ErrorCategory]::NotSpecified } elseif ($status -in 401, 403) { [System.Management.Automation.ErrorCategory]::PermissionDenied } elseif ($status -eq 404) { [System.Management.Automation.ErrorCategory]::ObjectNotFound } elseif ($status) { [System.Management.Automation.ErrorCategory]::ConnectionError } else { $ErrorRecord.CategoryInfo.Category } $id = if ($isBug) { 'InternalError' } elseif ($status) { "AzureRequestFailed$status" } else { 'CommandFailed' } [System.Management.Automation.ErrorRecord]::new($clean, $id, $category, $ErrorRecord.TargetObject) } |