Private/Forest/Engine.ps1

#requires -Version 5.1
[CmdletBinding(DefaultParameterSetName='Live')]
param(
    [Parameter(Mandatory=$true,ParameterSetName='Live')][ValidatePattern('^(?=.{1,253}$)[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$')][string]$Domain,
    [Parameter(ParameterSetName='Live')][pscredential]$Credential,
    [Parameter(ParameterSetName='Live')][ValidateRange(10,1800)][int]$TimeoutSeconds=180,
    [Parameter(Mandatory=$true,ParameterSetName='Sample')][switch]$SampleData,
    [ValidateNotNullOrEmpty()][string]$OutputPath='C:\Temp\Reports',
    [switch]$Open
)
Set-StrictMode -Version 2.0;$ErrorActionPreference='Stop'
$moduleRoot=Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
foreach($file in @('Helpers.ps1','Export.ps1')){. (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'Sites') $file)}
foreach($file in @('Collector.ps1','Model.ps1')){. (Join-Path $PSScriptRoot $file)}
$now=[datetimeoffset]::Now;$runId=[guid]::NewGuid().ToString('N').Substring(0,8)
$folder=Join-Path $OutputPath ('PSInsightInventory_Forest_'+$now.ToString('yyyyMMdd_HHmmss_fff')+'_'+$runId)
$reports=Join-Path $folder 'Reports';$logs=Join-Path $folder 'Logs';$null=New-Item -ItemType Directory -Path $reports,$logs -Force
$log=Join-Path $logs ('ForestInventory_'+$now.ToString('yyyyMMdd_HHmmss')+'.jsonl')
try{
    Write-PSISitesLog $log $runId 'Run' 'Start' 'Started' $Domain ('Forest/domain inventory; SampleData='+[bool]$SampleData)
    if($SampleData){$fixture=[IO.File]::ReadAllText((Join-Path $moduleRoot 'Assets/Forest.sample.json'))|ConvertFrom-Json;$context=$fixture.context;$envelopes=@($fixture.envelopes)}
    else{
        if($env:OS -ne 'Windows_NT'){throw 'Live forest inventory requires Windows and RSAT ActiveDirectory.'}
        if($Domain -notmatch '\.'){throw 'Specify a DNS connection domain. The inventory scope is the entire forest.'}
        if(-not (Get-Module -ListAvailable ActiveDirectory)){throw 'RSAT ActiveDirectory module is required.'}
        if(-not (Get-Command Test-PSIInventoryQuiet -ErrorAction SilentlyContinue) -or -not (Test-PSIInventoryQuiet)){Write-Information "Reading forest metadata and member domains using $Domain..." -InformationAction Continue}
        $collected=Invoke-PSIForestCollection -Domain $Domain -Credential $Credential -TimeoutSeconds $TimeoutSeconds
        $context=$collected.Context;$envelopes=$collected.Envelopes
    }
 if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay -Stage 'Preparing report data' -Force}
    $model=ConvertTo-PSIForestModel $context $envelopes
    foreach($entry in $model.Data.collection){Write-PSISitesLog $log $runId 'Collection' $entry.Name $entry.Status $entry.Target $entry.Message ([long]$entry.'Duration ms') ([string]$entry.'Error ID')}
    $end=[datetimeoffset]::Now;$expected=2+2*@($context.Domains).Count
    $meta=[ordered]@{moduleName='PSInsightInventory';version='1.0.0';inventoryId='ADForest';title='Forest & Domain Inventory';run=$runId;dataSource=if($SampleData){'Sample'}else{'Live'};scope='Forest-wide metadata and member domains';forest=$context.Forest;connectionDomain=$context.Domain;sourceDC=$context.Server;configurationNC=$context.ConfigurationNC;started=$now.ToString('o');generated=$end.ToString('o');generatedDisplay=$end.ToString('dd MMM yyyy, HH:mm:ss zzz');timeZone=[timezoneinfo]::Local.Id;expectedQueries=$expected;domainCount=@($context.Domains).Count;domainDetailsCollected=$model.DomainDetailsCollected;policiesCollected=$model.PoliciesCollected;notice=if($SampleData){'SAMPLE DATA. Fictional forest. No infrastructure was contacted.'}else{'Read-only forest and domain inventory. The supplied domain is the starting point; all returned forest member domains are queried.'};limitations=@('Functional levels and role owners are configuration values, not compatibility or health assessments.','Forest membership references remain visible even when a member-domain query fails; unavailable values remain unknown.','Hosted naming contexts are observed on the selected DCs; partition references are read separately from Configuration.','Only configured additional UPN/SPN suffix lists are shown; they are not a census of suffixes used by accounts.','Default domain password policies are inventory only. Fine-grained policies and user-effective password policies are not evaluated.','Successful reads cannot prove ACL-filtered attribute/object visibility. Server references do not prove endpoint availability.','No WinRM, object changes, trust enumeration, password/hash retrieval or email is performed.');timeoutSeconds=if($SampleData){$null}else{$TimeoutSeconds}}
 if(Get-Command Receive-PSIInventoryDisplay -ErrorAction SilentlyContinue){Receive-PSIInventoryDisplay -Items @($envelopes);Complete-PSIInventoryCollectionDisplay -Collection $model.Data.collection;Update-PSIInventoryDisplay -Stage 'Saving reports' -Force}
    $paths=Export-PSISitesReport $model $meta $moduleRoot $reports 'Forest.html' 'Forest-And-Domains'
    $success=@($model.Data.collection|Where-Object{$_.Status -eq 'Succeeded'}).Count
    Write-PSISitesLog $log $runId 'Export' 'Report' 'Succeeded' $context.Server $paths.HtmlReport
    if($Open){try{Invoke-Item -LiteralPath $paths.HtmlReport}catch{Write-Warning "Report saved, but could not open automatically: $($_.Exception.Message)"}}
    [pscustomobject][ordered]@{ModuleName='PSInsightInventory';ModuleVersion='1.0.0';InventoryId='ADForest';Scope='Forest-wide';Forest=$context.Forest;DomainReferences=@($context.Domains).Count;DomainDetailsCollected=$model.DomainDetailsCollected;PoliciesCollected=$model.PoliciesCollected;QueriesCollected=$success;QueriesUnavailable=$expected-$success;DataSource=$meta.dataSource;HtmlReport=$paths.HtmlReport;JsonSnapshot=$paths.JsonSnapshot;CsvFolder=$paths.CsvFolder;LogFile=$log;OutputFolder=$folder}
}catch{Write-PSISitesLog $log $runId 'Run' 'Complete' 'Failed' $Domain $_.Exception.Message 0 $_.FullyQualifiedErrorId;throw}