Public/Get-AACUnusedResource.ps1

function Get-AACUnusedResource {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Finds the Azure resources nobody uses - unattached disks, NICs and
        public IPs, empty App Service plans, pools and gateways, stopped VMs
        still billed, old snapshots, empty resource groups and more - with
        whether each is still billed, since when, and what to do.
    .DESCRIPTION
        Reads the estate in one Azure Resource Graph batch and lists what is
        attached to nothing, serves nothing or is switched off:
 
          Unattached disk managed disks on no VM (since when)
          Unattached network interface NICs on no VM or private endpoint
          Unassociated public IP public IPs on nothing
          Unassociated NSG / route table on no subnet or NIC
          Unused NAT gateway on no subnet
          Load balancer / Application every backend pool empty (or a
          Gateway with no backends gateway stopped)
          Empty App Service plan no apps on it
          Empty SQL elastic pool no databases in it
          Stopped VM still billed stopped from the OS, not
                                           deallocated
          Deallocated VM only its disks are billed
          Old snapshot older than -SnapshotDays (90)
          Unlinked private DNS zone linked to no network
          Empty availability set, Traffic Manager profile, unused IP group,
          DDoS protection plan or WAF policy, expired certificate, empty
          resource group
 
        Each (AAC.UnusedResource) has a severity - High for what is billed
        heavily while doing nothing, Medium for what is billed by the hour or
        GB, Low for clutter - Billed (Yes, Partly, No), Since and AgeDays
        where Azure records it, and the fix. -IncludeCost adds each one's
        cost last month (Cost Management), and the total that deleting them
        would save.
 
        This is about configuration - what is attached to nothing. For
        resources that run but are idle (CPU, memory, requests), use
        Get-AACResourceUtilization. Read-only; Reader is enough (and Cost
        Management Reader for -IncludeCost).
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only these resource groups.
    .PARAMETER ResourceType
        Only resources of these types (for example
        'microsoft.compute/disks', or '*network*'); wildcards work.
    .PARAMETER Category
        Only these kinds of finding (for example 'Unattached disk', or
        '*VM*'); wildcards work.
    .PARAMETER Severity
        Only these severities (High, Medium, Low).
    .PARAMETER SnapshotDays
        Snapshots older than this many days are listed. Default 90.
    .PARAMETER IncludeCost
        Read each resource's cost last month from Cost Management.
    .PARAMETER CsvPath
        Write the resources to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the resources.
    .PARAMETER NoDisplay
        Return the resources without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACUnusedResource
        Everything unused in the subscriptions you can see, what's billed first.
    .EXAMPLE
        Get-AACUnusedResource -IncludeCost -HtmlPath .\out\Unused.html
        With last month's cost of each, as an interactive HTML report.
    .EXAMPLE
        Get-AACUnusedResource -Category 'Unattached disk' -NoDisplay | Where-Object AgeDays -GT 30 | Select-Object Resource, Sku, AgeDays, ResourceId
        The disks unattached for more than a month.
    .EXAMPLE
        Get-AACUnusedResource -Severity High, Medium -CsvPath .\out\Cleanup.csv
        What costs money, as a clean-up list.
    .OUTPUTS
        AAC.UnusedResource
    #>

    [CmdletBinding()]
    [OutputType('AAC.UnusedResource')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [string[]] $ResourceType,

        [SupportsWildcards()]
        [string[]] $Category,

        [ValidateSet('High', 'Medium', 'Low')]
        [string[]] $Severity,

        [ValidateRange(1, 3650)]
        [int] $SnapshotDays = 90,

        [switch] $IncludeCost,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Unused Azure resources',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    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
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); SnapshotDays = $SnapshotDays; IncludeCost = [bool]$IncludeCost }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Unused resources' -Color 'darkorange' }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $queries = Get-AACUnusedResourceQuery -ResourceGroupName $request.ResourceGroupName
        Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading disks, networks, plans, VMs and resource groups'
        $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @($queries.Keys) -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
        }
        Update-AACProgress -Id 'read' -Complete -Description "Read $($queries.Count - $read.Errors.Count) of $($queries.Count) kinds of resource"
        $notices = [System.Collections.Generic.List[string]]::new()
        foreach ($key in @($read.Errors.Keys | Sort-Object)) { $notices.Add("The $key couldn't be read: $($read.Errors[$key] -replace '\s+', ' ')") }

        $costs = @{}
        if ($request.IncludeCost) {
            Update-AACProgress -Id 'cost' -Indeterminate -Description 'Reading last month''s cost of each resource (Cost Management)'
            $costRead = Read-AACInventoryCost -TenantId ([string]$script:AACSession.TenantId) -Subscription @($scope.Subscriptions | ForEach-Object { @{ subscriptionId = [string]$_['subscriptionId']; name = [string]$_['name']; state = [string]$_['state'] } }) -ManagementGroupId $request.ManagementGroupId -PerSubscription:([bool]$request.SubscriptionId.Count)
            foreach ($row in @($costRead.Rows)) {
                $id = ([string](Get-AACPropertyValue -InputObject $row -Name 'ResourceId')).ToLowerInvariant()
                if (-not $id) { continue }
                $monthRaw = @('BillingMonth', 'UsageDate' | ForEach-Object { Get-AACPropertyValue -InputObject $row -Name $_ } | Where-Object { $_ }) | Select-Object -First 1
                $month = if ($monthRaw -is [datetime]) { $monthRaw.ToString('yyyy-MM') } else { ([string]$monthRaw).Substring(0, [Math]::Min(7, ([string]$monthRaw).Length)) -replace '^(\d{4})(\d{2}).*$', '$1-$2' }
                if ($month -ne $costRead.LastMonth) { continue }
                if (-not $costs.Contains($id)) { $costs[$id] = @{ Cost = 0.0; Currency = [string](Get-AACPropertyValue -InputObject $row -Name 'Currency') } }
                $costs[$id].Cost += [double](Get-AACPropertyValue -InputObject $row -Name 'Cost')
            }
            if ($costRead.Notice) { $notices.Add([string]$costRead.Notice) }
            Update-AACProgress -Id 'cost' -Complete -Description ('Read the cost of {0:N0} resource(s) for {1}' -f $costs.Count, $costRead.LastMonth)
        }
        $result = ConvertTo-AACUnusedResource -Read $read -SubscriptionName $scope.Names -SnapshotDays $request.SnapshotDays -Cost $costs
        @{ Result = $result; Scope = $scope; Notices = $notices.ToArray() }
    }

    $items = @($state.Result.Items)
    if ($ResourceType) { $items = @($items | Where-Object { $t = $_.ResourceType; @($ResourceType | Where-Object { $t -like $_ }).Count }) }
    if ($Category) { $items = @($items | Where-Object { $c = $_.Category; @($Category | Where-Object { $c -like $_ }).Count }) }
    if ($Severity) { $items = @($items | Where-Object { $Severity -contains $_.Severity }) }

    $rank = Get-AACSeverityRank
    $high = @($items | Where-Object Severity -EQ 'High').Count
    $billed = @($items | Where-Object Billed -EQ 'Yes').Count
    $priced = @($items | Where-Object { $null -ne $_.MonthlyCost })
    $monthly = 0.0; foreach ($p in $priced) { $monthly += [double]$p.MonthlyCost }; $monthly = [Math]::Round($monthly, 2)
    $currency = [string](@($priced | ForEach-Object { $_.Currency } | Where-Object { $_ }) | Select-Object -First 1)
    $report = @{
        Subtitle = 'Unused resources: attached to nothing, serving nothing or switched off'
        Facts    = [ordered]@{ Scope = $state.Scope.Label; Snapshots = "older than $SnapshotDays days"; Cost = $(if ($IncludeCost) { 'last month, Cost Management' } else { 'not read (-IncludeCost)' }) }
        Status   = $(if ($high) { 'Failed' } elseif ($billed) { 'Warning' } elseif ($items.Count) { 'Info' } else { 'Success' })
        Headline = $(if ($items.Count) { "$($items.Count) unused resource(s): $billed still billed, $high high$(if ($IncludeCost -and $priced.Count) { " - $('{0:N2}' -f $monthly) $currency a month" })" } else { 'Nothing unused in scope.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $items.Count; Label = 'unused resources'; Tone = $(if ($items.Count) { 'warn' } else { 'good' }) }
            @{ Value = '{0:N0}' -f $high; Label = 'high'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'unused'; Filters = @{ Severity = 'High' } }
            @{ Value = '{0:N0}' -f $billed; Label = 'still billed'; Tone = $(if ($billed) { 'warn' } else { 'good' }); Table = 'unused'; Filters = @{ Billed = 'Yes' } }
            @{ Value = $(if ($IncludeCost) { '{0:N2} {1}' -f $monthly, $currency } else { '-' }); Label = 'a month (last month)'; Tone = $(if ($monthly -gt 0) { 'bad' } else { 'neutral' }) }
            @{ Value = '{0:N0}' -f @($items | Where-Object Category -EQ 'Unattached disk').Count; Label = 'unattached disks'; Tone = 'info'; Table = 'unused'; Filters = @{ Category = 'Unattached disk' } }
            @{ Value = '{0:N0}' -f @($items | Where-Object Category -EQ 'Empty resource group').Count; Label = 'empty resource groups'; Tone = 'neutral'; Table = 'unused'; Filters = @{ Category = 'Empty resource group' } }
        )
        Notices  = @($state.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By severity'; Kind = 'donut'; CenterLabel = 'resources'; Items = @(foreach ($s in 'High', 'Medium', 'Low') { $n = @($items | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $rank.Tone[$s]; Filter = $s } } }); Table = 'unused'; Column = 'Severity' }
            @{ Title = 'By kind'; Items = @($items | Group-Object Category | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'unused'; Column = 'Category'; Tone = 'warn'; Console = $true }
            @{ Title = 'By subscription'; Items = @($items | Group-Object Subscription | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'unused'; Column = 'Subscription'; Tone = 'info' }
            @{ Title = 'Costliest (last month)'; Items = @($priced | Sort-Object MonthlyCost -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Resource; Value = $_.MonthlyCost; Filter = $_.Resource } }); Table = 'unused'; Column = 'Resource'; Tone = 'bad'; Console = [bool]$IncludeCost }
        )
        Tables   = @(
            @{ Id = 'unused'; Title = 'Unused resources'; Section = 'Unused resources'; Rows = $items; Noun = 'resources'; GroupBy = @('Category', 'Severity', 'Subscription', 'ResourceGroup'); ConsoleLimit = 30
                Empty = 'Nothing unused in scope.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Category'; Label = 'Kind'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Billed'; Label = 'Billed'; Type = 'badge'; Tones = @{ Yes = 'bad'; Partly = 'warn'; No = 'neutral' }; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'AgeDays'; Label = 'Age (days)'; Type = 'number'; Console = $true }
                    @{ Key = 'MonthlyCost'; Label = 'Last month'; Type = 'money'; CurrencyKey = 'Currency'; Sum = $true; Console = [bool]$IncludeCost; Pdf = [bool]$IncludeCost }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true; Console = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'ResourceType'; Label = 'Type'; Type = 'type'; Facet = $true }
                    @{ Key = 'Sku'; Label = 'SKU'; Facet = $true }
                    @{ Key = 'Location'; Label = 'Location'; Facet = $true }
                    @{ Key = 'Since'; Label = 'Since'; Type = 'date' }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' }
                ) }
        )
        Hint     = '-IncludeCost adds last month''s cost; -Category, -ResourceType and -Severity narrow the list; -NoDisplay returns the objects; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $items -Noun 'resource' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $items -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}