Private/Dns/Common.ps1

function Get-DCInvValue($Object,[string]$Property){
    if($null -eq $Object){return $null}
    if($Object -is [System.Collections.IDictionary]){if($Object.Contains($Property)){return $Object[$Property]}else{return $null}}
    if($null -ne $Object.PSObject.Properties[$Property]){return $Object.$Property}
    return $null
}

function ConvertTo-DCInvFlat($Value){
    if($null -eq $Value){return $null}
    if($Value -is [datetime] -or $Value -is [datetimeoffset]){return ([datetimeoffset]$Value).ToString('o')}
    if($Value -is [string] -or $Value -is [bool] -or $Value -is [ValueType]){return $Value}
    if($Value -is [System.Collections.IEnumerable]){return (@($Value | ForEach-Object{[string]$_}) -join '; ')}
    return [string]$Value
}

function Write-DCInvLog([string]$Level,[string]$Phase,[string]$Target,[string]$Operation,[string]$Outcome,[string]$Message,[long]$DurationMs=0,[string]$ErrorId='', [string]$ErrorMessage=''){
    [pscustomobject][ordered]@{
        Timestamp=[datetimeoffset]::Now.ToString('o');Level=$Level;RunId=$script:DCInvRunId;Phase=$Phase
        Target=$Target;Operation=$Operation;Outcome=$Outcome;DurationMs=$DurationMs;Message=$Message;ErrorId=$ErrorId;ErrorMessage=$ErrorMessage
    } | ConvertTo-Json -Compress -Depth 5 | Add-Content -LiteralPath $script:DCInvLogPath -Encoding UTF8
}

function Add-DCInvSection($Envelope){
    if(Get-Command Receive-PSIInventoryDisplay -ErrorAction SilentlyContinue){Receive-PSIInventoryDisplay -Items @($Envelope)}
    $key=[string]$Envelope.Key;$target=[string]$Envelope.Target
    if(-not $script:DCInvSections.Contains($key) -or $key -eq 'collection'){throw "Unknown category envelope: $key"}
    $identity=$target+'|'+$key
    if($script:DCInvSeen.ContainsKey($identity)){throw "Duplicate category envelope: $identity"}
    $allowed=@('Succeeded','Partial','Failed','TimedOut','NotAttempted','NotApplicable')
    if([string]$Envelope.Status -notin $allowed){throw "Invalid collection status for $identity"}
    $rows=@($Envelope.Records | Where-Object{$null -ne $_})
    # Failed/timeout categories have no trusted records; completed categories are separate envelopes.
    if([string]$Envelope.Status -notin @('Succeeded','Partial','NotApplicable')){$rows=@()}
    foreach($row in $rows){
        $record=[ordered]@{}
        foreach($column in $script:DCInvSections[$key][1]){$record[$column]=ConvertTo-DCInvFlat (Get-DCInvValue $row $column)}
        $record.Name=$target
        [void]$script:DCInvData[$key].Add([pscustomobject]$record)
    }
    $outcome=switch([string]$Envelope.Status){'Succeeded'{'Collected'}'Partial'{'Partial'}'NotApplicable'{'Not applicable'}'NotAttempted'{'Not attempted'}'TimedOut'{'Timed out'}default{'Failed'}}
    $message=[string](Get-DCInvValue $Envelope 'ErrorMessage')
    if($Envelope.Status -eq 'Succeeded'){$message='Collection succeeded; zero records is a valid empty result.'}
    $ledger=[pscustomobject][ordered]@{
        Name=$target;Section=$script:DCInvSections[$key][0];Outcome=$outcome;Method=$Envelope.Method
        'Collected at'=$Envelope.CollectedAt;Message=$message;'Check ID'=$key;Status=$Envelope.Status
        'Duration ms'=$Envelope.DurationMs;'Record count'=$rows.Count;'Error ID'=Get-DCInvValue $Envelope 'ErrorId'
        'Started at'=Get-DCInvValue $Envelope 'StartedAt'
    }
    [void]$script:DCInvData.collection.Add($ledger);$script:DCInvSeen[$identity]=$true
    $level=if($Envelope.Status -in @('Succeeded','NotApplicable')){'Information'}else{'Warning'}
    Write-DCInvLog $level 'Collection' $target $key $Envelope.Status $message $Envelope.DurationMs ([string](Get-DCInvValue $Envelope 'ErrorId')) ([string](Get-DCInvValue $Envelope 'ErrorMessage'))
}

function Select-DCInvTargets([object[]]$Discovered,[string]$SelectedDomain,[string[]]$Names){
    $domainTargets=@($Discovered | Where-Object{[string]$_.Domain -ieq $SelectedDomain} | Sort-Object HostName -Unique)
    if($domainTargets.Count -ne $Discovered.Count){throw 'Discovery returned a DC outside the requested domain. Collection stopped.'}
    if(-not $domainTargets.Count){throw "No domain controllers discovered in $SelectedDomain"}
    if(@($Names | Where-Object{$_}).Count -eq 0){return $domainTargets}
    $selected=New-Object System.Collections.ArrayList
    foreach($name in $Names){
        if($name -match '[*?\[\]]'){throw 'ComputerName accepts exact short names or FQDNs, not wildcard patterns.'}
        $matches=@($domainTargets | Where-Object{[string]$_.Name -ieq $name -or [string]$_.HostName -ieq $name})
        if($matches.Count -ne 1){throw "ComputerName '$name' is not a unique DC discovered in $SelectedDomain"}
        if(@($selected | Where-Object{$_.HostName -ieq $matches[0].HostName}).Count -eq 0){[void]$selected.Add($matches[0])}
    }
    $selected.ToArray()
}

function Invoke-DCInvDiscovery([string]$RequestedDomain,[pscredential]$DiscoveryCredential,[int]$TimeoutSeconds){
    $job=$null
    try{
        $job=Start-Job -ScriptBlock {
            param($RequestedDomain,$DiscoveryCredential)
            $ErrorActionPreference='Stop'
            Import-Module ActiveDirectory -ErrorAction Stop
            $params=@{Server=$RequestedDomain;ErrorAction='Stop'}
            if($null -ne $DiscoveryCredential){$params.Credential=$DiscoveryCredential}
            $domainInfo=Get-ADDomain @params
            if($domainInfo.DNSRoot -ine $RequestedDomain){throw 'Requested domain does not match discovered DNS domain.'}
            Get-ADDomainController -Filter * @params | ForEach-Object{
                [pscustomobject]@{
                    Name=$_.Name;HostName=$_.HostName;Domain=$_.Domain;Site=$_.Site;IPv4Address=$_.IPv4Address;IPv6Address=$_.IPv6Address
                    OperatingSystem=$_.OperatingSystem;OperatingSystemVersion=$_.OperatingSystemVersion;IsGlobalCatalog=$_.IsGlobalCatalog
                    IsReadOnly=$_.IsReadOnly;NTDSSettingsObjectDN=$_.NTDSSettingsObjectDN;OperationMasterRoles=@($_.OperationMasterRoles | ForEach-Object{[string]$_})
                }
            }
        } -ArgumentList $RequestedDomain,$DiscoveryCredential -ErrorAction Stop
        $discoveryClock=[diagnostics.stopwatch]::StartNew();$finished=$null
        while($discoveryClock.Elapsed.TotalSeconds -lt $TimeoutSeconds){
            if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay -Stage 'Discovering domain controllers'}
            if($job.State -in @('Completed','Failed','Stopped')){$finished=$job;break}
            Start-Sleep -Milliseconds 200
        }
        if($null -eq $finished){throw "Domain discovery exceeded $TimeoutSeconds seconds. No DC collection started."}
        $results=@(Receive-Job -Job $job -ErrorAction Stop)
        if($job.State -ne 'Completed'){throw "Domain discovery ended with state $($job.State)"}
        return $results
    }
    finally{if($null -ne $job){if($job.State -in @('Running','NotStarted','Blocked')){Stop-Job -Job $job -ErrorAction SilentlyContinue};Remove-Job -Job $job -Force -ErrorAction SilentlyContinue}}
}


function Invoke-DCInvWorkers([object[]]$Controllers,[scriptblock]$Worker,[string]$RemoteText,$Settings,[pscredential]$WorkerCredential){
    if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay -Total ($Controllers.Count*@($script:DCInvSections.Keys|Where-Object{$_ -ne 'collection'}).Count)}
    $pending=New-Object 'System.Collections.Generic.Queue[object]'
    foreach($controller in $Controllers){$pending.Enqueue($controller)}
    $active=New-Object System.Collections.ArrayList;$finishedCount=0
    $required=@($script:DCInvSections.Keys | Where-Object{$_ -ne 'collection' -and $_ -ne 'dcs'})
    try{
        while($pending.Count -gt 0 -or $active.Count -gt 0){
            while($pending.Count -gt 0 -and $active.Count -lt $Settings.ThrottleLimit){
                $dc=$pending.Dequeue();$target=[string]$dc.HostName
                Write-DCInvLog 'Information' 'Collection' $target 'Worker' 'Started' 'Starting read-only target worker.'
                $job=Start-Job -ScriptBlock $Worker -ArgumentList $dc,$WorkerCredential,$Settings,$RemoteText -ErrorAction Stop
                [void]$active.Add([pscustomobject]@{Job=$job;Controller=$dc;Clock=[diagnostics.stopwatch]::StartNew();ErrorMessage=''})
            }
            foreach($item in @($active.ToArray())){
                $receiveErrors=@()
                $envelopes=@(Receive-Job -Job $item.Job -ErrorAction SilentlyContinue -ErrorVariable receiveErrors)
                foreach($envelope in $envelopes){
                    if([string](Get-DCInvValue $envelope 'Kind') -eq 'Section'){Add-DCInvSection $envelope}
                    else{throw "Unexpected worker output from $($item.Controller.HostName). Expected category envelope."}
                }
                if($receiveErrors.Count){$item.ErrorMessage=($receiveErrors | ForEach-Object{$_.Exception.Message}) -join '; '}
                $timedOut=$item.Clock.Elapsed.TotalSeconds -ge $Settings.TargetTimeoutSeconds
                $terminal=$item.Job.State -in @('Completed','Failed','Stopped')
                if($timedOut -or $terminal){
                    if($timedOut -and -not $terminal){Stop-Job -Job $item.Job -ErrorAction SilentlyContinue}
                    # Drain any envelopes produced between the earlier receive and completion/cancellation.
                    $lastErrors=@();$last=@(Receive-Job -Job $item.Job -ErrorAction SilentlyContinue -ErrorVariable lastErrors)
                    foreach($envelope in $last){if([string](Get-DCInvValue $envelope 'Kind') -eq 'Section'){Add-DCInvSection $envelope}}
                    if($lastErrors.Count){$item.ErrorMessage=($lastErrors | ForEach-Object{$_.Exception.Message}) -join '; '}
                    foreach($key in $required){
                        if(-not $script:DCInvSeen.ContainsKey($item.Controller.HostName+'|'+$key)){
                            $status=if($timedOut -and -not $terminal){'TimedOut'}else{'Failed'}
                            $message=if($status -eq 'TimedOut'){"Target worker exceeded $($Settings.TargetTimeoutSeconds) seconds; this category did not complete."}elseif($item.ErrorMessage){$item.ErrorMessage}else{'Worker did not return this category.'}
                            Add-DCInvSection ([pscustomobject]@{Target=$item.Controller.HostName;Key=$key;Status=$status;Method='Worker / incomplete category';StartedAt=$null;CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=[long]$item.Clock.ElapsedMilliseconds;Records=@();ErrorId='DCInventory.WorkerIncomplete';ErrorMessage=$message})
                        }
                    }
                    Remove-Job -Job $item.Job -Force -ErrorAction SilentlyContinue
                    [void]$active.Remove($item);$finishedCount++
                    if(-not (Get-Command Test-PSIInventoryQuiet -ErrorAction SilentlyContinue) -or -not (Test-PSIInventoryQuiet)){Write-Information "[$finishedCount/$($Controllers.Count)] Inventory collection finished: $($item.Controller.HostName)" -InformationAction Continue}
                }
            }
            if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay}else{Write-Progress -Activity 'Read-only DC inventory' -Status "$finishedCount of $($Controllers.Count) controllers finished; $($active.Count) active" -PercentComplete (100*$finishedCount/$Controllers.Count)}
            if($active.Count -gt 0){Start-Sleep -Milliseconds 200}
        }
    }
    finally{
        foreach($item in @($active.ToArray())){Stop-Job -Job $item.Job -ErrorAction SilentlyContinue;Remove-Job -Job $item.Job -Force -ErrorAction SilentlyContinue}
            if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay}else{Write-Progress -Activity 'Read-only DC inventory' -Completed}
    }
}