Modules/Core/10-Manifest.ps1

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

        [Parameter(Mandatory = $true)]
        [object[]]$SelectedCollectors,

        [string]$ToolVersion = '0.2.0'
    )

    $targetNodes = @($Config.targets.cluster.nodes)
    [ordered]@{
        run = [ordered]@{
            toolVersion          = $ToolVersion
            schemaVersion        = Get-RangerManifestSchemaVersion
            startTimeUtc         = (Get-Date).ToUniversalTime().ToString('o')
            endTimeUtc           = $null
            mode                 = $Config.output.mode
            runner               = $env:COMPUTERNAME
            includeDomains       = @($Config.domains.include)
            excludeDomains       = @($Config.domains.exclude)
            selectedCollectors   = @($SelectedCollectors | ForEach-Object { $_.Id })
            schemaValidation     = [ordered]@{ isValid = $null; errors = @(); warnings = @() }
        }
        target = [ordered]@{
            environmentLabel = $Config.environment.name
            clusterName      = $Config.environment.clusterName
            clusterFqdn      = $Config.targets.cluster.fqdn
            resourceGroup    = $Config.targets.azure.resourceGroup
            subscriptionId   = $Config.targets.azure.subscriptionId
            tenantId         = $Config.targets.azure.tenantId
            nodeList         = $targetNodes
        }
        topology      = [ordered]@{}
        collectors    = [ordered]@{}
        domains       = Get-RangerReservedDomainPayloads
        relationships = @()
        findings      = @()
        artifacts     = @()
        evidence      = @()
    }
}

function Get-RangerManifestSchemaContract {
    $schemaPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\repo-management\contracts\manifest-schema.json'
    $resolvedPath = [System.IO.Path]::GetFullPath($schemaPath)
    if (-not (Test-Path -Path $resolvedPath)) {
        throw "Manifest schema contract file was not found: $resolvedPath"
    }

    return Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 50
}

function Test-RangerManifestSchema {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

        [object[]]$SelectedCollectors
    )

    $errors = New-Object System.Collections.Generic.List[string]
    $warnings = New-Object System.Collections.Generic.List[string]
    $schemaContract = Get-RangerManifestSchemaContract

    foreach ($requiredKey in @($schemaContract.requiredTopLevelKeys)) {
        if (-not $Manifest.Contains($requiredKey)) {
            $errors.Add("Manifest is missing required top-level key '$requiredKey'.")
        }
    }

    if ($Manifest.run.schemaVersion -ne $schemaContract.schemaVersion) {
        $warnings.Add("Manifest schemaVersion '$($Manifest.run.schemaVersion)' does not match schema contract version '$($schemaContract.schemaVersion)'.")
    }

    foreach ($runKey in @($schemaContract.requiredRunKeys)) {
        if (-not $Manifest.run.Contains($runKey) -or $null -eq $Manifest.run[$runKey] -or [string]::IsNullOrWhiteSpace([string]$Manifest.run[$runKey])) {
            $errors.Add("Manifest.run is missing required value '$runKey'.")
        }
    }

    foreach ($reservedDomain in @($schemaContract.reservedDomains)) {
        if (-not $Manifest.domains.Contains($reservedDomain)) {
            $errors.Add("Manifest.domains is missing reserved payload '$reservedDomain'.")
        }
    }

    foreach ($collectorId in @($Manifest.run.selectedCollectors)) {
        if (-not $Manifest.collectors.Contains($collectorId)) {
            $errors.Add("Manifest.collectors is missing selected collector '$collectorId'.")
        }
    }

    foreach ($collectorEntry in @($Manifest.collectors.GetEnumerator())) {
        if ($collectorEntry.Value.status -notin @($schemaContract.collectorStatuses)) {
            $errors.Add("Collector '$($collectorEntry.Key)' has unsupported status '$($collectorEntry.Value.status)'.")
        }
    }

    $artifactPaths = @($Manifest.artifacts | Where-Object { -not [string]::IsNullOrWhiteSpace($_.relativePath) } | ForEach-Object { $_.relativePath })
    $duplicateArtifacts = @($artifactPaths | Group-Object | Where-Object { $_.Count -gt 1 })
    foreach ($duplicateArtifact in $duplicateArtifacts) {
        $warnings.Add("Manifest.artifacts contains duplicate relativePath '$($duplicateArtifact.Name)'.")
    }

    if ($SelectedCollectors) {
        foreach ($collector in $SelectedCollectors) {
            if ($collector.Id -notin @($Manifest.run.selectedCollectors)) {
                $warnings.Add("Selected collector '$($collector.Id)' was not recorded in manifest.run.selectedCollectors.")
            }
        }
    }

    return [ordered]@{
        IsValid  = $errors.Count -eq 0
        Errors   = @($errors)
        Warnings = @($warnings)
    }
}

function Save-RangerCollectorEvidence {
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$CollectorResult,

        [Parameter(Mandatory = $true)]
        [string]$EvidenceRoot,

        [Parameter(Mandatory = $true)]
        [ref]$Manifest
    )

    if (-not $CollectorResult.ContainsKey('RawEvidence') -or $null -eq $CollectorResult.RawEvidence) {
        return
    }

    New-Item -ItemType Directory -Path $EvidenceRoot -Force | Out-Null
    $fileName = "{0}.json" -f (Get-RangerSafeName -Value $CollectorResult.CollectorId)
    $filePath = Join-Path -Path $EvidenceRoot -ChildPath $fileName
    $CollectorResult.RawEvidence | ConvertTo-Json -Depth 100 | Set-Content -Path $filePath -Encoding UTF8
    $relativePath = [System.IO.Path]::GetRelativePath((Split-Path -Parent $EvidenceRoot), $filePath)
    $Manifest.Value.evidence += @(
        [ordered]@{
            collector = $CollectorResult.CollectorId
            kind      = 'raw-evidence'
            path      = $relativePath
        }
    )
}

function Add-RangerCollectorToManifest {
    param(
        [Parameter(Mandatory = $true)]
        [ref]$Manifest,

        [Parameter(Mandatory = $true)]
        [hashtable]$CollectorResult,

        [string]$EvidenceRoot,
        [bool]$KeepRawEvidence = $false
    )

    $Manifest.Value.collectors[$CollectorResult.CollectorId] = [ordered]@{
        status          = $CollectorResult.Status
        startTimeUtc    = $CollectorResult.StartTimeUtc
        endTimeUtc      = $CollectorResult.EndTimeUtc
        targetScope     = @($CollectorResult.TargetScope)
        credentialScope = $CollectorResult.CredentialScope
        messages        = @($CollectorResult.Messages)
    }

    if ($CollectorResult.ContainsKey('Topology') -and $CollectorResult.Topology) {
        $Manifest.Value.topology = ConvertTo-RangerHashtable -InputObject $CollectorResult.Topology
    }

    if ($CollectorResult.ContainsKey('Domains')) {
        foreach ($domainKey in $CollectorResult.Domains.Keys) {
            $Manifest.Value.domains[$domainKey] = ConvertTo-RangerHashtable -InputObject $CollectorResult.Domains[$domainKey]
        }
    }

    if ($CollectorResult.ContainsKey('Relationships')) {
        $Manifest.Value.relationships += @(ConvertTo-RangerHashtable -InputObject $CollectorResult.Relationships)
    }

    if ($CollectorResult.ContainsKey('Findings')) {
        $Manifest.Value.findings += @(ConvertTo-RangerHashtable -InputObject $CollectorResult.Findings)
    }

    if ($CollectorResult.ContainsKey('Evidence')) {
        $Manifest.Value.evidence += @(ConvertTo-RangerHashtable -InputObject $CollectorResult.Evidence)
    }

    if ($KeepRawEvidence -and $EvidenceRoot) {
        Save-RangerCollectorEvidence -CollectorResult $CollectorResult -EvidenceRoot $EvidenceRoot -Manifest $Manifest
    }
}

function Save-RangerManifest {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest,

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

    $Manifest.run.endTimeUtc = (Get-Date).ToUniversalTime().ToString('o')
    New-Item -ItemType Directory -Path (Split-Path -Parent $Path) -Force | Out-Null
    $Manifest | ConvertTo-Json -Depth 100 | Set-Content -Path $Path -Encoding UTF8
}

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

        [Parameter(Mandatory = $true)]
        [string]$ManifestPath,

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

    $index = [ordered]@{
        environment = $Manifest.target.environmentLabel
        clusterName = $Manifest.target.clusterName
        mode        = $Manifest.run.mode
        generatedAt = (Get-Date).ToUniversalTime().ToString('o')
        manifest    = [System.IO.Path]::GetRelativePath($PackageRoot, $ManifestPath)
        artifacts   = @($Manifest.artifacts)
    }

    $path = Join-Path -Path $PackageRoot -ChildPath 'package-index.json'
    $index | ConvertTo-Json -Depth 50 | Set-Content -Path $path -Encoding UTF8
    return $path
}