src/pipeline/Invoke-ScoutCollector.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Run one inventory collector in-process, with its failure contained (AB#5649).

.DESCRIPTION
    Replaces the per-collector `[PowerShell]::Create().AddScript(...).BeginInvoke()` runspace
    from `Start-AZSCProcessJob`. That design cost far more than it bought:

      * `BeginInvoke()` returns a `PowerShellAsyncResult`, which has NO `.Runspace` property.
        The wait loop read `$Job.Runspace.IsCompleted`, so it evaluated to an empty collection
        and never waited — `EndInvoke` then raced its own work (AB#5629, and again in a second
        un-fixed copy in AB#5633).
      * Each runspace RE-IMPORTED the module, which is why module-scope StrictMode leaked back
        in and the opt-out had to be applied at 17 separate entry points instead of 5.
      * The whole estate was serialised to JSON and rehydrated once per category job.
      * An exception inside a runspace surfaced at `EndInvoke` time, detached from the
        collector that caused it, so a single bad collector took out its entire category.

    Running a collector in-process removes all four. A collector is a pure function of the
    resource set — it filters `$Resources` by type and shapes rows — so there was never any
    concurrency requirement to honour.

    ERROR CONTAINMENT IS THE POINT. A collector that throws is reported and skipped; every
    other collector still runs and the report is still produced. Under the old pipeline the
    same throw silently emptied a category, or aborted the batch.

.PARAMETER Collector
    A descriptor from Get-ScoutCollector.

.PARAMETER Context
    Hashtable supplying the collector's ten positional parameters. Recognised keys:
    ScriptRoot, Subscriptions, InTag, Resources, Retirements, Task, File, SmaResources,
    TableStyle, Unsupported. Any key omitted is passed as $null.

.OUTPUTS
    PSCustomObject with Name, FolderCategory, Success, Rows, Error and Duration.

.NOTES
    Tracks ADO AB#5649 (Epic AB#5638).

    Collectors are invoked with StrictMode OFF. The 176 files under InventoryModules were
    written for a runspace where StrictMode never applied, and turning it on for them is a
    separate, tested piece of work with recorded live payload fixtures (AB#5667). Doing both
    at once would be a flag day. StrictMode is dynamically scoped, so the opt-out here covers
    the collector's call tree and nothing else.
#>

function Invoke-ScoutCollector {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param(
        [Parameter(Mandatory, Position = 0)]
        [PSCustomObject]$Collector,

        [Parameter(Mandatory, Position = 1)]
        [hashtable]$Context
    )

    Set-StrictMode -Off

    # Match the runspace default the collectors were written against. Under the module's
    # 'Stop' preference a merely non-terminating error would discard rows the collector had
    # already produced.
    $ErrorActionPreference = 'Continue'

    $Started = Get-Date
    $Rows    = @()
    $Failure = $null

    try {
        $Source = [System.IO.File]::ReadAllText($Collector.Path)
        $Block  = [ScriptBlock]::Create($Source)

        # The collector parameter block, in order:
        # $SCPath, $Sub, $Intag, $Resources, $Retirements, $Task, $File, $SmaResources,
        # $TableStyle, $Unsupported
        #
        # Built by INDEX into a fixed-size array, deliberately. An array subexpression --
        # @( $a; $b; $c ) -- takes its elements from the output stream, and an empty collection
        # contributes NOTHING to that stream rather than contributing itself. Several of these
        # values are legitimately @() (a tenant with no retirements, no unsupported-version
        # list), so the array silently came back short and every argument after the first empty
        # one shifted down a position -- $Task landed in $Retirements and the collectors'
        # `If ($Task -eq 'Processing')` guard never fired, so every collector returned nothing.
        #
        # Same root cause as AB#5633: an empty collection is not a value, it is an absence.
        # Indexed assignment cannot flatten, so the arity is guaranteed.
        $Arguments = [object[]]::new(10)
        $Arguments[0] = $Context['ScriptRoot']
        $Arguments[1] = $Context['Subscriptions']
        $Arguments[2] = $Context['InTag']
        $Arguments[3] = $Context['Resources']
        $Arguments[4] = $Context['Retirements']
        $Arguments[5] = $Context['Task']
        $Arguments[6] = $Context['File']
        $Arguments[7] = $Context['SmaResources']
        $Arguments[8] = $Context['TableStyle']
        $Arguments[9] = $Context['Unsupported']

        $Rows = @(& $Block @Arguments)
    }
    catch {
        $Failure = $_
        $Rows    = @()

        Write-Warning ("[AzureScout] Collector '{0}' ({1}) failed and was skipped: {2}" -f
            $Collector.Name, $Collector.FolderCategory, $_.Exception.Message)

        # Deliberately WARN, not Write-AZSCLogError: that helper is the run-failed reporter and
        # opens with a "RUN FAILED" banner. A contained collector skip is not a failed run.
        if (Get-Command -Name 'Write-AZSCLog' -ErrorAction SilentlyContinue) {
            Write-AZSCLog -Level 'WARN' -Message ("Collector {0}/{1} failed and was skipped: {2}" -f
                $Collector.FolderCategory, $Collector.Name, $_.Exception.Message)
            Write-AZSCLog -Level 'WARN' -Message (' at {0}:{1}' -f
                $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber)
        }
    }

    [PSCustomObject]@{
        Name           = $Collector.Name
        FolderCategory = $Collector.FolderCategory
        Success        = ($null -eq $Failure)
        Rows           = $Rows
        Error          = $Failure
        Duration       = (Get-Date) - $Started
    }
}