Public/New-AzureLocalIncident.ps1
|
function New-AzureLocalIncident { <# .SYNOPSIS Opens or de-duplicates ServiceNow incidents from a JUnit results file. .DESCRIPTION Consumes the JUnit XML produced by Get-AzureLocalUpdateRuns / Invoke-AzureLocalFleetOperation, applies the configured trigger matrix (Get-AzureLocalItsmConfig), and for each cluster row that matches a 'raiseTicket: true' trigger: 1. Computes a deterministic SHA256 dedupe key. 2. Queries ServiceNow for an existing incident with the same key in states (New, In Progress, On Hold). Skips re-creation if found (returns Action='DedupedToExisting' with the sys_id), unless -ForceCreate is supplied. 3. Otherwise creates a new incident with category / severity / custom fields populated from the trigger matrix and run metadata. Returns Action='Created' with the new sys_id. In -DryRun mode the function still parses, evaluates triggers, and builds the payloads, but performs zero HTTP writes. It DOES perform the read-only dedupe lookup (GET /api/now/table/incident) when an access token can be obtained, so the returned Action correctly shows DedupedToExisting / DryRun. If the OAuth grant or dedupe GET fails, DryRun degrades gracefully to a fully offline payload build and emits Action='DryRun' with a Reason annotation. Phase 2 (Sync-AzureLocalIncident) handles close-out on subsequent successful runs. Phase 3 mirrors to Teams / Slack. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$InputArtifactPath, [Parameter(Mandatory = $true)] [ValidateNotNull()] [pscustomobject]$Config, [Parameter(Mandatory = $false)] [hashtable]$RunMetadata, [Parameter(Mandatory = $false)] [switch]$DryRun, [Parameter(Mandatory = $false)] [switch]$ForceCreate, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [string]$ExportJUnitPath ) if (-not (Test-Path -Path $InputArtifactPath -PathType Leaf)) { throw "New-AzureLocalIncident: input artefact not found at '$InputArtifactPath'." } if (-not $RunMetadata) { $RunMetadata = @{} } # 1. Parse JUnit -> per-cluster rows ------------------------------------ [xml]$xml = Get-Content -Path $InputArtifactPath -Raw $rows = New-Object System.Collections.ArrayList foreach ($tc in $xml.SelectNodes('//testcase')) { $status = 'Unknown' $message = $null if ($tc.failure) { $status = 'Failed'; $message = [string]$tc.failure.'#text' } elseif ($tc.error) { $status = 'Error'; $message = [string]$tc.error.'#text' } elseif ($tc.skipped) { $status = 'Skipped' } else { $status = 'Success' } # Module's JUnit writer (Export-ResultsToJUnitXml) emits properties on each testcase $props = @{} if ($tc.properties -and $tc.properties.property) { foreach ($p in $tc.properties.property) { $props[[string]$p.name] = [string]$p.value } } if ($props.ContainsKey('Status') -and -not [string]::IsNullOrWhiteSpace($props['Status'])) { $status = $props['Status'] } [void]$rows.Add([pscustomobject]@{ ClassName = [string]$tc.classname Name = [string]$tc.name Status = $status Message = $message ClusterName = if ($props.ContainsKey('ClusterName')) { $props['ClusterName'] } else { [string]$tc.name } ClusterResourceId = if ($props.ContainsKey('ClusterResourceId')) { $props['ClusterResourceId'] } else { '' } UpdateName = if ($props.ContainsKey('UpdateName')) { $props['UpdateName'] } else { '' } Properties = $props }) } # 2. Resolve ServiceNow secrets / token -------------------------------- # In DryRun we still attempt the (read-only) auth + dedupe lookup so the # returned Action accurately reflects what would happen on a real run. # If auth fails in DryRun we degrade gracefully (no throw) and skip the # dedupe lookup; outside DryRun a token-grant failure is a hard error. $instanceUrl = $null $accessToken = $null $authError = $null $sn = $Config.Secrets['servicenow'] $kv = [string]$Config.Secrets['keyvaultName'] if (-not $sn) { throw "New-AzureLocalIncident: config missing 'secrets.servicenow'." } try { $instanceUrl = Resolve-AzLocalItsmSecret -Reference ([string]$sn['instanceUrl']) -DefaultKeyVault $kv -AllowLiteral $clientId = Resolve-AzLocalItsmSecret -Reference ([string]$sn['clientId']) -DefaultKeyVault $kv $clientSecret = Resolve-AzLocalItsmSecret -Reference ([string]$sn['clientSecret']) -DefaultKeyVault $kv $tok = Invoke-AzLocalServiceNowAdapter -Action GetToken ` -InstanceUrl $instanceUrl -ClientId $clientId -ClientSecret $clientSecret $accessToken = $tok.AccessToken } catch { $authError = $_.Exception.Message if (-not $DryRun) { throw } Write-Warning "New-AzureLocalIncident: DryRun continuing without ServiceNow auth (dedupe lookup will be skipped): $authError" } # 3. Evaluate triggers + create / dedupe per row ------------------------ $results = New-Object System.Collections.ArrayList $defaults = $Config.Defaults $titleTemplate = if ($defaults -and $defaults['templates'] -and $defaults['templates']['titleTemplate']) { [string]$defaults['templates']['titleTemplate'] } else { '[Azure Local] {{cluster.name}} - {{trigger.category}} ({{run.updateName}})' } $bodyTemplatePath = if ($defaults -and $defaults['templates'] -and $defaults['templates']['bodyTemplatePath']) { Resolve-AzLocalItsmTemplatePath -RawPath ([string]$defaults['templates']['bodyTemplatePath']) -ConfigSourcePath $Config.SourcePath } else { $null } foreach ($row in $rows) { $decision = Get-AzLocalItsmTriggerDecision -Status $row.Status -Triggers $Config.Triggers -Defaults $defaults if (-not $decision.ShouldTicket) { [void]$results.Add([pscustomobject]@{ ClusterName = $row.ClusterName ClusterResourceId = $row.ClusterResourceId UpdateName = $row.UpdateName Status = $row.Status Action = 'Skipped' Severity = $null TicketId = $null TicketSysId = $null TicketUrl = $null DedupeKey = $null Reason = $decision.Reason }) continue } $dedupeInputsValid = -not ([string]::IsNullOrWhiteSpace($row.ClusterResourceId) -or [string]::IsNullOrWhiteSpace($row.UpdateName)) if (-not $dedupeInputsValid) { [void]$results.Add([pscustomobject]@{ ClusterName = $row.ClusterName ClusterResourceId = $row.ClusterResourceId UpdateName = $row.UpdateName Status = $row.Status Action = 'Skipped' Severity = $null TicketId = $null TicketSysId = $null TicketUrl = $null DedupeKey = $null Reason = "Row missing ClusterResourceId or UpdateName; cannot compute dedupe key. Ensure your JUnit emitter writes both properties on each testcase." }) continue } $dedupeKey = Get-AzLocalItsmDedupeKey ` -ClusterResourceId $row.ClusterResourceId ` -UpdateName $row.UpdateName ` -TriggerCategory $decision.Category $context = @{ cluster = @{ name = $row.ClusterName resourceId = $row.ClusterResourceId } trigger = @{ category = $decision.Category severity = $decision.Severity status = $row.Status } run = @{ updateName = $row.UpdateName id = if ($RunMetadata['RunId']) { $RunMetadata['RunId'] } else { '' } url = if ($RunMetadata['RunUrl']) { $RunMetadata['RunUrl'] } else { '' } platform = if ($RunMetadata['Platform']) { $RunMetadata['Platform'] } else { '' } } message = $row.Message } $title = Format-AzLocalIncidentBody -Template $titleTemplate -Context $context -NoHtmlEscape $body = if ($bodyTemplatePath) { Format-AzLocalIncidentBody -TemplatePath $bodyTemplatePath -Context $context } else { "Status: {0}`nCluster: {1}`nUpdate: {2}`nRun: {3}`n`n{4}" -f $row.Status, $row.ClusterName, $row.UpdateName, $context.run.url, $row.Message } $action = 'Created' $sysId = $null; $ticketNumber = $null; $ticketUrl = $null $existing = $null $extraReason = $null # Read-only dedupe lookup. Runs in DryRun too (read-only by definition) # provided we managed to acquire a token. If the lookup itself fails, # we degrade to "treat as new" with a Reason annotation. if (-not $ForceCreate -and $accessToken) { try { $existing = Invoke-AzLocalServiceNowAdapter -Action FindByDedupe ` -InstanceUrl $instanceUrl -AccessToken $accessToken -DedupeKey $dedupeKey } catch { $extraReason = "FindByDedupe failed: $($_.Exception.Message)" Write-Warning "New-AzureLocalIncident: FindByDedupe failed for $($row.ClusterName) / ${dedupeKey}: $($_.Exception.Message)" } } elseif (-not $ForceCreate -and $DryRun -and -not $accessToken) { $extraReason = "Dedupe lookup skipped (DryRun, no ServiceNow auth): $authError" } if ($existing) { $action = 'DedupedToExisting' $sysId = [string]$existing.sys_id $ticketNumber = [string]$existing.number $ticketUrl = "$instanceUrl/nav_to.do?uri=incident.do?sys_id=$sysId" } elseif ($DryRun) { $action = 'DryRun' } else { $impact, $urgency = Get-AzLocalItsmPriorityFromSeverity -Severity $decision.Severity $fields = @{ short_description = $title description = $body impact = $impact urgency = $urgency category = $decision.Category u_azlocal_dedupe_key = $dedupeKey u_azlocal_cluster_resource_id = $row.ClusterResourceId u_azlocal_update_name = $row.UpdateName u_azlocal_run_id = [string]$context.run.id u_azlocal_source = 'AzLocal.UpdateManagement' } if ($defaults['assignmentGroup']) { $fields['assignment_group'] = [string]$defaults['assignmentGroup'] } if ($defaults['callerId']) { $fields['caller_id'] = [string]$defaults['callerId'] } if ($defaults['cmdbCi']) { $fields['cmdb_ci'] = [string]$defaults['cmdbCi'] } if ($PSCmdlet.ShouldProcess($row.ClusterName, "Create ServiceNow incident for trigger '$($row.Status)'")) { try { $created = Invoke-AzLocalServiceNowAdapter -Action CreateIncident ` -InstanceUrl $instanceUrl -AccessToken $accessToken -IncidentFields $fields $sysId = [string]$created.sys_id $ticketNumber = [string]$created.number $ticketUrl = "$instanceUrl/nav_to.do?uri=incident.do?sys_id=$sysId" } catch { $action = 'CreateFailed' Write-Warning "New-AzureLocalIncident: CreateIncident failed for $($row.ClusterName): $($_.Exception.Message)" } } else { $action = 'WhatIf' } } $finalReason = $decision.Reason if ($extraReason) { $finalReason = "$finalReason | $extraReason" } [void]$results.Add([pscustomobject]@{ ClusterName = $row.ClusterName ClusterResourceId = $row.ClusterResourceId UpdateName = $row.UpdateName Status = $row.Status Action = $action Severity = $decision.Severity TicketId = $ticketNumber TicketSysId = $sysId TicketUrl = $ticketUrl DedupeKey = $dedupeKey Reason = $finalReason }) } if ($ExportPath) { try { $exportDir = Split-Path -Path $ExportPath -Parent if ($exportDir -and -not (Test-Path -Path $exportDir)) { New-Item -Path $exportDir -ItemType Directory -Force | Out-Null } $results | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8 -Force } catch { Write-Warning "New-AzureLocalIncident: failed to export results to '$ExportPath': $($_.Exception.Message)" } } if ($ExportJUnitPath) { try { $junitDir = Split-Path -Path $ExportJUnitPath -Parent if ($junitDir -and -not (Test-Path -Path $junitDir)) { New-Item -Path $junitDir -ItemType Directory -Force | Out-Null } # Project ITSM results onto the shape expected by # Export-ResultsToJUnitXml: Action becomes the synthetic Status so # CreateFailed -> <failure>, Skipped/WhatIf -> <skipped>, and # Created / DedupedToExisting / DryRun -> success with system-out. $junitRows = @($results | ForEach-Object { $syntheticStatus = switch ($_.Action) { 'CreateFailed' { 'Failed' } 'WhatIf' { 'Skipped' } 'Skipped' { 'Skipped' } default { 'Success' } } $msgParts = @("ITSM Action: $($_.Action)") if ($_.TicketId) { $msgParts += "Ticket: $($_.TicketId)" } if ($_.TicketUrl) { $msgParts += "Url: $($_.TicketUrl)" } if ($_.Status) { $msgParts += "ClusterStatus: $($_.Status)" } if ($_.Severity) { $msgParts += "Severity: $($_.Severity)" } if ($_.DedupeKey) { $msgParts += "DedupeKey: $($_.DedupeKey)" } if ($_.Reason) { $msgParts += "Reason: $($_.Reason)" } [pscustomobject]@{ ClusterName = $_.ClusterName Status = $syntheticStatus Message = ($msgParts -join ' | ') UpdateName = $_.UpdateName } }) if ($junitRows.Count -gt 0) { Export-ResultsToJUnitXml -Results $junitRows -OutputPath $ExportJUnitPath ` -TestSuiteName 'AzureLocalItsm' -OperationType 'IncidentAction' } else { # Emit an empty suite so dorny/test-reporter still consumes it cleanly. $emptyJUnit = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n<testsuites>`n <testsuite name=`"AzureLocalItsm`" tests=`"0`" failures=`"0`" errors=`"0`" skipped=`"0`" time=`"0`" timestamp=`"$(Get-Date -Format 'yyyy-MM-ddTHH:mm:ss')`"></testsuite>`n</testsuites>" Set-Content -Path $ExportJUnitPath -Value $emptyJUnit -Encoding UTF8 -Force } } catch { Write-Warning "New-AzureLocalIncident: failed to export JUnit results to '$ExportJUnitPath': $($_.Exception.Message)" } } return $results } function Resolve-AzLocalItsmTemplatePath { <# .SYNOPSIS Resolves a template path from the ITSM config (relative paths are resolved against the config file's directory). #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)][string]$RawPath, [Parameter(Mandatory = $true)][string]$ConfigSourcePath ) if ([IO.Path]::IsPathRooted($RawPath)) { return $RawPath } $configDir = Split-Path -Path $ConfigSourcePath -Parent return (Join-Path $configDir $RawPath) } function Get-AzLocalItsmPriorityFromSeverity { <# .SYNOPSIS Maps a 1..5 severity to ServiceNow (impact, urgency) per the design. #> [CmdletBinding()] [OutputType([int[]])] param( [Parameter(Mandatory = $true)][ValidateRange(1,5)][int]$Severity ) switch ($Severity) { 1 { return @(1,1) } 2 { return @(2,2) } 3 { return @(3,3) } 4 { return @(4,4) } 5 { return @(4,4) } } } |