Private/Errors.ps1
|
# Every failure the module reports is a VMetricException. The public cmdlet that hit it turns it into an # ErrorRecord (Write-VMetricCmdletError), so the record names that cmdlet, carries the API's code as its error # id, and has a category chosen from the HTTP status. class VMetricException : System.Exception { # The HTTP status of the answer, or 0 when the failure did not come from one. [int] $StatusCode # The API's code (DEPLOYMENT_IN_PROGRESS), or the module's own id (NotConnected). [string] $Code = 'VMetricError' # The server's trace id, when the answer carried one. Support can find the request by it. [string] $TraceId # The error body beside the message: diagnostics, missingPermissions, missingFeatures. [object] $Details [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified [string] $RecommendedAction # Set for a failure every later pipeline input would hit too (not connected, a session that ended): the # cmdlet stops rather than report the same error once per input. [bool] $IsTerminating VMetricException([string] $message) : base($message) { } VMetricException([string] $message, [System.Exception] $innerException) : base($message, $innerException) { } } function New-VMetricException { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')] [CmdletBinding()] [OutputType([System.Exception])] param( [Parameter(Mandatory)] [string] $Message, [string] $Code = 'VMetricError', [System.Management.Automation.ErrorCategory] $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, [int] $StatusCode, [string] $TraceId, [object] $Details, [string] $RecommendedAction, [System.Exception] $InnerException, [switch] $Terminating ) if ($InnerException) { $exception = [VMetricException]::new($Message, $InnerException) } else { $exception = [VMetricException]::new($Message) } $exception.Code = $Code $exception.Category = $Category $exception.StatusCode = $StatusCode $exception.TraceId = $TraceId $exception.Details = $Details $exception.RecommendedAction = $RecommendedAction $exception.IsTerminating = $Terminating.IsPresent $exception } function Get-VMetricErrorCategory { [CmdletBinding()] [OutputType([System.Management.Automation.ErrorCategory])] param( [int] $StatusCode, [string] $Code ) switch ($StatusCode) { 400 { return [System.Management.Automation.ErrorCategory]::InvalidArgument } 401 { return [System.Management.Automation.ErrorCategory]::AuthenticationError } 403 { return [System.Management.Automation.ErrorCategory]::PermissionDenied } 404 { return [System.Management.Automation.ErrorCategory]::ObjectNotFound } 408 { return [System.Management.Automation.ErrorCategory]::OperationTimeout } 409 { if ($Code -eq 'DEPLOYMENT_IN_PROGRESS') { return [System.Management.Automation.ErrorCategory]::ResourceBusy } return [System.Management.Automation.ErrorCategory]::ResourceExists } 413 { return [System.Management.Automation.ErrorCategory]::LimitsExceeded } 422 { return [System.Management.Automation.ErrorCategory]::InvalidData } 429 { return [System.Management.Automation.ErrorCategory]::LimitsExceeded } 501 { return [System.Management.Automation.ErrorCategory]::NotImplemented } 502 { return [System.Management.Automation.ErrorCategory]::ConnectionError } 503 { return [System.Management.Automation.ErrorCategory]::ResourceUnavailable } 504 { return [System.Management.Automation.ErrorCategory]::OperationTimeout } } if ($StatusCode -ge 500) { return [System.Management.Automation.ErrorCategory]::InvalidResult } if ($StatusCode -ge 400) { return [System.Management.Automation.ErrorCategory]::InvalidOperation } return [System.Management.Automation.ErrorCategory]::NotSpecified } function ConvertTo-VMetricSafeText { # Server text on its way to the terminal (a name in a message, an error, a preview line): what could make # a terminal do rather than show is neutralised, so a device or tenant name cannot write the clipboard # (OSC 52), retitle the window, hide or rewrite lines, or reorder the text around it. C0 and C1 controls # other than the tab go (ESC with them, so an escape sequence is left as harmless text), and so do the # format characters, bidirectional controls among them; a line break becomes a space, so the text stays # on its own line, unless -AllowNewLine keeps it. The objects the cmdlets emit keep their data as it came. [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [AllowEmptyString()] [string] $Text, [switch] $AllowNewLine ) if ([string]::IsNullOrEmpty($Text)) { return '' } if ($AllowNewLine) { $Text = [regex]::Replace($Text, '\r\n?|[\p{Zl}\p{Zp}]', "`n") return [regex]::Replace($Text, '[\p{Cc}\p{Cf}-[\t\n]]', '') } $Text = [regex]::Replace($Text, '[\r\n\p{Zl}\p{Zp}]+', ' ') return [regex]::Replace($Text, '[\p{Cc}\p{Cf}-[\t]]', '') } function ConvertFrom-VMetricErrorResponse { # Reads an error answer: the public format {code, message, traceId} with the detail beside it, the RFC 8628 # device errors {code, message}, and anything else (a proxy's HTML page) as the bare status. [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [int] $StatusCode, [AllowNull()] [AllowEmptyString()] [string] $Content ) $body = $null if (-not [string]::IsNullOrWhiteSpace($Content)) { try { $body = ConvertFrom-VMetricJson -Json $Content } catch { $body = $null } } if ($body -isnot [System.Management.Automation.PSObject] -or $body.psobject.BaseObject -isnot [System.Management.Automation.PSCustomObject]) { $body = $null } $code = $null $message = $null $traceId = $null if ($body) { foreach ($name in @('code', 'item', 'error')) { $value = Get-VMetricMember -InputObject $body -Name $name if ($value -is [string] -and $value) { $code = $value break } } foreach ($name in @('message', 'description', 'error_description')) { $value = Get-VMetricMember -InputObject $body -Name $name if ($value -is [string] -and $value) { $message = $value break } } $traceId = Get-VMetricMember -InputObject $body -Name 'traceId' } if (-not $code) { $code = "HTTP_$StatusCode" } if (-not $message) { $reason = '' if ([System.Enum]::IsDefined([System.Net.HttpStatusCode], $StatusCode)) { $reason = ' ' + [regex]::Replace(([System.Net.HttpStatusCode]$StatusCode).ToString(), '(?<=[a-z])(?=[A-Z])', ' ') } $message = "The server answered $StatusCode$reason." } [pscustomobject]@{ Code = ConvertTo-VMetricSafeText -Text $code Message = ConvertTo-VMetricSafeText -Text $message TraceId = ConvertTo-VMetricSafeText -Text ([string]$traceId) Details = $body } } function New-VMetricApiException { # The exception for an API error answer: the message, then the code and trace id in parentheses, then the # error diagnostics a refused template carries, one per line. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an exception object; changes no state.')] [CmdletBinding()] [OutputType([System.Exception])] param( [Parameter(Mandatory)] [int] $StatusCode, [Parameter(Mandatory)] [pscustomobject] $ApiError, # The text of the secrets the request carried: masked in the message, and the error body is dropped # when it echoed one, before the exception exists (a transcript logs every terminating error as it is # thrown, caught or not). [AllowNull()] [string[]] $Secret ) $suffix = if ($ApiError.TraceId) { "($($ApiError.Code), trace $($ApiError.TraceId))" } else { "($($ApiError.Code))" } $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("$($ApiError.Message) $suffix") $shown = 0 foreach ($diagnostic in (ConvertTo-VMetricArray -InputObject (Get-VMetricMember -InputObject $ApiError.Details -Name 'diagnostics'))) { if ($null -eq $diagnostic -or (Get-VMetricMember -InputObject $diagnostic -Name 'severity') -ne 'error') { continue } if ($shown -eq 10) { $lines.Add(' ...') break } $lines.Add(' ' + (Format-VMetricDiagnosticText -Diagnostic $diagnostic)) $shown++ } $recommended = $null if ($StatusCode -eq 401) { $recommended = 'Run Connect-VMetric to sign in again.' } $message = $lines -join [Environment]::NewLine $details = $ApiError.Details if ($Secret) { $masked = Hide-VMetricSecretText -Text $message -Secret $Secret $bodyText = '' if ($null -ne $details) { $bodyText = ConvertTo-Json -InputObject $details -Depth 20 -Compress } if ($masked -ne $message -or (Hide-VMetricSecretText -Text $bodyText -Secret $Secret) -ne $bodyText) { $message = $masked $details = $null } } New-VMetricException -Message $message -Code $ApiError.Code ` -Category (Get-VMetricErrorCategory -StatusCode $StatusCode -Code $ApiError.Code) ` -StatusCode $StatusCode -TraceId $ApiError.TraceId -Details $details -RecommendedAction $recommended } function Format-VMetricDiagnosticText { # One diagnostic as a line: "BCP018 (line 3, column 5): Expected the = character." [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [object] $Diagnostic ) $code = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Diagnostic -Name 'code')) $message = ConvertTo-VMetricSafeText -Text ([string](Get-VMetricMember -InputObject $Diagnostic -Name 'message')) $range = Get-VMetricMember -InputObject $Diagnostic -Name 'range' $line = [int](Get-VMetricMember -InputObject $range -Name 'startLine') $column = [int](Get-VMetricMember -InputObject $range -Name 'startColumn') if ($line -gt 0) { return "${code} (line $line, column $column): $message" } return "${code}: $message" } function New-VMetricErrorRecord { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an error record object; changes no state.')] [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( [Parameter(Mandatory)] [System.Exception] $Exception, [AllowNull()] [object] $TargetObject ) $errorId = $Exception.GetType().Name $category = [System.Management.Automation.ErrorCategory]::NotSpecified $recommended = $null if ($Exception -is [VMetricException]) { $errorId = $Exception.Code $category = $Exception.Category $recommended = $Exception.RecommendedAction } $record = [System.Management.Automation.ErrorRecord]::new($Exception, $errorId, $category, $TargetObject) if ($recommended) { $record.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($Exception.Message) $record.ErrorDetails.RecommendedAction = $recommended } $record } function ConvertTo-VMetricErrorRecord { # A caught error, rebuilt so that the cmdlet writing it is the one the record names. [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [AllowNull()] [object] $TargetObject ) $source = $ErrorRecord if ($source.Exception -is [System.Management.Automation.ActionPreferenceStopException] -and $source.Exception.ErrorRecord) { $source = $source.Exception.ErrorRecord } $target = $source.TargetObject if ($PSBoundParameters.ContainsKey('TargetObject')) { $target = $TargetObject } $exception = $source.Exception if ($exception -is [VMetricException]) { return New-VMetricErrorRecord -Exception $exception -TargetObject $target } $errorId = ([string]$source.FullyQualifiedErrorId -split ',')[0] if (-not $errorId) { $errorId = $exception.GetType().Name } $record = [System.Management.Automation.ErrorRecord]::new($exception, $errorId, $source.CategoryInfo.Category, $target) if ($source.ErrorDetails) { $record.ErrorDetails = $source.ErrorDetails } $record } function Hide-VMetricSecretInError { # An error whose message carries the text of a secret the request held (a server that echoed it), rebuilt # with the secret masked and without the inner exception or error body that could carry it too. [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [AllowNull()] [string[]] $Secret ) if (-not $Secret) { return $ErrorRecord } $source = $ErrorRecord if ($source.Exception -is [System.Management.Automation.ActionPreferenceStopException] -and $source.Exception.ErrorRecord) { $source = $source.Exception.ErrorRecord } $message = $source.Exception.Message $detail = '' if ($source.ErrorDetails) { $detail = [string]$source.ErrorDetails.Message } $masked = Hide-VMetricSecretText -Text $message -Secret $Secret if ($masked -eq $message -and (Hide-VMetricSecretText -Text $detail -Secret $Secret) -eq $detail) { return $ErrorRecord } $original = $source.Exception if ($original -is [VMetricException]) { $copy = New-VMetricException -Message $masked -Code $original.Code -Category $original.Category -StatusCode $original.StatusCode ` -TraceId $original.TraceId -RecommendedAction $original.RecommendedAction -Terminating:$original.IsTerminating } else { $copy = New-VMetricException -Message $masked -Code (([string]$source.FullyQualifiedErrorId -split ',')[0]) -Category $source.CategoryInfo.Category } New-VMetricErrorRecord -Exception $copy -TargetObject $source.TargetObject } function Test-VMetricFlowControl { # Ctrl+C, and a downstream Select-Object -First stopping the pipeline, must pass through the catch-all # blocks untouched. [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord ) $exception = $ErrorRecord.Exception return ($exception -is [System.Management.Automation.PipelineStoppedException] -or $exception -is [System.Management.Automation.FlowControlException]) } function Write-VMetricCmdletError { # Reports a caught error through the public cmdlet: stops the cmdlet for a failure every later input would # hit too, and writes a non-terminating error otherwise, so the rest of the pipeline input still runs. [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.PSCmdlet] $Cmdlet, [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [AllowNull()] [object] $TargetObject, [switch] $Terminating ) if ($PSBoundParameters.ContainsKey('TargetObject')) { $record = ConvertTo-VMetricErrorRecord -ErrorRecord $ErrorRecord -TargetObject $TargetObject } else { $record = ConvertTo-VMetricErrorRecord -ErrorRecord $ErrorRecord } if ($Terminating -or ($record.Exception -is [VMetricException] -and $record.Exception.IsTerminating)) { $Cmdlet.ThrowTerminatingError($record) } $Cmdlet.WriteError($record) } |