Public/Export-AACDiagnosticBundle.ps1

function Export-AACDiagnosticBundle {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Packages everything needed to troubleshoot Azure resources into one
        zip for a support case or a colleague - each resource's
        configuration, health and its history, Activity Log, diagnostic
        settings, locks, recommendations, compliance, alerts, recent changes
        and role assignments - with secrets redacted and a summary on top.
    .DESCRIPTION
        For each resource (-ResourceId, piped in, or every resource of
        -ResourceGroupName, up to -MaxResources):
 
          config.json the resource as Resource Graph has it
          health.json Resource Health now, its history and the
                                    latest health event
          activity-log.json the Activity Log for the resource, the
                                    last -Hours
          diagnostic-settings.json where its logs and metrics go
          locks.json management locks on it
          advisor.json Azure Advisor recommendations
          defender.json unhealthy Defender for Cloud assessments
          policy.json non-compliant Azure Policy states
          alerts.json alerts fired in the last -Hours
          changes.json property changes (Resource Graph keeps
                                    14 days)
          role-assignments.json role assignments scoped to it
 
        and at the top of the zip: README.md (what is where, when, the
        window), summary.json (the manifest: resources, files, what couldn't
        be read) and summary.html (the summary as an interactive report).
 
        Secrets are redacted before anything is written: values whose name
        says password, secret, token, connection string, SAS, credentials
        or an access, account or shared access key - and values that look
        like a connection string, a SAS signature, a JWT or a private key.
        Read the bundle before you send it anyway: the account and tenant ID
        it was made with, names, IPs and tags stay in.
 
        Read-only in Azure; Reader is enough. Writes one zip file (-Force to
        overwrite it); -WhatIf shows where.
    .PARAMETER ResourceId
        The resources to bundle. Takes ResourceId or Id from the pipeline.
    .PARAMETER ResourceGroupName
        Bundle every resource in this resource group (up to -MaxResources).
    .PARAMETER SubscriptionId
        The subscription of -ResourceGroupName (otherwise every subscription
        with a resource group of that name).
    .PARAMETER Path
        The zip file to write.
    .PARAMETER Hours
        How far back the Activity Log and alerts go. Default 24; up to 720.
    .PARAMETER MaxResources
        The most resources to bundle. Default 50.
    .PARAMETER Force
        Overwrite the zip file if it exists.
    .PARAMETER PassThru
        Show the summary and also return the bundle.
    .PARAMETER NoDisplay
        Return the bundle without showing the summary.
    .PARAMETER NoPaging
        Show the whole summary at once.
    .EXAMPLE
        Export-AACDiagnosticBundle -ResourceId $vm.Id -Path .\out\vm-case.zip
        One VM's configuration, health, activity and more, for a support case.
    .EXAMPLE
        Export-AACDiagnosticBundle -ResourceGroupName rg-app -SubscriptionId $sub -Hours 72 -Path .\out\rg-app.zip
        Every resource in rg-app with three days of activity.
    .EXAMPLE
        Get-AACResourceHealth -State Unavailable -NoDisplay | Export-AACDiagnosticBundle -Path .\out\down.zip
        A bundle of everything that is down right now.
    .OUTPUTS
        AAC.DiagnosticBundle
    #>

    [CmdletBinding(DefaultParameterSetName = 'Resource', SupportsShouldProcess)]
    [OutputType('AAC.DiagnosticBundle')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Resource', ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [string[]] $ResourceId,

        [Parameter(Mandatory, ParameterSetName = 'ResourceGroup')]
        [string] $ResourceGroupName,

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

        [Parameter(Mandatory)]
        [string] $Path,

        [ValidateRange(1, 720)]
        [int] $Hours = 24,

        [ValidateRange(1, 200)]
        [int] $MaxResources = 50,

        [switch] $Force,

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    begin {
        $wanted = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($id in @($ResourceId | Where-Object { $_ })) { if ($id -match '^/subscriptions/[^/]+/resourcegroups/[^/]+/providers/') { $wanted.Add($id.TrimEnd('/')) } else { Write-Warning "Not a resource ID, skipped: $id" } }
    }

    end {
        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
        $zip = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        if ($zip -notmatch '\.zip$') { $zip += '.zip' }
        if ((Test-Path -LiteralPath $zip) -and -not $Force) { throw [System.IO.IOException]::new("$zip already exists. Use -Force to overwrite it, or choose another -Path.") }
        if (-not $PSCmdlet.ShouldProcess($zip, 'Write a diagnostic bundle')) { return }
        $request = @{ Ids = $wanted.ToArray(); ResourceGroupName = $ResourceGroupName; SubscriptionId = $SubscriptionId; Hours = $Hours; Max = $MaxResources; Version = [string]$MyInvocation.MyCommand.Module.Version }

        $null = Get-AACAccessToken
        if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Diagnostic bundle' -Color 'deepskyblue3_1' }
        $outcome = Invoke-AACProgress -ScriptBlock {
            $warnings = [System.Collections.Generic.List[string]]::new()
            $ids = @($request.Ids | Select-Object -Unique)
            if ($request.ResourceGroupName) {
                Update-AACProgress -Id 'list' -Indeterminate -Description "Listing the resources of $($request.ResourceGroupName)"
                $listed = Invoke-AACGraphBatch -Query ([ordered]@{ resources = "resources | where resourceGroup =~ '$($request.ResourceGroupName -replace "'", "\'")' | project id, name, type | order by type asc, name asc" }) -SubscriptionId @($request.SubscriptionId | Where-Object { $_ })
                $ids = @($listed.Rows['resources'] | Where-Object { $_ } | ForEach-Object { [string]$_['id'] })
                Update-AACProgress -Id 'list' -Complete -Description ('{0:N0} resource(s) in {1}' -f $ids.Count, $request.ResourceGroupName)
                if (-not $ids.Count) { throw [System.InvalidOperationException]::new("No resources found in resource group '$($request.ResourceGroupName)'$(if ($request.SubscriptionId) { " of subscription $($request.SubscriptionId)" }).") }
            }
            if (-not $ids.Count) { throw [System.ArgumentException]::new('No resource to bundle: give -ResourceId (or pipe resources in) or -ResourceGroupName.') }
            if ($ids.Count -gt $request.Max) { $warnings.Add("$($ids.Count) resources: the first $($request.Max) were bundled (-MaxResources)."); $ids = @($ids | Select-Object -First $request.Max) }
            $lower = @($ids | ForEach-Object { $_.ToLowerInvariant() })
            $subscriptions = @($lower | ForEach-Object { ($_ -split '/')[2] } | Select-Object -Unique)

            # --- Resource Graph ----------------------------------------------------------------------------------------
            $queries = Get-AACDiagnosticBundleQuery -ResourceId $lower -Hours $request.Hours
            Update-AACProgress -Id 'graph' -Total $queries.Count -Description "Reading $($ids.Count) resource(s) from Resource Graph"
            $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $subscriptions -AllowFailure @($queries.Keys) -OnProgress { param($QueryName, $Done, $Total) Update-AACProgress -Id 'graph' -Increment 1 -Description "Read the $QueryName ($Done of $Total)" }
            Update-AACProgress -Id 'graph' -Complete -Description "Read $($queries.Count - $read.Errors.Count) of $($queries.Count) Resource Graph sections"

            # --- Resource Manager, per resource ------------------------------------------------------------------------
            $to = [datetime]::UtcNow
            $iso = { param([datetime] $When) $When.ToString('yyyy-MM-ddTHH:mm:ssZ', [cultureinfo]::InvariantCulture) }
            $uris = [ordered]@{}
            foreach ($id in $lower) {
                $sub = ($id -split '/')[2]
                $filter = "eventTimestamp ge '$(& $iso $to.AddHours(-$request.Hours))' and eventTimestamp le '$(& $iso $to)' and resourceUri eq '$id'"
                $uris["$id|Activity"] = "/subscriptions/$sub/providers/Microsoft.Insights/eventtypes/management/values?api-version=2015-04-01&`$filter=$([System.Uri]::EscapeDataString($filter))"
                $uris["$id|Diagnostics"] = "$id/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview"
                $uris["$id|Locks"] = "$id/providers/Microsoft.Authorization/locks?api-version=2020-05-01"
                $uris["$id|History"] = "$id/providers/Microsoft.ResourceHealth/availabilityStatuses?api-version=2022-10-01"
            }
            Update-AACProgress -Id 'arm' -Total $uris.Count -Description 'Reading activity, diagnostic settings, locks and health history'
            $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($ArmDone, $ArmTotal) Update-AACProgress -Id 'arm' -Increment 1 }
            $arm = @{}
            foreach ($key in $uris.Keys) {
                $id, $part = $key.Split('|')
                if (-not $arm.Contains($id)) { $arm[$id] = @{} }
                $a = $answers[$uris[$key]]
                # A resource type without diagnostic settings or Resource Health answers 400/404: not an error, just nothing.
                $arm[$id][$part] = if ($a -and $a.Error -and $part -in 'Diagnostics', 'History' -and [int]$a.Status -in 400, 404, 405) { $null } else { $a }
            }
            Update-AACProgress -Id 'arm' -Complete -Description ('Read {0:N0} Resource Manager section(s)' -f $uris.Count)
            $scopeNames = @{}
            $result = ConvertTo-AACDiagnosticBundle -ResourceId $lower -Read $read -Arm $arm -SubscriptionName $scopeNames

            # --- Write, redacted, and zip -----------------------------------------------------------------------------------
            Update-AACProgress -Id 'write' -Indeterminate -Description 'Writing the bundle (secrets redacted)'
            $work = Join-Path ([System.IO.Path]::GetTempPath()) "aac-bundle-$([guid]::NewGuid().ToString('N'))"
            $utf8 = [System.Text.UTF8Encoding]::new($false)
            $redacted = 0
            $files = [System.Collections.Generic.List[string]]::new()
            $write = {
                param([string] $Relative, $Data)
                $full = Join-Path $work $Relative
                $null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $full)
                $clean = ConvertTo-AACRedactedObject -InputObject $Data -Count ([ref]$redacted)
                [System.IO.File]::WriteAllText($full, ($(if ($null -eq $clean) { 'null' } else { ConvertTo-Json -InputObject $clean -Depth 64 })), $utf8)
                $files.Add($Relative.Replace('\', '/'))
            }
            try {
                foreach ($r in $result.Resources) { foreach ($file in $r.Sections.Keys) { & $write "resources/$($r.Folder)/$file" $r.Sections[$file] } }
                $session = $script:AACSession
                $manifest = [ordered]@{
                    generated   = $to.ToString('o', [cultureinfo]::InvariantCulture)
                    module      = "Azure.Admin.Console $($request.Version)"
                    account     = $(if ($session) { [string]$session.Account } else { '' })
                    tenantId    = $(if ($session) { [string]$session.TenantId } else { '' })
                    windowHours = $request.Hours
                    resources   = @($result.Resources | ForEach-Object { $_.Summary })
                    warnings    = @($warnings) + @($result.Warnings)
                }
                & $write 'summary.json' $manifest
                $readme = @(
                    '# Azure diagnostic bundle'
                    ''
                    "Generated $($manifest.generated) by $($manifest.module)$(if ($manifest.account) { " ($($manifest.account), tenant $($manifest.tenantId))" }); Activity Log and alerts cover the last $($request.Hours) hour(s)."
                    ''
                    'Secrets (keys, passwords, tokens, connection strings, SAS) were replaced by [REDACTED]. Check the files before sharing them anyway.'
                    ''
                    '| Resource | Type | Health | Failed operations | Alerts | Issues | Folder |'
                    '| --- | --- | --- | --- | --- | --- | --- |'
                    @($result.Resources | ForEach-Object { $s = $_.Summary; "| $($s.Resource) | $($s.ResourceType) | $($s.Health) | $($s.FailedOperations) | $($s.Alerts) | $($s.Issues) | $($s.Folder) |" })
                    ''
                    'Each folder holds: config.json, health.json, activity-log.json, diagnostic-settings.json, locks.json, advisor.json, defender.json, policy.json, alerts.json, changes.json, role-assignments.json.'
                    $(if ($manifest.warnings.Count) { "`n## What couldn't be read`n`n$(@($manifest.warnings | ForEach-Object { "- $_" }) -join "`n")" })
                ) -join "`n"
                [System.IO.File]::WriteAllText((Join-Path $work 'README.md'), $readme + "`n", $utf8); $files.Add('README.md')
                @{ Result = $result; Work = $work; Files = $files; Redacted = $redacted; Warnings = @($warnings) + @($result.Warnings) }
            }
            catch { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue; throw }
        }

        $rows = @($outcome.Result.Resources | ForEach-Object { $_.Summary })
        $report = @{
            Subtitle = 'Diagnostic bundle: configuration, health, activity, settings, recommendations and changes per resource'
            Facts    = [ordered]@{ Bundle = $zip; Window = "the last $Hours hour(s)"; Redacted = "$($outcome.Redacted) value(s)" }
            Status   = $(if (@($rows | Where-Object Status -EQ 'Failed').Count) { 'Warning' } else { 'Success' })
            Headline = "Bundled $($rows.Count) resource(s) into $zip ($($outcome.Files.Count + 1) file(s), $($outcome.Redacted) secret value(s) redacted)"
            Tiles    = @(
                @{ Value = '{0:N0}' -f $rows.Count; Label = 'resources'; Tone = 'info' }
                @{ Value = '{0:N0}' -f @($rows | Where-Object { $_.Health -in 'Unavailable', 'Degraded' }).Count; Label = 'not healthy'; Tone = $(if (@($rows | Where-Object { $_.Health -in 'Unavailable', 'Degraded' }).Count) { 'bad' } else { 'good' }) }
                @{ Value = '{0:N0}' -f @($rows | ForEach-Object { $_.FailedOperations } | Measure-Object -Sum).Sum; Label = 'failed operations'; Tone = 'warn' }
                @{ Value = '{0:N0}' -f @($rows | ForEach-Object { $_.Alerts } | Measure-Object -Sum).Sum; Label = 'alerts'; Tone = 'warn' }
                @{ Value = '{0:N0}' -f $outcome.Redacted; Label = 'values redacted'; Tone = 'violet' }
            )
            Notices  = @($outcome.Warnings | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
            Tables   = @(
                @{ Id = 'resources'; Title = 'Resources in the bundle'; Section = 'Resources'; Rows = $rows; Noun = 'resources'; ConsoleLimit = 50
                    Columns = @(
                        @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = @{ Success = 'good'; Warning = 'warn'; Failed = 'bad' }; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                        @{ Key = 'ResourceType'; Label = 'Type'; Type = 'type'; Facet = $true; Console = $true }
                        @{ Key = 'Health'; Label = 'Health'; Type = 'badge'; Tones = @{ Available = 'good'; Unavailable = 'bad'; Degraded = 'warn'; Unknown = 'neutral' }; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'FailedOperations'; Label = 'Failed ops'; Type = 'number'; Console = $true; Pdf = $true }
                        @{ Key = 'Alerts'; Label = 'Alerts'; Type = 'number'; Console = $true; Pdf = $true }
                        @{ Key = 'Issues'; Label = 'Issues'; Type = 'wide'; Console = $true; Pdf = $true }
                        @{ Key = 'ActivityEvents'; Label = 'Activity events'; Type = 'number' }
                        @{ Key = 'Advisor'; Label = 'Advisor'; Type = 'number' }
                        @{ Key = 'Defender'; Label = 'Defender'; Type = 'number' }
                        @{ Key = 'PolicyNonCompliant'; Label = 'Policy non-compliant'; Type = 'number' }
                        @{ Key = 'Changes'; Label = 'Changes'; Type = 'number' }
                        @{ Key = 'Locks'; Label = 'Locks'; Type = 'number' }
                        @{ Key = 'DiagnosticSettings'; Label = 'Diagnostic settings'; Type = 'number' }
                        @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                        @{ Key = 'Folder'; Label = 'Folder'; Type = 'mono' }
                    ) }
            )
            Hint     = 'Open README.md in the zip first; summary.html is the same summary, interactive.'
        }
        try {
            $null = Invoke-AACReportOutput -Report $report -Title 'Azure diagnostic bundle' -HtmlPath (Join-Path $outcome.Work 'summary.html') -Object @()
            $null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $zip)
            Compress-Archive -Path (Join-Path $outcome.Work '*') -DestinationPath $zip -Force
        }
        finally { Remove-Item -LiteralPath $outcome.Work -Recurse -Force -ErrorAction SilentlyContinue }

        $bundle = [pscustomobject][ordered]@{
            PSTypeName = 'AAC.DiagnosticBundle'
            Path       = $zip
            Resources  = $rows.Count
            Files      = $outcome.Files.Count + 1
            SizeKB     = [Math]::Round((Get-Item -LiteralPath $zip).Length / 1KB, 1)
            Redacted   = $outcome.Redacted
            Hours      = $Hours
            Warnings   = @($outcome.Warnings)
            Summary    = $rows
            Created    = [datetime]::UtcNow
        }
        if ($interactive) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACReportView -Report $report } }
        if ($PassThru -or $NoDisplay -or $pipedOnward -or -not $interactive) { $bundle }
    }
}