src/ingest/Import-ScoutDevOpsCapability.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Native Azure DevOps Capability ingestor — populate collect.json's `devops` object from Start-AZSCDevOpsExtraction, and make "-IncludeDevOps was never granted access" a first-class, queryable signal rather than a silent empty array. .DESCRIPTION AB#6827 (Feature AB#6749, Epic AB#6454). The `caf.devopscapability.yaml` rule set (see docs/frameworks/devops-capability-question-set.md) needs to tell "Azure DevOps access was not granted / -IncludeDevOps was not used" apart from "access was granted and there are genuinely zero pipelines" -- an `exists`/`countGreaterThan` assert cannot make that distinction on its own, so this ingestor computes an explicit `available` boolean findings can gate on (Invoke-Rule's `assert.gate`, AB#6826/AB#6827 share the mechanism). `-IncludeDevOps` is opt-in (mirrors the inventory command's own switch, by design -- Azure DevOps is a separate service with its own auth surface, see src/collect/Start-ScoutDevOpsExtraction.ps1's header). Three distinct reasons the pull can be unavailable, all folded into one `available = $false`: 1. `-IncludeDevOps` was never passed to this assessment run at all. 2. It was passed, but no Azure DevOps organizations could be discovered from the signed-in identity (a service principal with no `-DevOpsOrganization` named explicitly) -- Start-AZSCDevOpsExtraction already returns `DevOpsResources = @()` for this case with a Write-Warning; this ingestor treats "attempted but zero resources of ANY devops/* type came back" as unavailable, not as a clean zero. 3. It was passed and an organization was found, but every project in it returned "no project access" -- same empty-result shape as (2), same treatment. .PARAMETER Collect The collect object to attach/merge `devops` onto. Invoke-Collect.ps1 already stamps the ARM-sourced half (`devops.managedPools`, `.devCenters`, `.loadTesting`, `.chaosExperiments`, `.playwrightTesting` -- Reader-scoped, never gated behind Azure DevOps access) before this ingestor runs; this function extends that object rather than replacing it. .PARAMETER IncludeDevOps Same switch as Invoke-AzureScout's -IncludeDevOps. Absent/off, this ingestor makes no Azure DevOps call at all and reports `available = $false, attempted = $false`. .PARAMETER DevOpsOrganization Passed straight through to Start-AZSCDevOpsExtraction. .PARAMETER DevOpsPat Passed straight through to Start-AZSCDevOpsExtraction. .PARAMETER TenantID Passed straight through to Start-AZSCDevOpsExtraction (stamped onto each normalized resource only, not used for auth). .PARAMETER FromInventory Optional raw resource rows from a combined `-InventoryAndAssessment -IncludeDevOps` run (`$ExtractionData.Resources`, already containing `devops/*` synthetic types) -- when supplied, this ingestor filters them locally instead of re-running the ADO extraction (the collect-once pattern every other ingestor here already follows). .OUTPUTS $Collect, with `devops.available`, `devops.attempted`, `devops.projects`, `devops.pipelines`, `devops.repositories`, `devops.serviceConnections`, and `devops.agentPools` populated. .NOTES Read-only throughout -- every call below is a GET. Never throws: a DevOps pull failure must not cost the caller their assessment. #> function Import-ScoutDevOpsCapability { [CmdletBinding()] param( $Collect, [switch] $IncludeDevOps, [string[]] $DevOpsOrganization, [string] $DevOpsPat, [string] $TenantID, [object[]] $FromInventory ) $existing = if ($Collect -and $Collect.PSObject.Properties['devops'] -and $Collect.devops) { $Collect.devops } else { $null } $managedPools = if ($existing -and $existing.PSObject.Properties['managedPools']) { @($existing.managedPools) } else { @() } $devCenters = if ($existing -and $existing.PSObject.Properties['devCenters']) { @($existing.devCenters) } else { @() } $loadTesting = if ($existing -and $existing.PSObject.Properties['loadTesting']) { @($existing.loadTesting) } else { @() } $chaosExperiments = if ($existing -and $existing.PSObject.Properties['chaosExperiments']) { @($existing.chaosExperiments) } else { @() } $playwrightTesting = if ($existing -and $existing.PSObject.Properties['playwrightTesting']) { @($existing.playwrightTesting) } else { @() } $devopsResources = @() $attempted = $false if ($null -ne $FromInventory -and @($FromInventory).Count -gt 0) { $devopsResources = @($FromInventory | Where-Object { $_ -and $_.PSObject.Properties['type'] -and [string]$_.type -like 'devops/*' }) $attempted = $true Write-Verbose "Import-ScoutDevOpsCapability: reusing $($devopsResources.Count) Azure DevOps resource(s) from the inventory pass -- no Azure DevOps call made." } elseif ($IncludeDevOps.IsPresent) { $attempted = $true if (-not (Get-Command Start-AZSCDevOpsExtraction -ErrorAction SilentlyContinue)) { try { . (Join-Path $PSScriptRoot '..' 'collect' 'Start-ScoutDevOpsExtraction.ps1') } catch { Write-Warning "Import-ScoutDevOpsCapability: could not load Start-AZSCDevOpsExtraction: $($_.Exception.Message)" } } if (Get-Command Start-AZSCDevOpsExtraction -ErrorAction SilentlyContinue) { try { $extractArgs = @{} if ($TenantID) { $extractArgs.TenantID = $TenantID } if ($DevOpsOrganization) { $extractArgs.Organization = $DevOpsOrganization } if ($DevOpsPat) { $extractArgs.Pat = $DevOpsPat } $result = Start-AZSCDevOpsExtraction @extractArgs $devopsResources = if ($result -and $result.PSObject.Properties['DevOpsResources']) { @($result.DevOpsResources) } else { @() } } catch { Write-Warning "Import-ScoutDevOpsCapability: Azure DevOps extraction failed, treating as unavailable rather than zero: $($_.Exception.Message)" $devopsResources = @() } } } function Select-ScoutDevOpsType { param([string] $Type) @($devopsResources | Where-Object { $_ -and $_.PSObject.Properties['type'] -and [string]$_.type -eq $Type }) } function ConvertTo-ScoutDevOpsProject { param($Resource) $data = if ($Resource.PSObject.Properties['properties']) { $Resource.properties } else { $null } [pscustomobject]@{ Organization = if ($Resource.PSObject.Properties['organization']) { [string]$Resource.organization } else { $null } Name = if ($Resource.PSObject.Properties['name']) { [string]$Resource.name } else { $null } State = if ($data -and $data.PSObject.Properties['state']) { [string]$data.state } else { $null } } } function ConvertTo-ScoutDevOpsPipeline { param($Resource) $data = if ($Resource.PSObject.Properties['properties']) { $Resource.properties } else { $null } $config = if ($data -and $data.PSObject.Properties['configuration']) { $data.configuration } else { $null } [pscustomobject]@{ Organization = if ($Resource.PSObject.Properties['organization']) { [string]$Resource.organization } else { $null } Project = if ($data -and $data.PSObject.Properties['projectName']) { [string]$data.projectName } else { $null } Name = if ($Resource.PSObject.Properties['name']) { [string]$Resource.name } else { $null } ConfigurationType = if ($config -and $config.PSObject.Properties['type']) { [string]$config.type } else { $null } YamlPath = if ($config -and $config.PSObject.Properties['path']) { [string]$config.path } else { $null } } } function ConvertTo-ScoutDevOpsRepository { param($Resource) $data = if ($Resource.PSObject.Properties['properties']) { $Resource.properties } else { $null } [pscustomobject]@{ Organization = if ($Resource.PSObject.Properties['organization']) { [string]$Resource.organization } else { $null } Project = if ($data -and $data.PSObject.Properties['projectName']) { [string]$data.projectName } else { $null } Name = if ($Resource.PSObject.Properties['name']) { [string]$Resource.name } else { $null } } } function ConvertTo-ScoutDevOpsServiceConnection { param($Resource) $data = if ($Resource.PSObject.Properties['properties']) { $Resource.properties } else { $null } $auth = if ($data -and $data.PSObject.Properties['authorization']) { $data.authorization } else { $null } $scheme = if ($auth -and $auth.PSObject.Properties['scheme']) { [string]$auth.scheme } else { $null } [pscustomobject]@{ Organization = if ($Resource.PSObject.Properties['organization']) { [string]$Resource.organization } else { $null } Project = if ($data -and $data.PSObject.Properties['projectName']) { [string]$data.projectName } else { $null } Name = if ($Resource.PSObject.Properties['name']) { [string]$Resource.name } else { $null } AuthorizationScheme = $scheme # Workload identity federation is the credential-free scheme -- same reading # DevOpsServiceConnections.psd1 already uses for the Excel report (AB#6828). CredentialFree = ($scheme -eq 'WorkloadIdentityFederation') } } function ConvertTo-ScoutDevOpsAgentPool { param($Resource) $data = if ($Resource.PSObject.Properties['properties']) { $Resource.properties } else { $null } $isHosted = [bool]($data -and $data.PSObject.Properties['isHosted'] -and $data.isHosted) [pscustomobject]@{ Organization = if ($Resource.PSObject.Properties['organization']) { [string]$Resource.organization } else { $null } Name = if ($Resource.PSObject.Properties['name']) { [string]$Resource.name } else { $null } Hosted = $isHosted } } $projects = @(Select-ScoutDevOpsType 'devops/projects' | ForEach-Object { ConvertTo-ScoutDevOpsProject $_ }) $pipelines = @(Select-ScoutDevOpsType 'devops/pipelines' | ForEach-Object { ConvertTo-ScoutDevOpsPipeline $_ }) $repositories = @(Select-ScoutDevOpsType 'devops/repositories' | ForEach-Object { ConvertTo-ScoutDevOpsRepository $_ }) $serviceConnections = @(Select-ScoutDevOpsType 'devops/serviceconnections' | ForEach-Object { ConvertTo-ScoutDevOpsServiceConnection $_ }) $agentPools = @(Select-ScoutDevOpsType 'devops/agentpools' | ForEach-Object { ConvertTo-ScoutDevOpsAgentPool $_ }) # `available`: an attempt was actually made (the switch was on, or a combined run already # made one) AND at least one Azure DevOps resource of ANY type came back. `-IncludeDevOps` # left off, no organization discoverable, and "no project access" everywhere all collapse # to the SAME unavailable signal on purpose -- a rule reading `devops.available == false` # never has to know which of the three happened, only that it must not score a zero. $available = $attempted -and (@($devopsResources).Count -gt 0) $devops = [pscustomobject]@{ available = [bool]$available attempted = [bool]$attempted projects = $projects pipelines = $pipelines repositories = $repositories serviceConnections = $serviceConnections agentPools = $agentPools managedPools = @($managedPools) devCenters = @($devCenters) loadTesting = @($loadTesting) chaosExperiments = @($chaosExperiments) playwrightTesting = @($playwrightTesting) } $Collect | Add-Member -NotePropertyName devops -NotePropertyValue $devops -Force return $Collect } |