Public/Get-AACInventory.ps1

function Get-AACInventory {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Inventories the tenant as a tree - management groups, subscriptions,
        resource groups and resources - with a Spectre.Console tree view,
        objects, and CSV, PDF and interactive HTML exports.
    .DESCRIPTION
        Reads everything with Azure Resource Graph (Reader access is enough;
        no Az modules): the management groups, the subscriptions and the
        management group each is in, the resource groups and the resources.
        The tree is:
 
          Tenant
            Management groups (nested as in Azure)
              Subscriptions
                Resource groups (location, most common resource types)
                  Resources (type, location, SKU)
 
        with the number of subscriptions, resource groups and resources
        below every node. Management groups with no subscription in the result
        are left out, unless you asked for them with -ManagementGroupId. A
        subscription in a management group you can't read is shown under the
        tenant. Empty resource groups are flagged.
 
        Security posture comes from Microsoft Defender for Cloud (Resource
        Graph's securityresources; Reader or Security Reader is enough), in
        colour:
          - each subscription's secure score - Defender's own (current / max)
            - and management groups' and the tenant's, their subscriptions'
            scores added up as Defender does
          - each resource's score - the share of its assessed recommendations
            that are healthy - and its unhealthy findings by severity, rolled
            up to its resource group
          - Good (70% or more) green, Fair (40-69%) amber, Poor (under 40%)
            red; High findings red, Medium amber, Low blue, Healthy green
          - the security controls with the potential score increase of fixing
            each (their impact), and every unhealthy recommendation with its
            severity, user impact, effort and resource
        Without Defender data (not enabled, or no access) the inventory is
        shown without it. -NoSecurity skips reading it.
 
        -Cost adds what everything costs, from Azure Cost Management (Cost
        Management Reader, or Reader, on the subscriptions): the actual cost
        month to date and last month of every resource, rolled up to its
        resource group, subscription, management groups and the tenant.
          - Asked once for the whole scope when Cost Management allows it -
            the tenant root management group, or -ManagementGroupId (an
            Enterprise Agreement or Microsoft Customer Agreement) - and
            otherwise once per subscription, three at a time.
          - Costs of resources that no longer exist, and charges not tied to
            a resource, are shown under their subscription as 'Deleted
            resources'.
          - Amounts are in each subscription's billing currency and never
            converted: a level whose subscriptions are billed in different
            currencies shows 'mixed' rather than a total.
          - A subscription whose cost can't be read (some offer types, or no
            permission) says why; the rest of the inventory is unaffected.
 
        -Insight adds what the estate is made of and what needs attention,
        from the same parallel Resource Graph read:
          - the mix: VM sizes, operating systems (Windows Server 2022, Ubuntu
            22.04, ...), VM power states, Azure VMs and Azure Arc servers,
            storage account replication (LRS, ZRS, GRS, ...), database tiers
            (Azure SQL DTU, vCore or serverless, Cosmos DB, PostgreSQL,
            MySQL) and tag coverage (the tags the module's tag rules require,
            or else the most used)
          - needs attention: unattached disks, unused public IPs and NICs,
            VMs stopped but still billed (not deallocated), disconnected Arc
            servers, classic (retired) resources, subnets 80% full or more,
            VPN and ExpressRoute connections down, empty resource groups -
            each with its cost this month when -Cost is given too
          - every subnet's used and usable IPs (Azure keeps 5 per subnet),
            and the VPN and ExpressRoute connections
        As proportion charts and a table at the console, donut charts and
        tables in the HTML report, and a page in the PDF.
 
        What you get depends on where the command runs:
          at the prompt tiles, the tree (down to -Depth; resource groups by
                           default) and the most common resource types - a
                           page at a time
          piped onward the objects, with no view
          -PassThru the view and the objects
          -NoDisplay the objects only
        One AAC.InventoryItem per node: Level (Tenant, ManagementGroup,
        Subscription, ResourceGroup, Resource), Depth, Name, Path, the
        management group, subscription and resource group it is in, Type,
        Kind, Location, SKU, State, the counts below it, TopTypes,
        SecureScore, Rating, Severity, High, Medium, Low, Findings,
        TopFindings, CostMonthToDate, CostLastMonth, Currency, CostStatus,
        Tags, Id. With -Cost, a DeletedResources item per subscription with
        such costs.
 
        -CsvPath writes every node as a CSV row. -HtmlPath writes an
        interactive report: tiles, charts, the hierarchy as a collapsible,
        searchable tree with each node's score and findings in colour (click
        a node to see it in the tables), and tables of management groups,
        subscriptions, resource groups, resources, security controls and
        recommendations, each with its own CSV download. -PdfPath writes a
        PDF: the summary, the hierarchy, security (scores, controls,
        recommendations), subscriptions, resource groups, resources by type
        and the resources. With any of them, the console shows only the progress and
        the files written.
    .PARAMETER ManagementGroupId
        Only these management groups (their IDs, e.g. 'mg-corp') and
        everything below them.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ResourceGroupName
        Only these resource groups (in the subscriptions or management groups
        given, or in any you can see).
    .PARAMETER Depth
        How deep the console tree goes: ManagementGroup, Subscription,
        ResourceGroup (the default) or Resource. The objects and exports
        always have everything.
    .PARAMETER NoSecurity
        Don't read Microsoft Defender for Cloud: no secure scores, findings
        or recommendations.
    .PARAMETER Insight
        Also read what the estate is made of and what needs attention - VM
        sizes, operating systems, power states, Azure Arc servers, storage
        replication, database tiers, tag coverage; unattached disks, unused
        public IPs and NICs, VMs stopped but still billed, classic resources,
        nearly full subnets, VPN and ExpressRoute status - and show it in
        every output.
    .PARAMETER Cost
        Also read what everything costs - month to date and last month -
        from Azure Cost Management, and show it at every level.
    .PARAMETER CsvPath
        Write every node - tenant, management groups, subscriptions,
        resource groups and resources - to this CSV file.
    .PARAMETER PdfPath
        Write a PDF report to this file.
    .PARAMETER HtmlPath
        Write an interactive HTML report to this file.
    .PARAMETER Title
        The PDF and HTML reports' title.
    .PARAMETER PassThru
        Show the view and also return the objects.
    .PARAMETER NoDisplay
        Return the objects without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once instead of a page at a time.
    .EXAMPLE
        Connect-AAC
        Get-AACInventory
        The whole tenant as a tree, down to resource groups.
    .EXAMPLE
        Get-AACInventory -ManagementGroupId 'mg-landingzones' -Depth Resource
        One management group, down to every resource.
    .EXAMPLE
        Get-AACInventory -SubscriptionId '00000000-0000-0000-0000-000000000000' -HtmlPath .\out\Inventory.html -PdfPath .\out\Inventory.pdf
        One subscription as an interactive HTML report and a PDF.
    .EXAMPLE
        Get-AACInventory -Insight -Cost -HtmlPath .\out\Inventory.html
        The tenant with its mix, what needs attention and what that costs this month.
    .EXAMPLE
        Get-AACInventory -Cost -HtmlPath .\out\Inventory.html
        The tenant with what every resource, group and subscription costs.
    .EXAMPLE
        Get-AACInventory -NoDisplay | Where-Object { $_.Level -eq 'ResourceGroup' -and $_.Resources -eq 0 }
        The empty resource groups.
    .OUTPUTS
        AAC.InventoryItem (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding()]
    [OutputType('AAC.InventoryItem')]
    param(
        [ValidateNotNullOrEmpty()]
        [string[]] $ManagementGroupId,

        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [ValidateSet('ManagementGroup', 'Subscription', 'ResourceGroup', 'Resource')]
        [string] $Depth = 'ResourceGroup',

        [switch] $NoSecurity,

        [switch] $Cost,

        [switch] $Insight,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $HtmlPath,

        [string] $Title = 'Azure tenant inventory',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    # A failure anywhere below ends as a Spectre.Console error panel and this
    # command's own terminating error, not a line inside the module. A stopped
    # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a
    # rethrow would stop the caller's whole script, not only this command.
    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $exporting = $CsvPath -or $PdfPath -or $HtmlPath
    $showView = $interactive -and -not $exporting
    $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $csvFullPath = & $resolve $CsvPath
    $pdfFullPath = & $resolve $PdfPath
    $htmlFullPath = & $resolve $HtmlPath
    $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" }

    $groupFilter = if ($ResourceGroupName) { "($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' }

    if ($interactive) {
        Write-AACRule -Title 'Azure Admin Console :: Tenant inventory' -Color 'deepskyblue3_1'
    }
    $state = Invoke-AACProgress -ScriptBlock {
        $null = Get-AACAccessToken
        $notices = [System.Collections.Generic.List[string]]::new()
        Update-AACProgress -Id 'read' -Total $((5 + $(if ($NoSecurity) { 0 } else { 4 }) + $(if ($Insight) { 11 } else { 0 }))) -Description 'Reading the tenant, its management groups, subscriptions, resource groups and resources'
        $tenantId = if ($script:AACSession) { [string]$script:AACSession.TenantId } else { '' }
        $tenantName = ''
        try {
            $tenants = Invoke-AACArmRequest -Uri '/tenants?api-version=2022-12-01'
            $match = @($tenants['value']) | Where-Object { [string]$_['tenantId'] -eq $tenantId } | Select-Object -First 1
            if ($match) { $tenantName = (@([string]$match['displayName'], [string]$match['defaultDomain']) | Where-Object { $_ } | Select-Object -First 1) }
        }
        catch { Write-Debug "The tenant's name couldn't be read: $($_.Exception.Message)" }
        Update-AACProgress -Id 'read' -Increment 1

        # Every Resource Graph query at once (Invoke-AACGraphBatch): rows as
        # hashtables, scoped to the subscriptions or management groups given;
        # the management groups across the tenant, for the path above them.
        $queries = [ordered]@{
            groups         = @{ Tenant = $true; Query = "resourcecontainers | where type =~ 'microsoft.management/managementgroups' | project id, name, displayName = tostring(properties.displayName), parentId = tostring(properties.details.parent.id)" }
            subscriptions  = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name, state = tostring(properties.state), parentGroup = tostring(properties.managementGroupAncestorsChain[0].name), quotaId = tostring(properties.subscriptionPolicies.quotaId), tags"
            resourceGroups = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups'$(if ($groupFilter) { " and name in~ $groupFilter" }) | project id, name, subscriptionId, location, state = tostring(properties.provisioningState), managedBy, tags"
            resources      = "resources$(if ($groupFilter) { " | where resourceGroup in~ $groupFilter" }) | project id, name, type, kind, location, resourceGroup, subscriptionId, sku = tostring(sku.name), zones, tags"
        }
        # Microsoft Defender for Cloud: secure scores, controls, and each
        # resource's assessments (a summary, and the unhealthy ones).
        $securityNames = @('Scores', 'Controls', 'Summary', 'Recommendations')
        if (-not $NoSecurity) {
            # The shared Defender for Cloud queries (Get-AACDefenderQuery).
            $defender = Get-AACDefenderQuery -Name $securityNames
            foreach ($name in $securityNames) { $queries[$name] = $defender[$name] }
        }
        $labels = @{ groups = 'the management groups'; subscriptions = 'the subscriptions'; resourceGroups = 'the resource groups'; resources = 'the resources'; insightVms = 'the virtual machines'; insightArc = 'the Azure Arc servers'; insightDisks = 'the disks'; insightPublicIps = 'the public IPs'; insightNics = 'the network interfaces'; insightStorage = 'the storage accounts'; insightDatabases = 'the databases'; insightSubnets = 'the subnets'; insightConnections = 'the VPN and ExpressRoute connections'; insightCircuits = 'the ExpressRoute circuits'; insightClassic = 'the classic resources' }
        # The insights' queries (Get-AACInsightQuery), in the same batch.
        $insightNames = @()
        if ($Insight) {
            $insightQueries = Get-AACInsightQuery -GroupFilter $(if ($groupFilter) { " | where resourceGroup in~ $groupFilter" } else { '' })
            $insightNames = @($insightQueries.Keys)
            foreach ($name in $insightNames) { $queries[$name] = $insightQueries[$name] }
        }
        $batch = Invoke-AACGraphBatch -Query $queries -SubscriptionId $SubscriptionId -ManagementGroupId $ManagementGroupId -AllowFailure (@('groups') + $securityNames + $insightNames) -OnProgress {
            param($Name, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read $(if ($labels.Contains($Name)) { $labels[$Name] } else { 'Microsoft Defender for Cloud' }) ($Done of $Total queries)"
        }

        $groups = @($batch.Rows['groups'])
        if ($batch.Errors.Contains('groups')) { $notices.Add("The management groups couldn't be read ($($batch.Errors['groups'] -replace '\s+', ' ')), so subscriptions are shown under the tenant.") }
        if (-not $groups.Count) { $notices.Add('No management groups are visible to this account, so subscriptions are shown under the tenant.') }
        # With -ManagementGroupId: those groups, the groups below them, and
        # the groups above them (the path from the tenant).
        if ($ManagementGroupId) {
            $byName = @{}
            foreach ($group in $groups) { $byName[([string]$group['name']).ToLowerInvariant()] = $group }
            $missing = @($ManagementGroupId | Where-Object { -not $byName.Contains($_.ToLowerInvariant()) })
            if ($missing.Count -eq $ManagementGroupId.Count -and $groups.Count) { throw "No management group with the ID $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found. Use the group's ID (its name), not its display name." }
            foreach ($name in $missing) { Write-Warning "No management group with the ID '$name' was found; it's left out." }
        }

        $subscriptions = @($batch.Rows['subscriptions'])
        $resourceGroups = @($batch.Rows['resourceGroups'])
        if ($ResourceGroupName) {
            $found = @($resourceGroups | ForEach-Object { [string]$_['name'] })
            $missing = @($ResourceGroupName | Where-Object { $name = $_; -not @($found | Where-Object { $_ -eq $name }).Count })
            if ($missing.Count -eq $ResourceGroupName.Count) { throw "No resource group named $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found in the subscriptions you can see$(if ($SubscriptionId -or $ManagementGroupId) { ' in that scope' })." }
            foreach ($name in $missing) { Write-Warning "No resource group named '$name' was found; it's left out." }
            # Only the subscriptions those groups are in.
            $withGroups = @($resourceGroups | ForEach-Object { ([string]$_['subscriptionId']).ToLowerInvariant() } | Select-Object -Unique)
            $subscriptions = @($subscriptions | Where-Object { ([string]$_['subscriptionId']).ToLowerInvariant() -in $withGroups })
        }
        $resources = @($batch.Rows['resources'])

        $security = $null
        if (-not $NoSecurity) {
            $failed = @($securityNames | Where-Object { $batch.Errors.Contains($_) })
            if ($failed.Count) {
                $notices.Add("Microsoft Defender for Cloud couldn't be read ($($batch.Errors[$failed[0]] -replace '\s+', ' ')), so there are no secure scores.")
            }
            else {
                $security = @{}
                foreach ($name in $securityNames) { $security[$name] = @($batch.Rows[$name]) }
                if (-not @(@($security.Scores) + @($security.Summary) | Where-Object { $_ }).Count) {
                    $notices.Add('No Microsoft Defender for Cloud data was found in this scope (not enabled, or no access), so there are no secure scores.')
                }
            }
        }
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} management group(s), {1:N0} subscription(s), {2:N0} resource group(s) and {3:N0} resource(s)' -f $groups.Count, $subscriptions.Count, $resourceGroups.Count, $resources.Count)

        # --- Cost (Cost Management) ----------------------------------------------------------------
        $costData = $null
        if ($Cost) {
            $costData = Read-AACInventoryCost -TenantId $tenantId -Subscription $subscriptions -ManagementGroupId $ManagementGroupId -PerSubscription:($SubscriptionId -or $ResourceGroupName)
            $costData.FilterGroups = [bool]$ResourceGroupName
            foreach ($notice in @($costData.Notice)) { $notices.Add($notice) }
        }

        Update-AACProgress -Id 'tree' -Description 'Building the tree' -Indeterminate
        $inventory = ConvertTo-AACInventory -TenantId $tenantId -TenantName $tenantName -ManagementGroup $groups -Subscription $subscriptions -ResourceGroup $resourceGroups -Resource $resources -KeepManagementGroup @($ManagementGroupId | Where-Object { $_ }) -Security $security -Cost $costData
        if ($Insight) {
            $rows = @{}
            foreach ($name in $insightNames) { $rows[$name] = @($batch.Rows[$name]) }
            $unread = @($insightNames | Where-Object { $batch.Errors.Contains($_) })
            if ($unread.Count) { $notices.Add("Some insights couldn't be read ($(($unread | ForEach-Object { $_ -replace '^insight', '' }) -join ', ')): $($batch.Errors[$unread[0]] -replace '\s+', ' ')") }
            # Tag coverage: the tags the module's tag rules require (Get-AACTagDefault), if any.
            $required = @((Get-AACRuleDefault -File 'AAC.Tags.Rule.ps1' -Function 'Get-AACTagDefault')['RequiredTags'] | Where-Object { $_ })
            $inventory.Insight = ConvertTo-AACInventoryInsight -Rows $rows -Item $inventory.Items -RequiredTag $required
        }
        $inventory.Notice = $notices.ToArray()
        $posture = if ($inventory.Stats.HasSecurity -and $null -ne $inventory.Stats.SecureScore) { ", secure score $($inventory.Stats.SecureScore)%" } else { '' }
        Update-AACProgress -Id 'tree' -Complete -Description ('Tree: {0:N0} management group(s) > {1:N0} subscription(s) > {2:N0} resource group(s) > {3:N0} resource(s){4}' -f $inventory.Stats.ManagementGroups, $inventory.Stats.Subscriptions, $inventory.Stats.ResourceGroups, $inventory.Stats.Resources, $posture)

        $scope = [ordered]@{
            Scope = if ($ManagementGroupId) { "management group(s) $($ManagementGroupId -join ', ')" } elseif ($SubscriptionId) { "subscription(s) $($SubscriptionId -join ', ')" } else { 'the whole tenant (everything the account can see)' }
        }
        if ($ResourceGroupName) { $scope['Resource groups'] = $ResourceGroupName -join ', ' }
        if ($costData) { $scope['Cost'] = $costData.Period }
        $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject @($inventory.Items | Select-Object -Property * -ExcludeProperty Depth) -Noun 'item' -PdfPath $pdfFullPath -WritePdf {
            Write-AACInventoryPdf -Inventory $inventory -Path $pdfFullPath -Title $Title -Detail $scope
        } -HtmlPath $htmlFullPath -WriteHtml {
            Write-AACInventoryHtml -Inventory $inventory -Path $htmlFullPath -Title $Title -Detail $scope
        }
        @{ Inventory = $inventory; Scope = $scope }
    }
    $inventory = $state.Inventory

    if ($showView) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACInventoryView -Inventory $inventory -Depth $Depth -Scope $state.Scope
        }
    }
    elseif ($interactive -and $inventory.Notice) {
        foreach ($notice in $inventory.Notice) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" }
    }

    if ($returnObjects) {
        $inventory.Items
    }
}