public/Get-MsecAzureDevOpsAgentPool.ps1
|
function Get-MsecAzureDevOpsAgentPool { <# .SYNOPSIS Agent pools in an Azure DevOps organization, with what runs in them - one row per pool. .DESCRIPTION A self-hosted agent executes pipeline code on a machine you own, as whatever account the agent service runs under. Anyone who can queue a pipeline against the pool can run code there. That makes pool membership a permission question and the agents themselves an estate question - what they are, how old, and whether they are still reachable. MICROSOFT-HOSTED POOLS ARE DISPOSABLE; SELF-HOSTED ONES ARE NOT. A hosted agent is a fresh VM per job. A self-hosted agent keeps its disk, its credentials and whatever the last job left behind, so a compromised pipeline persists there. AUTOPROVISION MEANS EVERY NEW PROJECT GETS THE POOL. It is how a pool intended for one team ends up reachable from projects nobody associated with it. AGENT VERSIONS AND OPERATING SYSTEMS ARE REPORTED AS DISTINCT LISTS, not summarised. A pool where most agents are current and one is three major versions behind is the case worth seeing, and an average or a maximum would hide exactly that agent. AN OFFLINE AGENT THAT IS STILL ENABLED IS NOT DECOMMISSIONED. It is a machine that will rejoin and start taking jobs the moment it comes back, which is a different thing from one that was removed. LongestOfflineDays says how long the most absent of them has been gone - measured on a live organization, two years, on an agent two major versions behind. .PARAMETER Organization Azure DevOps organization name: the path segment after dev.azure.com/. .PARAMETER SelfHostedOnly Only pools running on your own machines. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecAzureDevOpsAgentPool -Organization 'contoso' -SelfHostedOnly .EXAMPLE # Pools reachable from every project, running on machines you own. Get-MsecAzureDevOpsAgentPool -Organization 'contoso' | Where-Object { -not $_.IsHosted -and $_.AutoProvision } .OUTPUTS PSCustomObject per pool, PSTypeName 'MsecAzureDevOpsAgentPool'. .NOTES Needs Connect-Msec and organization membership. Everything here is readable by any member, including -IncludeExposure. THERE IS DELIBERATELY NO ROLE-ASSIGNMENT COLUMN. Reading distributedtask.agentqueuerole was not enabled by 'View' or by 'Use' on the DistributedTask namespace - both were granted at the organization root against a live organization and the read still returned 403. The only remaining candidate is 'AdministerPermissions', the right to CHANGE permissions, which this module has no business holding to display four counts. The question those counts would answer - who can put work on this pool - is answered by -IncludeExposure instead: which projects have a queue for it, and whether any pipeline in those projects may use it without approval. That needs no extra permission. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0)] [string] $Organization, [switch] $SelfHostedOnly, # Which projects can queue work on each pool, and whether any pipeline in those projects # may do so without approval. # # OPT-IN and the most expensive thing here: one call per project to list queues, plus one # per queue belonging to a pool being reported. AutoProvision answers "will FUTURE # projects get this pool"; this answers "which ones have it NOW", which is the question # an access review actually asks. [switch] $IncludeExposure ) Assert-MsecSession $pools = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1' -Path '_apis/distributedtask/pools' -All) if (-not $pools.Count) { Write-Warning "No agent pools returned for '$Organization'. That is 'nothing was read', not 'none exist' - every organization has the Microsoft-hosted pools." return } # poolId -> @{ Projects = @(names); OpenProjects = @(names) }. Built once, before the pool # loop, because queues are addressed per PROJECT and the pool is the thing we report on. $exposure = @{} if ($IncludeExposure) { $projects = @() try { $projects = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization -HostName 'dev.azure.com' -ApiVersion '7.1' -Path '_apis/projects' -All) } catch { Write-Warning "Could not list projects, so exposure columns are `$null: $($_.Exception.Message)" } foreach ($proj in $projects) { $queues = @() try { $queues = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization -HostName 'dev.azure.com' ` -ApiVersion '7.1-preview.1' -Path "$($proj.id)/_apis/distributedtask/queues") } catch { # Named: a project whose queues could not be read is a project whose exposure is # unknown, not a project with none. Write-Warning "Could not read queues in project '$($proj.name)', so its pools may under-report exposure: $($_.Exception.Message)" continue } foreach ($queue in $queues) { $poolId = [string] $queue.pool.id if (-not $poolId) { continue } if (-not $exposure.ContainsKey($poolId)) { $exposure[$poolId] = @{ Projects = [System.Collections.Generic.List[string]]::new() OpenProjects = [System.Collections.Generic.List[string]]::new() } } $exposure[$poolId].Projects.Add($proj.name) try { $perms = Invoke-MsecAzureDevOpsRequest -Organization $Organization -HostName 'dev.azure.com' ` -ApiVersion '7.1-preview.1' -Path "$($proj.id)/_apis/pipelines/pipelinePermissions/queue/$($queue.id)" # The field is omitted unless the setting is on, so absence is 'not open'. if ([bool] $perms.allPipelines.authorized) { $exposure[$poolId].OpenProjects.Add($proj.name) } } catch { Write-Verbose "Could not read pipeline permissions for queue $($queue.id) in '$($proj.name)': $($_.Exception.Message)" } } } } foreach ($pool in $pools) { if ($SelfHostedOnly -and $pool.isHosted) { continue } # Hosted pools have no enumerable agents; asking is a wasted call and a confusing 404. $agents = @() $agentsRead = $false if (-not $pool.isHosted) { try { $agents = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization ` -HostName 'dev.azure.com' -ApiVersion '7.1' ` -Path "_apis/distributedtask/pools/$($pool.id)/agents") $agentsRead = $true } catch { # Left unread rather than reported as an empty pool. Write-Warning "Could not read agents in pool '$($pool.name)', so its agent columns are `$null: $($_.Exception.Message)" } } else { $agentsRead = $true } [PSCustomObject]@{ PSTypeName = 'MsecAzureDevOpsAgentPool' Organization = $Organization Pool = $pool.name IsHosted = [bool] $pool.isHosted # Every new project gets this pool without anyone asking for it. AutoProvision = [bool] $pool.autoProvision AutoUpdate = [bool] $pool.autoUpdate AgentCount = if ($agentsRead) { $agents.Count } else { $null } AgentsOnline = if ($agentsRead) { @($agents | Where-Object { $_.status -eq 'online' }).Count } else { $null } # Enabled but offline: not decommissioned, just not here right now. AgentsOfflineEnabled = if ($agentsRead) { @($agents | Where-Object { $_.status -ne 'online' -and $_.enabled }).Count } else { $null } AgentsDisabled = if ($agentsRead) { @($agents | Where-Object { -not $_.enabled }).Count } else { $null } # How long the longest-absent enabled agent has been gone. An agent offline for two # years and still enabled is registered infrastructure that will rejoin and start # taking jobs the moment its machine powers on - on whatever agent version and # operating system it was left at. A count of offline agents does not convey that; # the age does. LongestOfflineDays = if ($agentsRead) { $away = @($agents | Where-Object { $_.status -ne 'online' -and $_.enabled -and $_.statusChangedOn }) # [int] on the RESULT too: Measure-Object -Maximum returns a double, so the column # rendered as 731.000 rather than 731. if ($away.Count) { [int] ($away | ForEach-Object { ([datetime]::UtcNow - [datetime] $_.statusChangedOn).TotalDays } | Measure-Object -Maximum).Maximum } else { $null } } else { $null } # Distinct, not summarised - one agent far behind the others is the finding. AgentVersions = if ($agentsRead -and $agents.Count) { (@($agents.version) | Sort-Object -Unique) -join ', ' } elseif ($agentsRead) { '' } else { $null } AgentOperatingSystems = if ($agentsRead -and $agents.Count) { (@($agents.osDescription) | Sort-Object -Unique) -join '; ' } elseif ($agentsRead) { '' } else { $null } # Projects that can queue work on this pool today. $null when not collected. ProjectCount = if ($IncludeExposure) { @($exposure[[string] $pool.id].Projects).Count } else { $null } Projects = if ($IncludeExposure) { (@($exposure[[string] $pool.id].Projects) | Sort-Object -Unique) -join '; ' } else { $null } # The PROJECTS in which any pipeline may use this pool with no further approval - # named rather than counted, because which project matters. A boolean would say OpenInProjects = if ($IncludeExposure) { (@($exposure[[string] $pool.id].OpenProjects) | Sort-Object -Unique) -join '; ' } else { $null } PoolType = $pool.poolType Owner = $pool.owner.displayName CreatedOn = $pool.createdOn PoolId = $pool.id } } } |