Modules/Core/20-Runtime.ps1

function Invoke-RangerCollectorExecution {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [Parameter(Mandatory = $true)]
        $CredentialMap,

        [Parameter(Mandatory = $true)]
        [string]$PackageRoot
    )

    $messages = New-Object System.Collections.Generic.List[string]
    $start = (Get-Date).ToUniversalTime().ToString('o')
    $status = 'success'
    $functionName = $Definition.FunctionName
    $arguments = @{
        Config       = $Config
        CredentialMap = $CredentialMap
        Definition   = $Definition
        PackageRoot  = $PackageRoot
    }

    try {
        if (-not (Get-Command -Name $functionName -ErrorAction SilentlyContinue)) {
            throw "Collector function '$functionName' is not available."
        }

        $result = & $functionName @arguments
        if (-not $result) {
            $status = 'not-applicable'
            $result = @{}
        }
        elseif ($result.Status) {
            $status = $result.Status
        }

        $messages.Add("Collector '$($Definition.Id)' completed with status '$status'.")
    }
    catch {
        $status = if ($Definition.Class -eq 'optional' -and [bool]$Config.behavior.skipUnavailableOptionalDomains) { 'skipped' } else { 'failed' }
        $messages.Add($_.Exception.Message)
        $result = @{
            Domains       = @{}
            Findings      = @(
                New-RangerFinding -Severity warning -Title "Collector $($Definition.Id) did not complete" -Description $_.Exception.Message -CurrentState $status -Recommendation 'Review target reachability, credentials, and required dependencies.'
            )
            Relationships = @()
            Evidence      = @()
        }
    }

    $end = (Get-Date).ToUniversalTime().ToString('o')
    return @{
        CollectorId     = $Definition.Id
        Status          = $status
        StartTimeUtc    = $start
        EndTimeUtc      = $end
        TargetScope     = @($Definition.RequiredTargets)
        CredentialScope = $Definition.RequiredCredential
        Messages        = @($messages + @($result.Messages))
        Domains         = if ($result.Domains) { $result.Domains } else { @{} }
        Topology        = $result.Topology
        Relationships   = @($result.Relationships)
        Findings        = @($result.Findings)
        Evidence        = @($result.Evidence)
        RawEvidence     = $result.RawEvidence
    }
}

function New-RangerPackageRoot {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [string]$OutputPathOverride,
        [string]$BasePath = (Get-Location).Path
    )

    $rootPath = if ($OutputPathOverride) { $OutputPathOverride } else { $Config.output.rootPath }
    $resolvedRoot = Resolve-RangerPath -Path $rootPath -BasePath $BasePath
    $packageName = '{0}-{1}-{2}' -f (Get-RangerSafeName -Value ($Config.environment.name)), (Get-RangerSafeName -Value $Config.output.mode), (Get-RangerTimestamp)
    $packageRoot = Join-Path -Path $resolvedRoot -ChildPath $packageName
    New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null
    return $packageRoot
}

function Invoke-RangerDiscoveryRuntime {
    param(
        [string]$ConfigPath,
        $ConfigObject,
        [string]$OutputPath,
        [hashtable]$CredentialOverrides,
        [string[]]$IncludeDomains,
        [string[]]$ExcludeDomains,
        [switch]$NoRender,
        [hashtable]$StructuralOverrides,
        [switch]$AllowInteractiveInput
    )

    $config = Import-RangerConfiguration -ConfigPath $ConfigPath -ConfigObject $ConfigObject
    $config = Set-RangerStructuralOverrides -Config $config -StructuralOverrides $StructuralOverrides

    if ($IncludeDomains) {
        $config.domains.include = @($IncludeDomains)
    }

    if ($ExcludeDomains) {
        $config.domains.exclude = @($ExcludeDomains)
    }

    if ($AllowInteractiveInput) {
        $config = Invoke-RangerInteractiveInput -Config $config
    }

    $script:RangerLogLevel = Resolve-RangerLogLevel -Level $(if ($config.behavior.logLevel) { $config.behavior.logLevel } else { 'info' })
    $script:RangerBehaviorRetryCount = if ($config.behavior.retryCount -gt 0) { [int]$config.behavior.retryCount } else { 0 }
    $validation = Test-RangerConfiguration -Config $config -PassThru
    if (-not $validation.IsValid) {
        throw ($validation.Errors -join [Environment]::NewLine)
    }

    $selectedCollectors = Resolve-RangerSelectedCollectors -Config $config
    $credentialMap = Resolve-RangerCredentialMap -Config $config -Overrides $CredentialOverrides
    $basePath = if ($ConfigPath) { Split-Path -Parent (Resolve-RangerPath -Path $ConfigPath) } else { (Get-Location).Path }
    $packageRoot = New-RangerPackageRoot -Config $config -OutputPathOverride $OutputPath -BasePath $basePath
    $script:RangerLogPath = $null
    $script:RangerRetryDetails = New-Object System.Collections.ArrayList
    $script:RangerWinRmProbeCache = @{}
    $logPath = Initialize-RangerFileLog -PackageRoot $packageRoot
    $transcriptPath = Join-Path -Path $packageRoot -ChildPath 'ranger.transcript.log'
    $script:_rangerPrevVerbosePreference = $VerbosePreference
    $script:_rangerPrevDebugPreference = $DebugPreference
    $script:_rangerPrevInformationPreference = $InformationPreference
    $script:_rangerPrevProgressPreference = $ProgressPreference

    switch ($script:RangerLogLevel) {
        'debug' {
            $VerbosePreference = 'Continue'
            $DebugPreference = 'Continue'
            $InformationPreference = 'Continue'
            $ProgressPreference = 'Continue'
        }
        'info' {
            $VerbosePreference = 'SilentlyContinue'
            $DebugPreference = 'SilentlyContinue'
            $InformationPreference = 'SilentlyContinue'
            $ProgressPreference = 'SilentlyContinue'
        }
        'warn' {
            $VerbosePreference = 'SilentlyContinue'
            $DebugPreference = 'SilentlyContinue'
            $InformationPreference = 'SilentlyContinue'
            $ProgressPreference = 'SilentlyContinue'
        }
        'error' {
            $VerbosePreference = 'SilentlyContinue'
            $DebugPreference = 'SilentlyContinue'
            $InformationPreference = 'SilentlyContinue'
            $ProgressPreference = 'SilentlyContinue'
        }
    }

    # Install a global Write-Warning proxy so warnings from ANY module (Az, WinRM, S2D, etc.) are
    # captured in the run log for the duration of this run, then restored in the finally block.
    $script:_rangerPrevWriteWarning = Get-Item function:\global:Write-Warning -ErrorAction SilentlyContinue
    function global:Write-Warning {
        param([AllowNull()][object]$Message)

        $messageText = ConvertTo-RangerLogMessage -InputObject $Message
        $currentLevel = Resolve-RangerLogLevel -Level $(if ($script:RangerLogLevel) { $script:RangerLogLevel } else { 'info' })
        if ((Get-RangerLogLevelRank -Level 'warn') -ge (Get-RangerLogLevelRank -Level $currentLevel) -and -not [string]::IsNullOrWhiteSpace($messageText) -and $script:RangerLogPath) {
            try {
                Add-Content -LiteralPath $script:RangerLogPath -Value "[$((Get-Date).ToString('s'))][WARN] $messageText" -Encoding UTF8 -ErrorAction Stop
            }
            catch {
            }
        }

        Microsoft.PowerShell.Utility\Write-Warning -Message $messageText
    }

    try {
        try {
            Start-Transcript -Path $transcriptPath -Force -ErrorAction Stop | Out-Null
        }
        catch {
            Write-RangerLog -Level warn -Message "Transcript start failed: $($_.Exception.Message)"
        }

        Write-RangerLog -Level info -Message "AzureLocalRanger run started — package: $(Split-Path -Leaf $packageRoot)"
        $manifest = New-RangerManifest -Config $config -SelectedCollectors $selectedCollectors
        $manifest.run.retryDetails = @()
        $manifestPath = Join-Path -Path $packageRoot -ChildPath 'manifest\audit-manifest.json'
        $evidenceRoot = Join-Path -Path $packageRoot -ChildPath 'evidence'

        foreach ($collector in $selectedCollectors) {
            Write-RangerLog -Level info -Message "Collector '$($collector.Id)' starting"
            $collectorResult = Invoke-RangerCollectorExecution -Definition $collector -Config $config -CredentialMap $credentialMap -PackageRoot $packageRoot
            Write-RangerLog -Level info -Message "Collector '$($collector.Id)' completed with status '$($collectorResult.Status)'"
            Add-RangerCollectorToManifest -Manifest ([ref]$manifest) -CollectorResult $collectorResult -EvidenceRoot $evidenceRoot -KeepRawEvidence ([bool]$config.output.keepRawEvidence)
        }

        $manifest.run.retryDetails = @($script:RangerRetryDetails)

        $manifestValidation = Test-RangerManifestSchema -Manifest $manifest -SelectedCollectors $selectedCollectors
        $manifest.run.schemaValidation = [ordered]@{
            isValid  = $manifestValidation.IsValid
            errors   = @($manifestValidation.Errors)
            warnings = @($manifestValidation.Warnings)
        }

        if ($manifestValidation.Warnings.Count -gt 0) {
            $manifest.findings += @(
                New-RangerFinding -Severity informational -Title 'Manifest schema warnings were recorded' -Description 'The generated manifest passed core validation but recorded schema warnings that should be reviewed before handoff.' -CurrentState ($manifestValidation.Warnings -join '; ') -Recommendation 'Review duplicate artifact paths or incomplete metadata before packaging the deliverable.'
            )
        }

        if (-not $manifestValidation.IsValid) {
            $manifest.findings += @(
                New-RangerFinding -Severity warning -Title 'Manifest schema validation failed' -Description 'The generated manifest did not satisfy the minimum schema contract for Ranger outputs.' -CurrentState ($manifestValidation.Errors -join '; ') -Recommendation 'Correct the collector payload or manifest contract before treating this package as a handoff-ready deliverable.'
            )
        }

        Save-RangerManifest -Manifest $manifest -Path $manifestPath
        $manifest.artifacts += @(New-RangerArtifactRecord -Type 'manifest-json' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $manifestPath)) -Status generated -Audience 'all')

        if (-not $manifestValidation.IsValid -and [bool]$config.behavior.failOnSchemaViolation) {
            Save-RangerManifest -Manifest $manifest -Path $manifestPath
            throw ($manifestValidation.Errors -join [Environment]::NewLine)
        }

        if (-not $NoRender -and [bool]$config.behavior.continueToRendering) {
            $renderResult = Invoke-RangerOutputGeneration -Manifest $manifest -PackageRoot $packageRoot -Formats @($config.output.formats) -Mode $config.output.mode
            if ($renderResult.Artifacts) {
                $manifest.artifacts += @($renderResult.Artifacts)
            }
            Save-RangerManifest -Manifest $manifest -Path $manifestPath
        }

        $packageIndexPath = New-RangerPackageIndex -Manifest $manifest -ManifestPath $manifestPath -PackageRoot $packageRoot
        $manifest.artifacts += @(New-RangerArtifactRecord -Type 'package-index' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $packageIndexPath)) -Status generated -Audience 'all')

        if ($logPath -and (Test-Path -LiteralPath $logPath)) {
            Write-RangerLog -Level info -Message "Run complete — package: $(Split-Path -Leaf $packageRoot)"
            $manifest.artifacts += @(New-RangerArtifactRecord -Type 'run-log' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $logPath)) -Status generated -Audience 'all')
        }

        Save-RangerManifest -Manifest $manifest -Path $manifestPath

        [ordered]@{
            Config       = $config
            Manifest     = $manifest
            ManifestPath = $manifestPath
            PackageRoot  = $packageRoot
            LogPath      = $logPath
            Validation   = $validation
            ManifestSchema = $manifestValidation
        }
    }
    finally {
        try {
            Stop-Transcript | Out-Null
        }
        catch {
        }

        if ($logPath -and (Test-Path -LiteralPath $logPath) -and (Test-Path -LiteralPath $transcriptPath)) {
            try {
                Add-Content -LiteralPath $logPath -Value @('', '# Host transcript', '') -Encoding UTF8 -ErrorAction Stop
                Get-Content -LiteralPath $transcriptPath -ErrorAction Stop | Add-Content -LiteralPath $logPath -Encoding UTF8 -ErrorAction Stop
            }
            catch {
            }
        }

        # Restore whatever Write-Warning existed before the run (usually the built-in)
        if ($script:_rangerPrevWriteWarning) {
            Set-Item function:\global:Write-Warning -Value $script:_rangerPrevWriteWarning.ScriptBlock
        } else {
            Remove-Item function:\global:Write-Warning -ErrorAction SilentlyContinue
        }
        $VerbosePreference = $script:_rangerPrevVerbosePreference
        $DebugPreference = $script:_rangerPrevDebugPreference
        $InformationPreference = $script:_rangerPrevInformationPreference
        $ProgressPreference = $script:_rangerPrevProgressPreference
        $script:RangerLogPath = $null
        $script:RangerRetryDetails = $null
        $script:RangerWinRmProbeCache = $null
        $script:RangerBehaviorRetryCount = $null
    }
}