Modules/Private/Main/Start-AZTIExtractionOrchestration.ps1

<#
.Synopsis
Extraction orchestration for Azure Resource Inventory
 
.DESCRIPTION
This module orchestrates the extraction of resources for Azure Resource Inventory.
 
.Link
https://github.com/thisismydemo/azure-scout/Modules/Private/0.MainFunctions/Start-AZSCExtractionOrchestration.ps1
 
.COMPONENT
This PowerShell Module is part of Azure Scout (AZSC)
 
.NOTES
Version: 3.6.11
First Release Date: 15th Oct, 2024
Authors: Claudio Merola
 
#>

function Start-AZSCExtractionOrchestration {
    Param($ManagementGroup, $Subscriptions, $SubscriptionID, $SkipPolicy, $ResourceGroup, $SecurityCenter, $SkipAdvisory, $IncludeTags, $TagKey, $TagValue, $SkipAPIs, $SkipVMDetails, $IncludeCosts, $Automation, $AzureEnvironment,
        [ValidateSet('All', 'ArmOnly', 'EntraOnly')]
        [string]$Scope = 'All',
        [string]$TenantID,
        [switch]$IncludeDevOps,
        [string[]]$DevOpsOrganization,
        [string]$DevOpsPat
    )
    # ── StrictMode boundary (AB#5633) ────────────────────────────────────────────────
    # This is the v1 inventory engine, forked from microsoft/ARI. It was written without
    # StrictMode and carries ~800 property reads that are only valid without it -- chained
    # reads over API payloads whose shape varies by tenant, and member enumeration over
    # collections that are legitimately empty.
    #
    # The v2 assessment platform under src/ sets `Set-StrictMode -Version Latest` at FILE
    # scope, and AzureScout.psm1 dot-sources those files, so StrictMode was silently applied
    # to the whole module -- this engine included. Nothing here was ever tested under it.
    # The result was a run that aborted on a perfectly normal Azure response, in a different
    # place on every tenant, because the faults are data-dependent: an empty API result set,
    # an estate with no VMs, a subscription with no quota rows.
    #
    # StrictMode is dynamically scoped, so turning it off here covers this call tree only.
    # The assessment platform is invoked from Invoke-AzureScout's own scope and keeps
    # StrictMode in full force -- it was written for it and its tests depend on it.
    #
    # This restores the behaviour v1 shipped with for years. It is not a licence to write
    # sloppy code here: the genuine defects found alongside this (a job wait that never
    # waited, an unreachable error fallback, a rethrow that destroyed optional data) were
    # fixed properly rather than papered over.
    Set-StrictMode -Off


    $Resources = @()
    $ResourceContainers = @()
    $Advisories = @()
    $Security = @()
    $Retirements = @()
    $EntraResources = @()
    $PolicyAssign = $null
    $PolicyDef = $null
    $PolicySetDef = $null
    $Costs = $null
    $VMQuotas = $null

    # ── ARM Extraction (skip when Scope = EntraOnly) ──
    if ($Scope -ne 'EntraOnly') {
        $GraphData = Start-AZSCGraphExtraction -ManagementGroup $ManagementGroup -Subscriptions $Subscriptions -SubscriptionID $SubscriptionID -ResourceGroup $ResourceGroup -SecurityCenter $SecurityCenter -SkipAdvisory $SkipAdvisory -IncludeTags $IncludeTags -TagKey $TagKey -TagValue $TagValue -AzureEnvironment $AzureEnvironment

        $Resources = $GraphData.Resources
        $ResourceContainers = $GraphData.ResourceContainers
        $Advisories = $GraphData.Advisories
        $Security = $GraphData.Security
        $Retirements = $GraphData.Retirements

        Remove-Variable -Name GraphData -ErrorAction SilentlyContinue

        if(![bool]$SkipAPIs)
            {
                Write-Progress -activity 'Azure Inventory' -Status "12% Complete." -PercentComplete 12 -CurrentOperation "Starting API Extraction.."
                Write-Debug ((get-date -Format 'yyyy-MM-dd_HH_mm_ss')+' - '+'Getting API Resources.')
                $APIResults = Get-AZSCAPIResources -Subscriptions $Subscriptions -AzureEnvironment $AzureEnvironment -SkipPolicy $SkipPolicy
                # Read element-wise, NOT via member enumeration ($APIResults.ReservationRecomen).
                # The module runs under Set-StrictMode -Version Latest (every src/*.ps1 sets it at
                # file scope and the .psm1 dot-sources them), and member enumeration throws
                # "The property 'X' cannot be found on this object" when the enumeration yields
                # nothing at all -- which is exactly what an EMPTY collection on every element
                # produces. An empty collection is a normal Azure response: a subscription with no
                # reservation recommendations returns { "value": [] }, so a healthy tenant was
                # aborting the whole run here. $null values were never the problem; empty ones
                # were. (AB#5633)
                $Resources += Get-AZSCCollectedValue -InputObject $APIResults -Name 'ResourceHealth'
                $Resources += Get-AZSCCollectedValue -InputObject $APIResults -Name 'ManagedIdentities'
                $Resources += Get-AZSCCollectedValue -InputObject $APIResults -Name 'AdvisorScore'
                $Resources += Get-AZSCCollectedValue -InputObject $APIResults -Name 'ReservationRecomen'
                $PolicyAssign = Get-AZSCCollectedValue -InputObject $APIResults -Name 'PolicyAssign'
                $PolicyDef = Get-AZSCCollectedValue -InputObject $APIResults -Name 'PolicyDef'
                $PolicySetDef = Get-AZSCCollectedValue -InputObject $APIResults -Name 'PolicySetDef'
                Write-Debug ((get-date -Format 'yyyy-MM-dd_HH_mm_ss')+' - '+'API Resource Inventory Finished.')
                Remove-Variable APIResults -ErrorAction SilentlyContinue
            }

        if ([bool]$IncludeCosts) {
            $Costs = Get-AZSCCostInventory -Subscriptions $Subscriptions -Days 60 -Granularity 'Monthly'
        }

        if (![bool]$SkipVMDetails)
            {
                Write-Host 'Gathering VM Extra Details: ' -NoNewline
                Write-Host 'Quotas' -ForegroundColor Cyan
                Write-Progress -activity 'Azure Inventory' -Status "13% Complete." -PercentComplete 13 -CurrentOperation "Starting VM Details Extraction.."

                $VMQuotas = Get-AZSCVMQuotas -Subscriptions $Subscriptions -Resources $Resources

                $Resources += $VMQuotas

                # NOTE: $VMQuotas is intentionally NOT removed here — it is returned
                # separately as the 'Quotas' field of $ReturnData below. Removing it
                # (as the original code did) left 'Quotas' permanently null.

                Write-Host 'Gathering VM Extra Details: ' -NoNewline
                Write-Host 'Size SKU' -ForegroundColor Cyan

                $VMSkuDetails = Get-AZSCVMSkuDetails -Resources $Resources

                $Resources += $VMSkuDetails

                Remove-Variable -Name VMSkuDetails -ErrorAction SilentlyContinue

            }
    }
    else {
        Write-Host 'Scope is EntraOnly — ' -NoNewline -ForegroundColor Yellow
        Write-Host 'Skipping ARM resource extraction' -ForegroundColor Yellow
    }

    # ── Entra ID Extraction (when Scope = All or EntraOnly) ──
    if ($Scope -in @('All', 'EntraOnly')) {
        if ([string]::IsNullOrEmpty($TenantID)) {
            Write-Warning 'TenantID is required for Entra ID extraction but was not provided. Skipping Entra extraction.'
        }
        else {
            Write-Progress -activity 'Azure Inventory' -Status "15% Complete." -PercentComplete 15 -CurrentOperation "Starting Entra ID Extraction.."
            Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Starting Entra ID extraction for tenant: ' + $TenantID)

            $EntraData = Start-AZSCEntraExtraction -TenantID $TenantID
            $EntraResources = if ($EntraData) { $EntraData.EntraResources } else { @() }

            # Merge Entra resources into the main Resources array. Guarded so a $null
            # EntraResources doesn't add a spurious null element to $Resources, which
            # would crash later property-chain access (e.g. '.type') under StrictMode.
            if ($EntraResources) { $Resources += $EntraResources }

            Remove-Variable -Name EntraData -ErrorAction SilentlyContinue

            Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Entra ID extraction complete. ' + @($EntraResources).Count + ' resources added.')
        }
    }

    # ── Azure DevOps Extraction (opt-in via -IncludeDevOps) ──
    # Opt-in rather than scope-driven: Azure DevOps is a separate service with its own
    # authorization, and an inventory run should not fail or stall on it by default.
    if ($IncludeDevOps.IsPresent) {
        Write-Progress -activity 'Azure Inventory' -Status "18% Complete." -PercentComplete 18 -CurrentOperation "Starting Azure DevOps Extraction.."
        Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Starting Azure DevOps extraction.')

        $DevOpsData = Start-AZSCDevOpsExtraction -TenantID $TenantID -Organization $DevOpsOrganization -Pat $DevOpsPat
        $DevOpsResources = if ($DevOpsData) { $DevOpsData.DevOpsResources } else { @() }

        # Guarded exactly as the Entra merge is: a $null here would add a null element to
        # $Resources and crash later property-chain access under StrictMode.
        if ($DevOpsResources) { $Resources += $DevOpsResources }

        Remove-Variable -Name DevOpsData -ErrorAction SilentlyContinue

        Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Azure DevOps extraction complete. ' + @($DevOpsResources).Count + ' resources added.')
    }

    $ResourcesCount = [string]@($Resources).Count
    $AdvisoryCount = [string]@($Advisories).Count
    $SecCenterCount = [string]@($Security).Count
    # $PolicyAssign's shape varies (empty string, a single REST payload, or an array of
    # per-subscription payloads) — a plain property chain throws under StrictMode whenever
    # 'policyAssignments' isn't present on whatever shape it currently is.
    $PolicyCount = try { [string]@($PolicyAssign.policyAssignments).Count } catch { '0' }

    $ReturnData = [PSCustomObject]@{
        Resources          = $Resources
        EntraResources     = $EntraResources
        Quotas             = $VMQuotas
        Costs              = $Costs
        ResourceContainers = $ResourceContainers
        Advisories         = $Advisories
        ResourcesCount     = $ResourcesCount
        AdvisoryCount      = $AdvisoryCount
        SecCenterCount     = $SecCenterCount
        Security           = $Security
        Retirements        = $Retirements
        PolicyCount        = $PolicyCount
        PolicyAssign       = $PolicyAssign
        PolicyDef          = $PolicyDef
        PolicySetDef       = $PolicySetDef
    }

    return $ReturnData
}