Private/Test-InforcerReportRunTerminal.ps1
|
function Test-InforcerReportRunTerminal { <# .SYNOPSIS Probes GET /reports/runs/{id}/outputs once to determine if a run is terminal (Private helper). .DESCRIPTION Per API contract (both API versions): * 200 → run is terminal; outputs are returned (downloadable) * 404 → run is not terminal yet (or doesn't exist — see note) beta wraps the outputs as { data: { outputs: [...] } }; v1 returns { outputs: [...] } with msTenantId, which is mapped to the module's tenantId (null for a collated output). A v1 401 renews the access token and retries once; a v1 429 waits for Retry-After (at most 3 times). Used as the polling probe by Get-InforcerReportRun -Wait. Returns a typed result rather than throwing on the "not yet" case, so polling loops can branch cleanly. Note: a 404 alone cannot distinguish "run pending" from "run-id invalid". This helper treats any 404 as "not terminal yet" — the caller's timeout loop is what catches a bogus run id (it times out instead of completing). Run-id format validation happens client-side via [guid]::TryParse before this is called. On v1 a 404 also means the run lies outside the API client's tenant scope. .PARAMETER RunId The run identifier (GUID) returned from POST /reports/runs. .OUTPUTS PSCustomObject with members: RunId — the run identifier (string form of the guid) IsTerminal — boolean Outputs — the parsed outputs array (when IsTerminal=$true), else $null CorrelationId — x-correlation-id of this probe, or $null StatusCode — raw HTTP status code (int) #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [guid]$RunId ) if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected. Run Connect-Inforcer first.' ` -ErrorId 'NotConnected' -Category ConnectionError return } $uri = Get-InforcerBaseUrl -Endpoint ('/reports/runs/{0}/outputs' -f $RunId) $isV1 = $script:InforcerSession.ApiVersion -eq 'v1' Write-Verbose "Probing terminal state: GET $uri" $forceRefresh = $false $retries429 = 0 while ($true) { $headers = Get-InforcerAuthHeader -ForceRefresh:$forceRefresh if (-not $headers) { return } $headers['Accept'] = 'application/json' try { $response = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -SkipHttpErrorCheck -UseBasicParsing -MaximumRedirection 0 -Debug:$false -Verbose:$false -ErrorAction Ignore } catch { Clear-InforcerRequestCredential -ErrorRecord $_ $msg = Protect-InforcerApiKeyInText -Text $_.Exception.Message Write-Error -Message "Failed to probe run terminal state: $msg" ` -ErrorId 'ProbeFailed' -Category ConnectionError return } if ($isV1 -and -not $forceRefresh -and [int]$response.StatusCode -eq 401) { Write-Verbose 'HTTP 401: renewing the access token and retrying once.' $forceRefresh = $true continue } if ($isV1 -and [int]$response.StatusCode -eq 429 -and $retries429 -lt 3) { $retries429++ $delay = Get-InforcerRetryDelay -Value (Get-InforcerHeaderValue -Headers $response.Headers -Name 'Retry-After') -Attempt $retries429 Write-Verbose "Rate limited (HTTP 429). Retrying in $delay second(s) (attempt $retries429 of 3)." Start-Sleep -Seconds $delay continue } break } $statusCode = [int]$response.StatusCode if ($statusCode -ge 300 -and $statusCode -lt 400) { Write-Error -Message (Get-InforcerRedirectMessage -StatusCode $statusCode -Headers $response.Headers) -ErrorId "ProbeFailed_$statusCode" -Category ConnectionError return } $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id' if ($correlationId) { Write-Verbose "x-correlation-id: $correlationId" } if ($statusCode -eq 200) { # beta: { data: { outputs: [...] }, success: true, ... }; v1: { outputs: [...] }. # Unwrap: response → (.data →) .outputs (the array of output records). $outputs = @() if ($response.Content) { try { $body = $response.Content | ConvertFrom-Json -Depth 100 -ErrorAction Stop $unwrapped = $body $dataProp = $body.PSObject.Properties['data'] if ($dataProp -and $null -ne $dataProp.Value) { $unwrapped = $dataProp.Value } if ($unwrapped -is [PSObject]) { $outputsProp = $unwrapped.PSObject.Properties['outputs'] if ($outputsProp -and $null -ne $outputsProp.Value) { $outputs = @($outputsProp.Value) } elseif ($unwrapped -is [array]) { $outputs = @($unwrapped) } } elseif ($unwrapped -is [array]) { $outputs = @($unwrapped) } } catch { Write-Verbose '200 response was not JSON-parseable; returning empty outputs.' } } if ($isV1 -and $outputs.Count -gt 0) { $outputs = @(ConvertFrom-InforcerV1Response -InputObject $outputs -ObjectType ReportOutput) } foreach ($o in $outputs) { if ($o -is [PSObject] -and -not $o.PSObject.Properties['runId']) { $o | Add-Member -NotePropertyName 'runId' -NotePropertyValue $RunId.ToString() } } return [PSCustomObject]@{ RunId = $RunId.ToString() IsTerminal = $true Outputs = $outputs CorrelationId = $correlationId StatusCode = $statusCode } } if ($statusCode -eq 404) { return [PSCustomObject]@{ RunId = $RunId.ToString() IsTerminal = $false Outputs = $null CorrelationId = $correlationId StatusCode = $statusCode } } # Any other status is an error worth surfacing — parse the body for a useful message. $detail = $response.Content if ($detail) { try { $json = $response.Content | ConvertFrom-Json -ErrorAction Stop $msgProp = $json.PSObject.Properties['message'] $titleProp = $json.PSObject.Properties['title'] if ($msgProp) { $detail = $msgProp.Value -as [string] } elseif ($titleProp) { $detail = $titleProp.Value -as [string] $problemDetail = $json.PSObject.Properties['detail'].Value -as [string] if ($problemDetail -and $problemDetail -ne $detail) { $detail = "${detail}: $problemDetail" } } } catch { Write-Verbose 'Error response body was not JSON; using raw content.' } } if ([string]::IsNullOrWhiteSpace($detail)) { $detail = "HTTP $statusCode" } $detail = Protect-InforcerApiKeyInText -Text $detail $errMsg = "Failed to probe run '$RunId': $detail" if ($correlationId) { $errMsg = "$errMsg [correlation-id: $correlationId]" } Write-Error -Message $errMsg -ErrorId "ProbeFailed_$statusCode" -Category ConnectionError } |