Modules/Outputs/Templates/10-AsBuilt.ps1

function New-RangerAsBuiltDocumentControlSection {
    <#
    .SYNOPSIS
        Returns the document control section payload for an as-built report tier.
    .DESCRIPTION
        Appears at the top of each as-built tier. Records the document identity,
        revision, authors, and handoff status so a receiving team can verify
        provenance. Uses deployment past-tense framing.
    #>

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

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

    $summary   = Get-RangerManifestSummary -Manifest $Manifest
    $packageId = '{0}-as-built-{1}' -f (Get-RangerSafeName -Value $summary.ClusterName), (Get-RangerTimestamp)

    [ordered]@{
        heading = 'Document Control'
        type    = 'kv'
        rows    = @(
            @('Document Title',           "Azure Local As-Built Documentation — $($summary.ClusterName)"),
            @('Package ID',               $packageId),
            @('Report Tier',              $Tier),
            @('Revision',                 '1.0 (initial handoff)'),
            @('Classification',           'CONFIDENTIAL — CUSTOMER DELIVERABLE'),
            @('Prepared By',              'Azure Local Ranger v{0}' -f $Manifest.run.toolVersion),
            @('Prepared On',              $Manifest.run.endTimeUtc),
            @('Schema Version',           $Manifest.run.schemaVersion),
            @('Document Status',          'FINAL — AS-BUILT HANDOFF')
        )
    }
}

function New-RangerAsBuiltInstallationRegisterSection {
    <#
    .SYNOPSIS
        Bill-of-materials / installation register with one row per installed node.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $nodes = @($Manifest.domains.clusterNode.nodes)
    $rows  = @(
        foreach ($n in $nodes) {
            $name   = if ($n.name)  { [string]$n.name }  elseif ($n.NodeName) { [string]$n.NodeName } else { '—' }
            $fqdn   = if ($n.fqdn)  { [string]$n.fqdn }  else { '—' }
            $mfr    = if ($n.manufacturer) { [string]$n.manufacturer } else { '—' }
            $model  = if ($n.model) { [string]$n.model } else { '—' }
            $serial = if ($n.serialNumber) { [string]$n.serialNumber } elseif ($n.serial) { [string]$n.serial } else { 'Not recorded' }
            $bios   = if ($n.biosVersion)  { [string]$n.biosVersion }  else { 'Not recorded' }
            $os     = if ($n.osCaption)    { [string]$n.osCaption }    elseif ($n.operatingSystem) { [string]$n.operatingSystem } else { '—' }
            $build  = if ($n.osVersion)    { [string]$n.osVersion }    elseif ($n.osBuildNumber) { [string]$n.osBuildNumber } else { '—' }
            @($name, $fqdn, $mfr, $model, $serial, $bios, $os, $build)
        }
    )

    [ordered]@{
        heading = 'Installation Register (Bill of Materials)'
        type    = 'table'
        headers = @('Hostname', 'FQDN', 'Manufacturer', 'Model', 'Serial', 'BIOS at Deployment', 'OS Installed', 'OS Build')
        rows    = $rows
        caption = 'Each unit listed above was installed and commissioned as part of this deployment. Serial numbers are recorded as discovered at handoff; missing values indicate the collector could not access the field.'
    }
}

function New-RangerAsBuiltNodeConfigurationSection {
    <#
    .SYNOPSIS
        Per-node configuration record (CPU, memory, domain, BIOS) at deployment.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $nodes = @($Manifest.domains.clusterNode.nodes)
    $rows  = @(
        foreach ($n in $nodes) {
            $name = if ($n.name) { [string]$n.name } else { '—' }
            $cpu  = if ($null -ne $n.logicalProcessorCount) { [string]$n.logicalProcessorCount } elseif ($null -ne $n.processorCount) { [string]$n.processorCount } else { '—' }
            $mem  = if ($null -ne $n.totalMemoryGiB) { "{0} GiB" -f [math]::Round([double]$n.totalMemoryGiB, 0) } elseif ($null -ne $n.memoryGiB) { "{0} GiB" -f [math]::Round([double]$n.memoryGiB, 0) } else { '—' }
            $dom  = if ($n.domain) { [string]$n.domain } else { '—' }
            $bios = if ($n.biosVersion) { [string]$n.biosVersion } else { '—' }
            $state = if ($n.state) { [string]$n.state } else { '—' }
            @($name, $state, $cpu, $mem, $dom, $bios)
        }
    )

    [ordered]@{
        heading = 'Per-Node Configuration Record'
        type    = 'table'
        headers = @('Node', 'State at Handoff', 'Logical CPUs', 'Installed Memory', 'Domain Joined', 'BIOS Version')
        rows    = $rows
        caption = 'Each node was configured with the values above at the time of deployment.'
    }
}

function New-RangerAsBuiltNetworkAllocationSection {
    <#
    .SYNOPSIS
        Cluster network address allocation as configured at deployment.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $networks = @($Manifest.domains.clusterNode.networks)
    $rows = @(
        foreach ($net in $networks) {
            $name   = if ($net.name)         { [string]$net.name } else { '—' }
            $role   = switch ([string]$net.role) {
                '0'       { 'Disabled' }
                '1'       { 'Cluster + Client (Management)' }
                '2'       { 'Cluster Only (Storage)' }
                '3'       { 'Cluster + Client (Workload)' }
                default   { [string]$net.role }
            }
            $addr   = if ($net.address)      { [string]$net.address } else { '—' }
            $mask   = if ($net.addressMask)  { [string]$net.addressMask } else { '—' }
            $metric = if ($net.metric)       { [string]$net.metric } else { '—' }
            $state  = if ($net.state)        { [string]$net.state } else { '—' }
            @($name, $role, $addr, $mask, $metric, $state)
        }
    )

    [ordered]@{
        heading = 'Network Address Allocation Record'
        type    = 'table'
        headers = @('Cluster Network', 'Role', 'Network Address', 'Mask', 'Metric', 'State')
        rows    = $rows
        caption = 'Cluster networks were assigned and configured as recorded above during deployment.'
    }
}

function New-RangerAsBuiltStorageConfigurationSection {
    <#
    .SYNOPSIS
        Storage pools, virtual disks, and CSV layout as deployed.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $pools = @($Manifest.domains.storage.pools)
    $poolRows = @(
        foreach ($p in $pools) {
            $name   = if ($p.friendlyName) { [string]$p.friendlyName } elseif ($p.name) { [string]$p.name } else { '—' }
            $raw    = if ($null -ne $p.rawCapacityGiB)    { '{0} GiB' -f [math]::Round([double]$p.rawCapacityGiB, 0) } elseif ($null -ne $p.size) { '{0} GiB' -f [math]::Round([double]$p.size / 1GB, 0) } else { '—' }
            $usable = if ($null -ne $p.usableCapacityGiB) { '{0} GiB' -f [math]::Round([double]$p.usableCapacityGiB, 0) } else { '—' }
            $health = if ($p.healthStatus)  { [string]$p.healthStatus } else { '—' }
            @($name, $raw, $usable, $health)
        }
    )

    [ordered]@{
        heading = 'Storage Configuration Record'
        type    = 'table'
        headers = @('Storage Pool', 'Raw Capacity', 'Usable Capacity', 'Health at Handoff')
        rows    = $poolRows
        caption = 'Storage pools and their capacities were provisioned at deployment as shown. CSV and virtual-disk details are included in the delivery-registers workbook.'
    }
}

function New-RangerAsBuiltAzureIntegrationSection {
    <#
    .SYNOPSIS
        Azure integration: Arc registration, subscription, RG, extensions.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $az = $Manifest.domains.azureIntegration
    $reg = $Manifest.domains.clusterNode.cluster.registration

    $tenant = if ($reg.tenantId)       { [string]$reg.tenantId }       elseif ($az.context.tenantId)       { [string]$az.context.tenantId }       else { 'Not registered' }
    $sub    = if ($reg.subscriptionId) { [string]$reg.subscriptionId } elseif ($az.context.subscriptionId) { [string]$az.context.subscriptionId } else { 'Not registered' }
    $rg     = if ($reg.resourceGroup)  { [string]$reg.resourceGroup }  elseif ($az.context.resourceGroup)  { [string]$az.context.resourceGroup }  else { 'Not registered' }

    $rows = @(
        ,@('Tenant ID',              $tenant)
        ,@('Subscription ID',        $sub)
        ,@('Resource Group',         $rg)
        ,@('Arc-connected machines', [string](@($az.arcMachineDetail).Count))
        ,@('AKS clusters',           [string](@($az.aksClusters).Count))
        ,@('Azure Monitor Agents',   [string](@($Manifest.domains.monitoring.ama).Count))
        ,@('Backup items',           [string](@($az.backup.items).Count))
        ,@('ASR protected items',    [string](@($az.asr.protectedItems).Count))
    )

    [ordered]@{
        heading = 'Azure Integration Record'
        type    = 'kv'
        rows    = $rows
    }
}

function New-RangerAsBuiltIdentitySecuritySection {
    <#
    .SYNOPSIS
        Identity and security configuration captured at deployment.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $id = $Manifest.domains.identitySecurity
    $summary = Get-RangerManifestSummary -Manifest $Manifest

    $idMode  = if ($Manifest.topology.identityMode)  { [string]$Manifest.topology.identityMode } else { '—' }
    $adSite  = if ($id.activeDirectory.adSite)       { [string]$id.activeDirectory.adSite } else { 'Not collected' }
    $bl      = if ($id.security.bitlockerEnabled -eq $true) { 'Enabled at deployment' } elseif ($id.security.bitlockerEnabled -eq $false) { 'Disabled at deployment' } else { 'Not collected' }
    $wdac    = if ($id.security.wdacPolicy)          { [string]$id.security.wdacPolicy } else { 'Not collected' }

    $rows = @(
        ,@('Identity mode',                 $idMode)
        ,@('Active Directory site',         $adSite)
        ,@('Secured-Core nodes enrolled',   ("{0} of {1}" -f $id.summary.securedCoreNodes, $summary.NodeCount))
        ,@('BitLocker',                     $bl)
        ,@('WDAC policy',                   $wdac)
        ,@('Certificates tracked',          [string]@($id.posture.certificates).Count)
        ,@('Certificates expiring <90d',    [string]$id.summary.certificateExpiringWithin90Days)
        ,@('RBAC assignments at RG scope',  [string]@($id.rbacAssignments).Count)
    )

    [ordered]@{
        heading = 'Identity and Security Record'
        type    = 'kv'
        rows    = $rows
    }
}

function New-RangerAsBuiltValidationRecordSection {
    <#
    .SYNOPSIS
        Validation record — cluster validation report reference and collector pass/fail.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $summary = Get-RangerManifestSummary -Manifest $Manifest

    # Reference the cluster validation report file when recorded under updatePosture
    $validationFileHint = $null
    try {
        $reportName = $Manifest.domains.clusterNode.updatePosture.clusterAwareUpdating.lastTestClusterReport
        if ($reportName) { $validationFileHint = [string]$reportName }
    } catch { }

    $validationRef = if ($validationFileHint) { $validationFileHint } else { 'See cluster-validation report artifact (Test-Cluster output)' }
    $schemaVal     = if ($Manifest.run.schemaValidation.isValid) { 'Passed' } elseif ($null -eq $Manifest.run.schemaValidation.isValid) { 'Not recorded' } else { 'Failed' }

    $rows = @(
        ,@('Validation report',      $validationRef)
        ,@('Collectors run',         [string]$summary.TotalCollectors)
        ,@('Collectors successful',  [string]$summary.SuccessfulCollectors)
        ,@('Collectors partial',     [string]$summary.PartialCollectors)
        ,@('Collectors failed',      [string]$summary.FailedCollectors)
        ,@('Schema validation',      $schemaVal)
        ,@('Critical findings',      [string]$summary.FindingsBySeverity.critical)
        ,@('Warning findings',       [string]$summary.FindingsBySeverity.warning)
    )

    [ordered]@{
        heading = 'Validation Record'
        type    = 'kv'
        rows    = $rows
    }
}

function New-RangerAsBuiltDeviationsSection {
    <#
    .SYNOPSIS
        Known issues / deviations from design — sourced from critical+warning findings.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Manifest
    )

    $deviations = @($Manifest.findings | Where-Object { $_.severity -in @('critical', 'warning') } | Select-Object -First 20)
    if ($deviations.Count -eq 0) {
        return [ordered]@{
            heading = 'Known Issues and Deviations'
            body    = @('No critical or warning-level deviations were recorded at handoff.')
        }
    }

    $rows = @(
        foreach ($d in $deviations) {
            @(
                [string]$d.severity.ToUpperInvariant(),
                [string]($d.title ?? '—'),
                [string]($d.description ?? '—'),
                [string]($d.recommendation ?? 'Accepted as-built; to be remediated under operations.')
            )
        }
    )

    [ordered]@{
        heading = 'Known Issues and Deviations'
        type    = 'table'
        headers = @('Severity', 'Item', 'Deviation', 'Remediation Path')
        rows    = $rows
        caption = 'Deviations listed below were documented at handoff. Items are accepted as-built unless explicitly marked for follow-up remediation.'
    }
}

function New-RangerAsBuiltSignOffSection {
    [ordered]@{
        heading = 'Acceptance and Sign-Off'
        type    = 'sign-off'
    }
}