Public/Invoke-AACAssessment.ps1
|
function Invoke-AACAssessment { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Assesses an Azure environment end to end - an inventory of every resource type with its key settings, the organization, Advisor, retirements, Defender for Cloud, Azure Policy, outages, quotas and cost - as CSV files, an interactive HTML report, a PDF and interactive diagrams, with a live Spectre.Console progress display. .DESCRIPTION The module's take on Azure Resource Inventory (ARI): one command reads the tenant (or the subscriptions, management groups, resource groups or tag you name) and writes the reports you choose. Read-only; Reader is enough (Security Reader for Defender, Cost Management Reader for cost); no Az modules, no Excel. What it reads, in phases - each a line of the progress display: 1. Scope the subscriptions (with their management groups) in scope, and the tenant's management groups 2. Estate every resource and resource group, the resource types present; Advisor recommendations (and retirements, always); Defender for Cloud (-SecurityCenter); Azure Policy compliance (unless -SkipPolicy); support tickets 3. Inventory a sheet per resource type present - ~95 types, in the categories Compute, Hybrid, Containers, Databases, Analytics, AI, Integration, IoT, Management, Monitoring, Networking, Security, Storage and Web - each with the settings that matter for it (a VM's size, power state, OS, disks, IP, network and security settings; a storage account's TLS, public access, shared keys and firewall; ...), as Resource Graph projections 4. Azure APIs per subscription: the Advisor score, reservation recommendations, the last 6 months' outages (Resource Health service issues), compute quotas (-QuotaUsage), and VM sizes' vCPUs and memory (unless -SkipVMDetail); -SkipApi skips them all 5. Cost each resource's cost, this month and last (-IncludeCost, Cost Management) 6. Reports CSV, HTML, PDF and diagrams (-Output), then an upload to a blob container (-StorageAccount) Every inventory sheet adds, after its own columns: Retirement (an Advisor retirement notice for the resource), Advisor (its number of recommendations), its cost with -IncludeCost, and its tags with -IncludeTag. The overview sheets: Subscriptions, Resource groups, Resource types, All resources; then Advisor recommendations, Advisor score, Retirements, Security recommendations, Secure score, Policy compliance, Outages, Quotas, Support tickets and Reservation recommendations. The reports, in -ReportDir (a folder named after -ReportName and the time): HTML one page: tiles, charts, the tenant tree, and every sheet as a table under its category, listed in the contents - searchable, filterable, groupable, each downloadable as CSV CSV a file per sheet PDF the summary, then each category with its sheets' key columns (bookmarked) Diagram <name>-Network.html: the network topology - virtual networks, subnets, peerings, gateways, firewalls, load balancers, private endpoints, NSGs and routes (-DiagramFullEnvironment: every resource and how they connect); <name>-Organization.html: management groups > subscriptions > resource groups; <name>-Resources.html: each subscription with its resource types and counts. Interactive: pan, zoom, search, details on click, PNG and SVG export. Sign-in: the Connect-AAC session. -TenantId signs in to that tenant first (in the browser) when the session is for another one. In Azure Automation, -Automation signs in with the account's managed identity (Connect-AAC -Identity) if no session is there yet, writes plain progress lines for the job log, and with -StorageAccount and -StorageContainer uploads the reports to blob storage (Storage Blob Data Contributor needed). .PARAMETER TenantId The tenant to assess. Signs in to it first if the current session is for another tenant (or there is none). .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER SubscriptionId Only these subscriptions. .PARAMETER ResourceGroupName Only these resource groups. .PARAMETER TagKey Only resources with this tag (any case). .PARAMETER TagValue Only resources with this tag value (with -TagKey: that tag with that value). .PARAMETER Category Only these inventory categories: Compute, Hybrid, Containers, Databases, Analytics, AI, Integration, IoT, Management, Monitoring, Networking, Security, Storage, Web. The overview sheets are always written. .PARAMETER IncludeTag Add each resource's tags to every inventory sheet. .PARAMETER SecurityCenter Read Defender for Cloud: the unhealthy recommendations and each subscription's secure score. .PARAMETER SkipAdvisor Don't read Advisor recommendations (retirements are still read). .PARAMETER SkipPolicy Don't read Azure Policy compliance. .PARAMETER IncludeCost Read each resource's actual cost, this month and last (Cost Management Reader). .PARAMETER QuotaUsage Read the compute quotas in use, per subscription and region. .PARAMETER SkipApi Don't call the per-subscription Azure APIs (Advisor score, reservations, outages, quotas, VM sizes) - Resource Graph only. .PARAMETER SkipVMDetail Don't read VM sizes' vCPUs and memory (Compute SKUs). .PARAMETER SkipDiagram Don't draw the diagrams (same as leaving Diagram out of -Output). .PARAMETER DiagramFullEnvironment Draw every resource in the network diagram, not only the network. .PARAMETER Output The reports to write: Html, Pdf, Csv, Diagram. All of them by default. .PARAMETER ReportName The reports' name: AzureAssessment by default. .PARAMETER ReportDir Where the reports go: a folder named <ReportName>-<date> is made in it. The current folder by default (the temporary folder with -Automation). .PARAMETER Automation Run in an Azure Automation runbook: sign in with the managed identity if not signed in, and write plain progress lines. .PARAMETER StorageAccount Upload the reports to this storage account (with -StorageContainer). .PARAMETER StorageContainer The blob container to upload to. .PARAMETER Title The HTML and PDF reports' title. .PARAMETER PassThru Show the summary and also return the assessment. .PARAMETER NoDisplay Return the assessment without showing the summary. .PARAMETER NoPaging Show the summary at once instead of a page at a time. .EXAMPLE Connect-AAC Invoke-AACAssessment The whole tenant (everything the account can see): every report, in .\AzureAssessment-<date>. .EXAMPLE Invoke-AACAssessment -TenantId 'contoso.onmicrosoft.com' -SecurityCenter -IncludeTag -IncludeCost Another tenant, with Defender for Cloud, tags and cost. .EXAMPLE Invoke-AACAssessment -ManagementGroupId 'mg-landingzones' -ReportDir C:\Reports -Output Html, Diagram A management group's subscriptions: the HTML report and the diagrams. .EXAMPLE Invoke-AACAssessment -SubscriptionId 00000000-0000-0000-0000-000000000000 -TagKey 'Environment' -TagValue 'Production' -Category Compute, Networking Production's compute and network resources in one subscription. .EXAMPLE Invoke-AACAssessment -Automation -StorageAccount 'stcontosoreports' -StorageContainer 'assessments' In an Azure Automation runbook: managed identity sign-in, and the reports uploaded to blob storage. .EXAMPLE (Invoke-AACAssessment -Output Csv -NoDisplay).Sheets['Virtual machines'] | Where-Object 'Power state' -NE 'VM running' The VMs that aren't running. .OUTPUTS AAC.Assessment (with -PassThru or -NoDisplay, or piped onward) #> [CmdletBinding()] [OutputType('AAC.Assessment')] param( [ValidateNotNullOrEmpty()] [string] $TenantId, [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, [string] $TagKey, [string] $TagValue, [ValidateSet('Compute', 'Hybrid', 'Containers', 'Databases', 'Analytics', 'AI', 'Integration', 'IoT', 'Management', 'Monitoring', 'Networking', 'Security', 'Storage', 'Web')] [string[]] $Category, [switch] $IncludeTag, [switch] $SecurityCenter, [switch] $SkipAdvisor, [switch] $SkipPolicy, [switch] $IncludeCost, [switch] $QuotaUsage, [switch] $SkipApi, [switch] $SkipVMDetail, [switch] $SkipDiagram, [switch] $DiagramFullEnvironment, [ValidateSet('Html', 'Pdf', 'Csv', 'Diagram')] [string[]] $Output = @('Html', 'Pdf', 'Csv', 'Diagram'), [ValidatePattern('^[^\\/:*?"<>|]+$')] [string] $ReportName = 'AzureAssessment', [string] $ReportDir, [switch] $Automation, [string] $StorageAccount, [string] $StorageContainer, [string] $Title = 'Azure environment assessment', [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)) } if (($StorageAccount -and -not $StorageContainer) -or ($StorageContainer -and -not $StorageAccount)) { throw 'Uploading the reports needs both -StorageAccount and -StorageContainer.' } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward -and -not $Automation $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward # --- Sign-in ----------------------------------------------------------------------------------------------- $session = $script:AACSession if ($Automation -and -not $session) { Connect-AAC -Identity | Out-Null } elseif ($TenantId) { # A domain is the tenant's too: compare their IDs. $wantedTenant = Resolve-AACTenantId -Tenant $TenantId if (-not $session -or ([string]$session.TenantId).ToLowerInvariant() -ne $wantedTenant) { Connect-AAC -TenantId $wantedTenant | Out-Null } } # --- Where the reports go -------------------------------------------------------------------------------------- $stamp = Get-Date -Format 'yyyy-MM-dd_HHmm' $baseDir = if ($ReportDir) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ReportDir) } elseif ($Automation) { [System.IO.Path]::GetTempPath() } else { $PSCmdlet.SessionState.Path.CurrentFileSystemLocation.ProviderPath } $folder = Join-Path -Path $baseDir -ChildPath "$ReportName-$stamp" $outputs = @($Output | Where-Object { $_ -ne 'Diagram' -or -not $SkipDiagram }) # Read here, not inside the progress block (it runs in Invoke-AACProgress's scope). $request = @{ ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }) SubscriptionId = @($SubscriptionId | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() }) ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }) TagKey = $TagKey TagValue = $TagValue Category = @($Category | Where-Object { $_ }) IncludeTag = [bool]$IncludeTag SecurityCenter = [bool]$SecurityCenter SkipAdvisor = [bool]$SkipAdvisor SkipPolicy = [bool]$SkipPolicy IncludeCost = [bool]$IncludeCost QuotaUsage = [bool]$QuotaUsage SkipApi = [bool]$SkipApi SkipVMDetail = [bool]$SkipVMDetail FullEnvironment = [bool]$DiagramFullEnvironment Outputs = $outputs Folder = $folder ReportName = $ReportName Title = $Title StorageAccount = $StorageAccount StorageContainer = $StorageContainer } if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Azure environment assessment' -Color 'deepskyblue3_1' } $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken $timing = [ordered]@{} $clock = [System.Diagnostics.Stopwatch]::StartNew() $lap = { param([string] $Phase) $timing[$Phase] = $clock.Elapsed; $clock.Restart() } $rowsOf = { param($Read, [string] $Name) @(if ($Read.Rows.Contains($Name)) { $Read.Rows[$Name] }) | Where-Object { $null -ne $_ } } # --- 1. Scope --------------------------------------------------------------------------------------------- Update-AACProgress -Id 'scope' -Description 'Finding the subscriptions and management groups in scope' -Indeterminate $read = Invoke-AACGraphBatch -Query (Get-AACAssessmentExtraQuery -Stage Scope) -AllowFailure 'managementGroups' $subscriptions = @(& $rowsOf $read 'subscriptions') $managementGroups = @(& $rowsOf $read 'managementGroups') $chainNames = { param($Row) @(@($Row['chain']) | Where-Object { $_ } | ForEach-Object { ([string]$_['name']).ToLowerInvariant() }) } if ($request.ManagementGroupId.Count) { $wanted = @($request.ManagementGroupId | ForEach-Object { $_.ToLowerInvariant() }) $subscriptions = @($subscriptions | Where-Object { $names = & $chainNames $_; @($wanted | Where-Object { $names -contains $_ }).Count }) if (-not $subscriptions.Count) { $problem = [System.InvalidOperationException]::new("No subscription the account can see is under management group $($request.ManagementGroupId -join ', ').") $problem.Data['AACHint'] = 'Use the management group''s ID (its name), not its display name - and check the account has Reader on it.' throw $problem } } if ($request.SubscriptionId.Count) { $found = @($subscriptions | Where-Object { $request.SubscriptionId -contains ([string]$_['subscriptionId']).ToLowerInvariant() }) foreach ($id in $request.SubscriptionId) { if (-not @($found | Where-Object { ([string]$_['subscriptionId']).ToLowerInvariant() -eq $id }).Count) { Write-Warning "Subscription $id isn't one the account can see; it's left out." } } if (-not $found.Count) { throw "None of the subscriptions $($request.SubscriptionId -join ', ') is one the account can see." } $subscriptions = $found } if (-not $subscriptions.Count) { $problem = [System.InvalidOperationException]::new('The account can see no subscription.') $problem.Data['AACHint'] = 'Give the account Reader on the subscriptions (or a management group above them), or sign in to the right tenant with Connect-AAC -TenantId.' throw $problem } $scoped = $request.ManagementGroupId.Count -or $request.SubscriptionId.Count # Unscoped, every query covers everything the account can see. $graphScope = if ($scoped) { @($subscriptions | ForEach-Object { [string]$_['subscriptionId'] }) } else { @() } $subscriptionNames = @{} foreach ($row in $subscriptions) { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } Update-AACProgress -Id 'scope' -Complete -Description ('Scope: {0:N0} subscription(s){1}' -f $subscriptions.Count, $(if ($managementGroups.Count) { " under $($managementGroups.Count) management group(s)" })) & $lap 'Scope' # --- 2. Estate: every resource, the containers, Advisor, Defender, Policy ---------------------------------------- $filter = Get-AACAssessmentQuery -Filter -ResourceGroupName $request.ResourceGroupName -TagKey $request.TagKey -TagValue $request.TagValue $estate = Get-AACAssessmentExtraQuery -Stage Estate -Filter $filter -ResourceGroupName $request.ResourceGroupName -SkipAdvisor:$request.SkipAdvisor -SecurityCenter:$request.SecurityCenter -SkipPolicy:$request.SkipPolicy $labels = @{ types = 'resource types'; resources = 'resources'; groups = 'resource groups'; advisor = $(if ($request.SkipAdvisor) { 'retirements' } else { 'Advisor recommendations' }); security = 'Defender for Cloud recommendations'; secureScores = 'secure scores'; policy = 'Azure Policy compliance'; supportTickets = 'support tickets' } Update-AACProgress -Id 'estate' -Total $estate.Count -Description 'Reading every resource, resource group, Advisor, Defender and Policy' $read = Invoke-AACGraphBatch -Query $estate -SubscriptionId $graphScope -AllowFailure @($estate.Keys | Where-Object { $_ -notin 'types', 'resources' }) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'estate' -Increment 1 -Description "Read the $($labels[$Name]) ($Done of $Total)" } $failed = [ordered]@{} foreach ($key in $read.Errors.Keys) { if ($key -ne 'supportTickets') { $failed[$labels[$key]] = $read.Errors[$key] } } $resources = @(& $rowsOf $read 'resources') $typeRows = @(& $rowsOf $read 'types') Update-AACProgress -Id 'estate' -Complete -Description ('Estate: {0:N0} resource(s) of {1:N0} type(s) in {2:N0} resource group(s)' -f $resources.Count, @($typeRows | ForEach-Object { $_['type'] } | Select-Object -Unique).Count, @(& $rowsOf $read 'groups').Count) $estateRead = $read & $lap 'Estate' # --- 3. Inventory: a sheet per resource type present ---------------------------------------------------------- $present = [System.Collections.Generic.HashSet[string]]::new() foreach ($row in $typeRows) { [void]$present.Add(([string]$row['type']).ToLowerInvariant()) } $catalog = @(Get-AACAssessmentCatalog | Where-Object { -not $request.Category.Count -or $request.Category -contains $_.Category }) $sheetQueries = [ordered]@{} $compiled = @{} foreach ($sheet in $catalog) { $table = if ($sheet.Contains('Table') -and $sheet.Table) { $sheet.Table } else { 'resources' } # Other tables (backup items, session hosts) aren't in the type count: always asked. if ($table -eq 'resources' -and -not @($sheet.Type | Where-Object { $present.Contains(([string]$_).ToLowerInvariant()) }).Count) { continue } $query = Get-AACAssessmentQuery -Sheet $sheet -ResourceGroupName $request.ResourceGroupName -TagKey $request.TagKey -TagValue $request.TagValue -IncludeTag:$request.IncludeTag $compiled[$sheet.Sheet] = $query $sheetQueries[$sheet.Sheet] = $query.Query } $sheetRows = @{} $sheetErrors = @{} if ($sheetQueries.Count) { Update-AACProgress -Id 'inventory' -Total $sheetQueries.Count -Description "Reading $($sheetQueries.Count) resource type sheet(s)" $read = Invoke-AACGraphBatch -Query $sheetQueries -SubscriptionId $graphScope -AllowFailure @($sheetQueries.Keys) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'inventory' -Increment 1 -Description "Read $Name ($Done of $Total sheets)" } foreach ($name in $sheetQueries.Keys) { $sheetRows[$name] = @(& $rowsOf $read $name) } foreach ($name in $read.Errors.Keys) { $sheetErrors[$name] = $read.Errors[$name] } } $withRows = @($sheetRows.Keys | Where-Object { $sheetRows[$_].Count }).Count Update-AACProgress -Id 'inventory' -Complete -Description ('Inventory: {0:N0} sheet(s) with resources{1}' -f $withRows, $(if ($sheetErrors.Count) { ", $($sheetErrors.Count) couldn't be read" })) & $lap 'Inventory' # --- 4. Azure APIs, per subscription --------------------------------------------------------------------------- $arm = @{} if (-not $request.SkipApi) { $active = @($subscriptions | Where-Object { [string]$_['state'] -in 'Enabled', 'Warned', 'PastDue', '' } | ForEach-Object { [string]$_['subscriptionId'] }) $uris = [ordered]@{} $since = [uri]::EscapeDataString((Get-Date).AddMonths(-6).ToString('MM/dd/yyyy', [cultureinfo]::InvariantCulture)) foreach ($sub in $active) { if (-not $request.SkipAdvisor) { $uris["AdvisorScore|$sub"] = "/subscriptions/$sub/providers/Microsoft.Advisor/advisorScore?api-version=2023-01-01" } $uris["Reservation|$sub"] = "/subscriptions/$sub/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01" $uris["Outage|$sub"] = "/subscriptions/$sub/providers/Microsoft.ResourceHealth/events?api-version=2022-10-01&queryStartTime=$since" } # Where the VMs and scale sets are: their sizes, and the quotas. $computeTypes = 'microsoft.compute/virtualmachines', 'microsoft.compute/virtualmachinescalesets' $computeAt = @($typeRows | Where-Object { [string]$_['type'] -in $computeTypes }) if (-not $request.SkipVMDetail) { foreach ($location in @($computeAt | ForEach-Object { ([string]$_['location']).ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)) { $sub = @($computeAt | Where-Object { ([string]$_['location']).ToLowerInvariant() -eq $location } | ForEach-Object { [string]$_['subscriptionId'] })[0] $uris["Sku|$location"] = "/subscriptions/$sub/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=$([uri]::EscapeDataString("location eq '$location'"))" } } if ($request.QuotaUsage) { foreach ($pair in @($computeAt | ForEach-Object { "$([string]$_['subscriptionId'])|$(([string]$_['location']).ToLowerInvariant())" } | Select-Object -Unique)) { $sub, $location = $pair -split '\|', 2 $uris["Quota|$pair"] = "/subscriptions/$sub/providers/Microsoft.Compute/locations/$location/usages?api-version=2023-09-01" } } if ($uris.Count) { Update-AACProgress -Id 'api' -Total $uris.Count -Description "Asking $($active.Count) subscription(s) for the Advisor score, reservations, outages$(if ($request.QuotaUsage) { ', quotas' })$(if (-not $request.SkipVMDetail) { ' and VM sizes' })" $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($Done, $Total) Update-AACProgress -Id 'api' -Increment 1 } $unread = 0 foreach ($key in $uris.Keys) { $kind, $rest = $key -split '\|', 2 $answer = $answers[$uris[$key]] if (-not $arm.Contains($kind)) { $arm[$kind] = @{} } if (-not $answer -or $answer.Error) { $unread++; Write-Debug "$key couldn't be read: $(if ($answer) { $answer.Error })"; continue } $arm[$kind][$rest] = @(if ($null -ne $answer.Items) { $answer.Items } elseif ($answer.Body -is [System.Collections.IDictionary] -and $answer.Body.Contains('value')) { $answer.Body['value'] }) } Update-AACProgress -Id 'api' -Complete -Description ('Azure APIs: {0:N0} call(s){1}' -f $uris.Count, $(if ($unread) { ", $unread not answered (no access, or not registered)" })) } } & $lap 'APIs' # --- 5. Cost ------------------------------------------------------------------------------------------------- $cost = $null if ($request.IncludeCost) { $costScope = @($subscriptions | ForEach-Object { @{ subscriptionId = [string]$_['subscriptionId']; name = [string]$_['name']; state = [string]$_['state'] } }) $cost = Read-AACInventoryCost -TenantId ([string]$script:AACSession.TenantId) -Subscription $costScope -ManagementGroupId $request.ManagementGroupId -PerSubscription:([bool]($request.SubscriptionId.Count -or $request.ResourceGroupName.Count -or $request.TagKey -or $request.TagValue)) } & $lap 'Cost' # --- 6. The assessment ------------------------------------------------------------------------------------------- Update-AACProgress -Id 'assess' -Description 'Building the sheets and the overview' -Indeterminate $tenantRoot = @($managementGroups | Where-Object { -not $_['parent'] }) | Select-Object -First 1 $assessment = ConvertTo-AACAssessment -Catalog $catalog -Compiled $compiled -SheetRow $sheetRows -SheetError $sheetErrors -Resource $resources ` -Subscription $subscriptions -ResourceGroup @(& $rowsOf $estateRead 'groups') -ManagementGroup $managementGroups -Advisor @(& $rowsOf $estateRead 'advisor') ` -Security @(& $rowsOf $estateRead 'security') -SecureScore @(& $rowsOf $estateRead 'secureScores') -Policy @(& $rowsOf $estateRead 'policy') ` -SupportTicket @(& $rowsOf $estateRead 'supportTickets') -Arm $arm -Cost $cost -TenantId ([string]$script:AACSession.TenantId) ` -TenantName $(if ($tenantRoot) { [string]$tenantRoot['displayName'] } else { '' }) -IncludeTag:$request.IncludeTag ` -AdvisorRead:(-not $request.SkipAdvisor -and -not $estateRead.Errors.Contains('advisor')) -SecurityRead:($request.SecurityCenter -and -not $estateRead.Errors.Contains('security')) ` -PolicyRead:(-not $request.SkipPolicy -and -not $estateRead.Errors.Contains('policy')) $notices = [System.Collections.Generic.List[string]]::new() foreach ($line in @($assessment.Notices)) { $notices.Add($line) } foreach ($key in $failed.Keys) { $notices.Add("The $key couldn't be read: $($failed[$key])") } if ($cost) { foreach ($line in @($cost.Notice)) { if ($line) { $notices.Add($line) } } } $assessment.Notices = $notices.ToArray() Update-AACProgress -Id 'assess' -Complete -Description ('Assessed: {0:N0} sheet(s), {1:N0} retirement(s){2}' -f @($assessment.Sheets | Where-Object { @($_.Rows).Count }).Count, $assessment.Stats.Retirements, $(if ($null -ne $assessment.Stats.Advisor) { ", $($assessment.Stats.AdvisorHigh) High-impact Advisor recommendation(s)" })) & $lap 'Assess' # --- 7. The reports -------------------------------------------------------------------------------------------- $scope = [ordered]@{ Scope = if ($request.ManagementGroupId.Count) { "management group(s) $($request.ManagementGroupId -join ', ')" } elseif ($request.SubscriptionId.Count) { "subscription(s) $(@($subscriptions | ForEach-Object { $_['name'] }) -join ', ')" } else { 'everything the account can see' } } if ($request.ResourceGroupName.Count) { $scope['Resource groups'] = $request.ResourceGroupName -join ', ' } if ($request.TagKey -or $request.TagValue) { $scope['Tag'] = "$(if ($request.TagKey) { $request.TagKey } else { '*' }) = $(if ($request.TagValue) { $request.TagValue } else { '*' })" } if ($request.Category.Count) { $scope['Categories'] = $request.Category -join ', ' } $files = [System.Collections.Generic.List[System.IO.FileInfo]]::new() if (-not (Test-Path -LiteralPath $request.Folder)) { New-Item -ItemType Directory -Path $request.Folder -Force | Out-Null } $write = { # One report: its own progress line; a failure is noted, the others still written. param([string] $Id, [string] $Doing, [scriptblock] $Writer) Update-AACProgress -Id $Id -Description $Doing -Indeterminate try { $written = @(& $Writer) foreach ($item in $written) { if ($item -is [System.IO.FileInfo]) { $files.Add($item) } } $first = @($written | Where-Object { $_ -is [System.IO.FileInfo] }) Update-AACProgress -Id $Id -Complete -Description $(if ($first.Count -eq 1) { "${Id}: $($first[0].FullName)" } else { "${Id}: $($first.Count) file(s) in $($request.Folder)" }) } catch { $notices.Add("The $Id report couldn't be written: $($_.Exception.Message)") Update-AACProgress -Id $Id -Complete -Description "${Id}: not written - $($_.Exception.Message)" } } $base = Join-Path -Path $request.Folder -ChildPath $request.ReportName if ($request.Outputs -contains 'Csv') { & $write 'CSV' 'Writing a CSV file per sheet' { Write-AACAssessmentCsv -Assessment $assessment -Path (Join-Path -Path $request.Folder -ChildPath 'csv') } } if ($request.Outputs -contains 'Html') { & $write 'HTML' 'Writing the HTML report' { Write-AACAssessmentHtml -Assessment $assessment -Path "$base.html" -Title $request.Title -Detail $scope } } if ($request.Outputs -contains 'Pdf') { & $write 'PDF' 'Writing the PDF report' { Write-AACAssessmentPdf -Assessment $assessment -Path "$base.pdf" -Title $request.Title -Detail $scope } } if ($request.Outputs -contains 'Diagram') { & $write 'Diagrams' 'Reading the network and drawing the diagrams' { $diagram = Get-AACAssessmentExtraQuery -Stage Diagram -Filter $filter -FullEnvironment:$request.FullEnvironment $rows = @(& $rowsOf (Invoke-AACGraphBatch -Query $diagram -SubscriptionId $graphScope) 'diagram') Write-AACAssessmentDiagram -Folder $request.Folder -ReportName $request.ReportName -Resource $rows -Inventory $resources -Organization $assessment.Organization -SubscriptionName $subscriptionNames -Scope $scope.Scope -FullEnvironment:$request.FullEnvironment } } & $lap 'Reports' # --- 8. Upload ---------------------------------------------------------------------------------------------- $uploads = @() if ($request.StorageAccount -and $files.Count) { Update-AACProgress -Id 'upload' -Description "Uploading $($files.Count) file(s) to $($request.StorageAccount)/$($request.StorageContainer)" -Indeterminate $uploads = @(Send-AACBlobFile -StorageAccount $request.StorageAccount -Container $request.StorageContainer -Path @($files | ForEach-Object FullName) -Root (Split-Path -Path $request.Folder -Parent) -Prefix $request.ReportName) $bad = @($uploads | Where-Object { $_.Status -ne 'Uploaded' }) foreach ($item in $bad) { $notices.Add("$($item.Blob) wasn't uploaded: $($item.Error)") } Update-AACProgress -Id 'upload' -Complete -Description ('Uploaded {0} of {1} file(s) to {2}/{3}' -f ($uploads.Count - $bad.Count), $uploads.Count, $request.StorageAccount, $request.StorageContainer) & $lap 'Upload' } $assessment.Notices = $notices.ToArray() @{ Assessment = $assessment; Scope = $scope; Files = $files.ToArray(); Timing = $timing; Uploads = $uploads } } $assessment = $state.Assessment if ($interactive) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACAssessmentView -Assessment $assessment -Scope $state.Scope -File $state.Files -Timing $state.Timing } } elseif ($Automation) { foreach ($line in @($assessment.Notices)) { Write-Warning $line } Write-Output ('Assessed {0:N0} resource(s) in {1:N0} subscription(s): {2:N0} file(s) in {3}' -f $assessment.Stats.Resources, $assessment.Stats.Subscriptions, @($state.Files).Count, $folder) } if ($returnObjects) { $sheets = [ordered]@{} foreach ($sheet in $assessment.Sheets) { $sheets[$sheet.Sheet] = @($sheet.Rows) } [pscustomobject][ordered]@{ PSTypeName = 'AAC.Assessment' Subscriptions = $assessment.Stats.Subscriptions ResourceGroups = $assessment.Stats.ResourceGroups Resources = $assessment.Stats.Resources ResourceTypes = $assessment.Stats.ResourceTypes AdvisorHigh = $assessment.Stats.AdvisorHigh Retirements = $assessment.Stats.Retirements SecurityHigh = $assessment.Stats.SecurityHigh Outages = $assessment.Stats.Outages ReportFolder = $folder Files = @($state.Files) Sheets = $sheets Stats = $assessment.Stats Notices = @($assessment.Notices) Timing = $state.Timing Uploads = @($state.Uploads) } } } |