Modules/Public/20-EstateCommands.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    v2.5.0 multi-cluster estate orchestration (#129) and manual evidence import (#32).
#>


function Invoke-AzureLocalRangerEstate {
    <#
    .SYNOPSIS
        v2.5.0 (#129): run Ranger against multiple clusters and produce an estate rollup.
    .DESCRIPTION
        Accepts an array of per-cluster configuration blocks (or a single config file
        containing an `estate.targets` list). Runs `Invoke-AzureLocalRanger` per target
        sequentially, then emits `estate-rollup.json` + `estate-summary.html` +
        `powerbi/estate-*.csv` summarising node count, VM count, WAF scores, AHB posture,
        and capacity headroom across the estate.
    .PARAMETER ConfigPath
        Path to a JSON/YAML config file with an `estate` block listing targets.
    .PARAMETER Targets
        Inline array of target config hashtables. Each must include `clusterName`
        and a `configPath` or inline collector settings.
    .PARAMETER OutputRoot
        Root folder for per-cluster packages + estate rollup. Default: current directory.
    .EXAMPLE
        Invoke-AzureLocalRangerEstate -ConfigPath ranger-estate.json
    #>

    [CmdletBinding(DefaultParameterSetName = 'Config')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'Config')]
        [string]$ConfigPath,

        [Parameter(Mandatory = $true, ParameterSetName = 'Inline')]
        [object[]]$Targets,

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

        [switch]$FixtureMode,

        [switch]$ContinueOnClusterError
    )

    if ($PSCmdlet.ParameterSetName -eq 'Config') {
        if (-not (Test-Path -Path $ConfigPath -PathType Leaf)) {
            throw "Estate config not found: $ConfigPath"
        }
        $raw = Get-Content -Path $ConfigPath -Raw
        $cfg = if ($ConfigPath -like '*.yml' -or $ConfigPath -like '*.yaml') {
            if (-not (Get-Module -ListAvailable -Name powershell-yaml)) { throw 'powershell-yaml module required for YAML estate configs.' }
            Import-Module powershell-yaml -Force
            $raw | ConvertFrom-Yaml
        } else {
            $raw | ConvertFrom-Json -AsHashtable -Depth 20
        }
        $Targets = @($cfg.estate.targets)
    }

    if (-not $Targets -or $Targets.Count -eq 0) {
        throw 'No estate targets provided.'
    }

    $estateStart = Get-Date
    $runId = (Get-Date -Format 'yyyyMMddHHmmss')
    $estateDir = Join-Path $OutputRoot ("estate-$runId")
    New-Item -ItemType Directory -Path $estateDir -Force | Out-Null

    $clusterResults = New-Object System.Collections.ArrayList
    foreach ($t in $Targets) {
        $clusterName = [string]$t.clusterName
        if ([string]::IsNullOrWhiteSpace($clusterName)) { $clusterName = 'unknown' }
        Write-Host "[estate] Running against cluster '$clusterName'..."

        $perOut = Join-Path $estateDir $clusterName
        New-Item -ItemType Directory -Path $perOut -Force | Out-Null

        try {
            $params = @{ OutputPath = $perOut }
            if ($t.configPath)   { $params['ConfigPath'] = [string]$t.configPath }
            if ($FixtureMode)    { $params['FixtureMode'] = $true }

            $result = if (Get-Command -Name Invoke-AzureLocalRanger -ErrorAction SilentlyContinue) {
                Invoke-AzureLocalRanger @params
            } else { $null }

            $manifestPath = Join-Path $perOut 'audit-manifest.json'
            $m = $null
            if (Test-Path -Path $manifestPath -PathType Leaf) {
                $m = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json -AsHashtable -Depth 50
            }

            [void]$clusterResults.Add([ordered]@{
                cluster         = $clusterName
                outputPath      = $perOut
                manifestPath    = $manifestPath
                status          = if ($m) { 'ok' } else { 'failed' }
                manifest        = $m
            })
        } catch {
            Write-Warning "[estate] $clusterName failed: $($_.Exception.Message)"
            [void]$clusterResults.Add([ordered]@{
                cluster      = $clusterName
                outputPath   = $perOut
                status       = 'failed'
                error        = $_.Exception.Message
            })
            if (-not $ContinueOnClusterError) { throw }
        }
    }

    # Build the estate rollup
    $rollup = New-RangerEstateRollup -ClusterResults $clusterResults -RunId $runId -StartTime $estateStart
    $rollupPath = Join-Path $estateDir 'estate-rollup.json'
    $rollup | ConvertTo-Json -Depth 20 | Set-Content -Path $rollupPath -Encoding UTF8

    # Power BI CSVs
    $pbiDir = Join-Path $estateDir 'powerbi'
    New-Item -ItemType Directory -Path $pbiDir -Force | Out-Null
    $rollup.clusters | ForEach-Object {
        [PSCustomObject]@{
            Cluster            = $_.cluster
            NodeCount          = $_.nodeCount
            VmCount            = $_.vmCount
            WafScore           = $_.wafScore
            WafStatus          = $_.wafStatus
            AhbAdoptionPct     = $_.ahbAdoptionPct
            StorageUsedPct     = $_.storageUsedPct
            CapacityStatus     = $_.capacityStatus
        }
    } | Export-Csv -Path (Join-Path $pbiDir 'estate-clusters.csv') -NoTypeInformation -Encoding UTF8

    $summaryPath = Join-Path $estateDir 'estate-summary.html'
    New-RangerEstateSummaryHtml -Rollup $rollup -OutputPath $summaryPath

    Write-Host "[estate] Rollup: $rollupPath"
    return [ordered]@{
        estateDir       = $estateDir
        rollupPath      = $rollupPath
        summaryPath     = $summaryPath
        clusterCount    = $clusterResults.Count
        clusters        = @($clusterResults | ForEach-Object { [ordered]@{ cluster = $_.cluster; status = $_.status } })
    }
}

function New-RangerEstateRollup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [System.Collections.IEnumerable]$ClusterResults,
        [Parameter(Mandatory = $true)] [string]$RunId,
        [Parameter(Mandatory = $true)] [datetime]$StartTime
    )

    $clusters = New-Object System.Collections.ArrayList
    $totalNodes = 0; $totalVms = 0
    $scoreSum = 0.0; $scoreCount = 0
    $ahbSum = 0.0; $ahbCount = 0

    foreach ($c in $ClusterResults) {
        $m = $c.manifest
        if (-not $m) {
            [void]$clusters.Add([ordered]@{ cluster = $c.cluster; status = $c.status; error = $c.error })
            continue
        }
        $nodes = @($m.domains.clusterNode.nodes).Count
        $vms   = @($m.domains.virtualMachines.inventory).Count
        $waf   = $m.domains.wafAssessment.summary
        $ahb   = $m.domains.azureIntegration.costLicensing.summary
        $cap   = $m.domains.capacityAnalysis.summary
        $totalNodes += $nodes
        $totalVms += $vms
        if ($waf -and $waf.overallScore) { $scoreSum += [double]$waf.overallScore; $scoreCount++ }
        if ($ahb -and $null -ne $ahb.ahbAdoptionPct) { $ahbSum += [double]$ahb.ahbAdoptionPct; $ahbCount++ }

        [void]$clusters.Add([ordered]@{
            cluster        = $c.cluster
            status         = 'ok'
            nodeCount      = $nodes
            vmCount        = $vms
            wafScore       = if ($waf) { [int]$waf.overallScore } else { 0 }
            wafStatus      = if ($waf) { [string]$waf.status } else { 'unknown' }
            ahbAdoptionPct = if ($ahb) { [double]$ahb.ahbAdoptionPct } else { 0 }
            storageUsedPct = if ($cap) { [double]$cap.storageUtilizationPct } else { 0 }
            capacityStatus = if ($cap) { [string]$cap.storageStatus } else { 'unknown' }
            manifestPath   = $c.manifestPath
        })
    }

    return [ordered]@{
        schemaVersion       = '1.0'
        runId               = $RunId
        generatedAtUtc      = (Get-Date).ToUniversalTime().ToString('o')
        clusterCount        = $clusters.Count
        totalNodes          = $totalNodes
        totalVms            = $totalVms
        averageWafScore     = if ($scoreCount -gt 0) { [int][Math]::Round($scoreSum / $scoreCount) } else { 0 }
        averageAhbAdoption  = if ($ahbCount -gt 0) { [Math]::Round($ahbSum / $ahbCount, 1) } else { 0 }
        clusters            = @($clusters)
    }
}

function New-RangerEstateSummaryHtml {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Rollup,
        [Parameter(Mandatory = $true)] [string]$OutputPath
    )

    $rows = foreach ($c in $Rollup.clusters) {
        "<tr><td>$([System.Web.HttpUtility]::HtmlEncode($c.cluster))</td><td>$($c.status)</td><td>$($c.nodeCount)</td><td>$($c.vmCount)</td><td>$($c.wafScore)% ($($c.wafStatus))</td><td>$($c.ahbAdoptionPct)%</td><td>$($c.storageUsedPct)% ($($c.capacityStatus))</td></tr>"
    }
    $rowsHtml = ($rows -join [Environment]::NewLine)

    $html = @"
<!DOCTYPE html>
<html><head><meta charset='utf-8'><title>Ranger Estate Rollup — $($Rollup.runId)</title>
<style>body{font-family:Segoe UI,Arial,sans-serif;margin:24px} table{border-collapse:collapse;width:100%} th,td{border:1px solid #ccc;padding:6px 10px;text-align:left} th{background:#f5f5f5}</style>
</head><body>
<h1>Azure Local Ranger — Estate Rollup</h1>
<p>Run $($Rollup.runId) — $($Rollup.clusterCount) cluster(s), $($Rollup.totalNodes) nodes, $($Rollup.totalVms) VMs. Average WAF score: $($Rollup.averageWafScore)%. Average AHB adoption: $($Rollup.averageAhbAdoption)%.</p>
<table>
<tr><th>Cluster</th><th>Status</th><th>Nodes</th><th>VMs</th><th>WAF</th><th>AHB</th><th>Storage</th></tr>
$rowsHtml
</table>
</body></html>
"@

    [System.IO.File]::WriteAllText($OutputPath, $html, [System.Text.Encoding]::UTF8)
}

function Import-RangerManualEvidence {
    <#
    .SYNOPSIS
        v2.5.0 (#32): merge externally collected evidence into an existing Ranger manifest.
    .DESCRIPTION
        Operators can hand-collect data from systems Ranger cannot reach (air-gapped
        network devices, externally managed firewalls, paper inventories) and merge
        the result into an existing audit-manifest.json with provenance labels.
    .PARAMETER ManifestPath
        Path to the existing audit-manifest.json to enrich.
    .PARAMETER EvidencePath
        Path to the manual-evidence JSON file. Must contain `domain` (string) and
        `data` (object/array) at the root, plus optional `provenance` metadata.
    .PARAMETER Source
        Label describing the data source (e.g. 'manual-network-inventory'). Recorded
        in `manifest.run.manualImports` so downstream consumers can distinguish
        machine-collected from manually supplied data.
    .PARAMETER OutputPath
        Optional alternate output path. Defaults to overwriting the source manifest.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$ManifestPath,
        [Parameter(Mandatory = $true)] [string]$EvidencePath,
        [Parameter(Mandatory = $true)] [string]$Source,
        [string]$OutputPath
    )

    if (-not (Test-Path -Path $ManifestPath -PathType Leaf)) { throw "Manifest not found: $ManifestPath" }
    if (-not (Test-Path -Path $EvidencePath -PathType Leaf)) { throw "Evidence not found: $EvidencePath" }

    $manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json -AsHashtable -Depth 50
    $evidence = Get-Content -Path $EvidencePath -Raw | ConvertFrom-Json -AsHashtable -Depth 50

    $domain = [string]$evidence.domain
    if ([string]::IsNullOrWhiteSpace($domain)) { throw "Evidence file must include a top-level 'domain' key." }
    if (-not $evidence.Contains('data')) { throw "Evidence file must include a top-level 'data' key." }

    if (-not $manifest.domains) { $manifest.domains = [ordered]@{} }
    if (-not $manifest.domains.Contains($domain)) { $manifest.domains[$domain] = [ordered]@{} }

    # Namespace the imported data so it doesn't clobber machine-collected fields.
    $manifest.domains[$domain].manualImport = [ordered]@{
        source         = $Source
        importedAtUtc  = (Get-Date).ToUniversalTime().ToString('o')
        provenance     = if ($evidence.provenance) { $evidence.provenance } else { @{} }
        data           = $evidence.data
    }

    if (-not $manifest.run) { $manifest.run = [ordered]@{} }
    if (-not $manifest.run.manualImports) { $manifest.run.manualImports = @() }
    $manifest.run.manualImports = @($manifest.run.manualImports) + @([ordered]@{
        source         = $Source
        domain         = $domain
        importedAtUtc  = (Get-Date).ToUniversalTime().ToString('o')
        evidenceFile   = (Resolve-Path -Path $EvidencePath).Path
    })

    $target = if ($OutputPath) { $OutputPath } else { $ManifestPath }
    $manifest | ConvertTo-Json -Depth 50 | Set-Content -Path $target -Encoding UTF8

    return [ordered]@{
        status         = 'ok'
        manifestPath   = $target
        domain         = $domain
        source         = $Source
        importCount    = @($manifest.run.manualImports).Count
    }
}