Modules/Core/30-ManifestAnalysis.ps1

function Invoke-RangerVmDistributionAnalysis {
    <#
    .SYNOPSIS
        v2.0.0 (#223): compute VM distribution balance across cluster nodes.
    .DESCRIPTION
        After Arc VM inventory is collected, count VMs per node, compute the
        coefficient of variation, and flag as unbalanced if CV > 0.3 or any
        node has > 2× the average.
    #>

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

    $nodes = @($Manifest.domains.clusterNode.nodes | Where-Object { $_ -and $_.name })
    $vms   = @($Manifest.domains.virtualMachines.inventory | Where-Object { $_ -and $_.hostNode })

    if ($nodes.Count -eq 0) {
        return [ordered]@{
            balanced = $true
            cv       = 0.0
            status   = 'pass'
            perNode  = @()
            message  = 'No nodes to analyse.'
        }
    }

    $perNode = @($nodes | ForEach-Object {
        $n = $_.name
        [ordered]@{
            node    = $n
            vmCount = @($vms | Where-Object { [string]$_.hostNode -eq $n }).Count
        }
    })

    $counts = @($perNode | ForEach-Object { [double]$_.vmCount })
    $mean   = if ($counts.Count -gt 0) { [double](($counts | Measure-Object -Average).Average) } else { 0.0 }

    if ($mean -eq 0 -or $counts.Count -le 1) {
        return [ordered]@{
            balanced = $true
            cv       = 0.0
            status   = 'pass'
            mean     = $mean
            perNode  = $perNode
            message  = if ($mean -eq 0) { 'Zero VMs — balanced by definition.' } else { 'Single-node cluster — balanced by definition.' }
        }
    }

    $variance = [double](($counts | ForEach-Object { ($_ - $mean) * ($_ - $mean) } | Measure-Object -Average).Average)
    $stdDev   = [math]::Sqrt($variance)
    $cv       = $stdDev / $mean
    $maxCount = [double](($counts | Measure-Object -Maximum).Maximum)
    $overload = $maxCount -gt (2.0 * $mean)

    $status = if ($cv -gt 0.3 -or $overload) { 'fail' }
              elseif ($cv -ge 0.2)           { 'warning' }
              else                            { 'pass' }

    $maxNode = @($perNode | Sort-Object { [int]$_.vmCount } -Descending | Select-Object -First 1)[0]

    [ordered]@{
        balanced = ($status -ne 'fail')
        cv       = [math]::Round($cv, 3)
        status   = $status
        mean     = [math]::Round($mean, 2)
        maxCount = [int]$maxCount
        overloadedNode = if ($overload -and $maxNode) { [string]$maxNode.node } else { $null }
        perNode  = $perNode
        message  = switch ($status) {
            'fail'    { "Imbalanced (CV=$([math]::Round($cv, 3))). $(if ($overload -and $maxNode) { "$($maxNode.node) is overloaded." } else { '' })" }
            'warning' { "Slightly imbalanced (CV=$([math]::Round($cv, 3)))." }
            default   { 'VMs distributed evenly across nodes.' }
        }
    }
}

function Invoke-RangerAgentVersionAnalysis {
    <#
    .SYNOPSIS
        v2.0.0 (#224): group cluster nodes by Arc agent version and OS version.
    .DESCRIPTION
        Returns agentVersionGroups, osVersionGroups, and an agentVersionDrift
        summary (uniqueVersions, latestVersion, maxBehind, status).
    #>

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

    $nodes = @($Manifest.domains.clusterNode.nodes | Where-Object { $_ -and $_.name })

    $agentGroups = @($nodes | Group-Object -Property { [string]$_.arcAgentVersion } | ForEach-Object {
        [ordered]@{
            version   = if ([string]::IsNullOrWhiteSpace($_.Name)) { 'unknown' } else { $_.Name }
            nodeCount = $_.Count
            nodeNames = @($_.Group | ForEach-Object { $_.name })
        }
    })

    $osGroups = @($nodes | Group-Object -Property { [string]($_.osSku ?? $_.osCaption) } | ForEach-Object {
        $first = @($_.Group)[0]
        [ordered]@{
            osSku     = if ([string]::IsNullOrWhiteSpace($_.Name)) { 'unknown' } else { $_.Name }
            version   = [string]$first.osVersion
            nodeCount = $_.Count
            nodeNames = @($_.Group | ForEach-Object { $_.name })
        }
    })

    # Determine "latest" by natural sort-descending of version strings (good enough
    # for dotted semver + build-number formats used by Arc agent).
    $versions = @($agentGroups | Where-Object { $_.version -ne 'unknown' } | ForEach-Object { $_.version } | Sort-Object -Descending)
    $latest   = if ($versions.Count -gt 0) { [string]$versions[0] } else { $null }

    # maxBehind = index in descending list of the oldest version in use.
    $maxBehind = 0
    foreach ($g in $agentGroups) {
        if ($g.version -eq 'unknown' -or -not $latest) { continue }
        $idx = [Array]::IndexOf($versions, [string]$g.version)
        if ($idx -gt $maxBehind) { $maxBehind = $idx }
    }

    $uniqueVersions = @($agentGroups | Where-Object { $_.version -ne 'unknown' }).Count
    $status = if ($uniqueVersions -le 1) { 'pass' }
              elseif ($maxBehind -eq 1)  { 'warning' }
              else                        { 'fail' }

    [ordered]@{
        agentVersionGroups = $agentGroups
        osVersionGroups    = $osGroups
        drift              = [ordered]@{
            uniqueVersions = $uniqueVersions
            latestVersion  = $latest
            maxBehind      = $maxBehind
            status         = $status
        }
    }
}

function Invoke-RangerCostLicensingAnalysis {
    <#
    .SYNOPSIS
        v2.0.0 (#222): compute Azure Hybrid Benefit adoption and cost/savings.
    .DESCRIPTION
        AHB for Azure Local is a cluster-level property. Reads
        softwareAssuranceProperties.softwareAssuranceStatus, multiplies physical
        cores against the public $10/core/month rate, and computes potential
        savings if AHB is not enabled. Does not override an existing costLicensing
        object already populated by a collector or fixture — merges summary fields
        in-place so the pricing reference date is always current.
    #>

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

    $existing = $null
    try { $existing = $Manifest.domains.azureIntegration.costLicensing } catch { }
    if ($existing -and $existing.summary -and $null -ne $existing.summary.totalPhysicalCores) {
        # Collector/fixture already populated — ensure pricing date is set.
        if (-not $existing.pricingReference) {
            $existing['pricingReference'] = [ordered]@{
                asOfDate = (Get-Date).ToString('yyyy-MM-dd')
                url      = 'https://azure.microsoft.com/en-us/pricing/details/azure-local/'
            }
        } elseif (-not $existing.pricingReference.asOfDate) {
            $existing.pricingReference.asOfDate = (Get-Date).ToString('yyyy-MM-dd')
        }
        return $existing
    }

    # Derive from cluster + hardware domains.
    $cluster  = $Manifest.domains.clusterNode.cluster
    $hardware = @($Manifest.domains.hardware.nodes)
    $costPerCore = 10.00

    $ahbEnabled = $false
    try {
        $saStatus = [string]$cluster.softwareAssuranceProperties.softwareAssuranceStatus
        $ahbEnabled = $saStatus -match '^(Enabled|enabled|True|1)$'
    } catch { }

    $totalCores = 0
    $perNode = New-Object System.Collections.ArrayList
    foreach ($h in $hardware) {
        $cores = 0
        foreach ($prop in @('physicalCoreCount','physicalCores','logicalCoreCount','processorCount')) {
            if ($null -ne $h.$prop) { $cores = [int]$h.$prop; break }
        }
        $totalCores += $cores
        [void]$perNode.Add([ordered]@{
            node             = [string]$h.node
            physicalCores    = $cores
            ahbEnabled       = $ahbEnabled
            monthlyCostUsd   = [double]($cores * $costPerCore)
            monthlySavingUsd = if ($ahbEnabled) { [double]($cores * $costPerCore) } else { 0.0 }
        })
    }

    $coresWithAhb    = if ($ahbEnabled) { $totalCores } else { 0 }
    $coresWithoutAhb = $totalCores - $coresWithAhb
    $currentCost     = [double]($totalCores * $costPerCore)
    $savings         = [double]($coresWithoutAhb * $costPerCore)
    $adoption        = if ($totalCores -gt 0) { [double][math]::Round(($coresWithAhb / $totalCores) * 100, 1) } else { 0.0 }

    [ordered]@{
        subscriptionName = [string]$Manifest.domains.azureIntegration.context.subscriptionId
        cluster          = [string]$cluster.name
        ahbStatus        = if ($ahbEnabled) { 'Enabled' } else { 'Disabled' }
        perNode          = @($perNode)
        summary          = [ordered]@{
            totalPhysicalCores         = $totalCores
            coresWithAhb               = $coresWithAhb
            coresWithoutAhb            = $coresWithoutAhb
            costPerCoreUsd             = $costPerCore
            currentMonthlyCostUsd      = $currentCost
            potentialMonthlySavingsUsd = $savings
            ahbAdoptionPct             = $adoption
            currency                   = 'USD'
        }
        pricingReference = [ordered]@{
            asOfDate = (Get-Date).ToString('yyyy-MM-dd')
            url      = 'https://azure.microsoft.com/en-us/pricing/details/azure-local/'
        }
    }
}

function Invoke-RangerManifestPostAnalysis {
    <#
    .SYNOPSIS
        v2.0.0: run all post-collection manifest analysis helpers in place.
    .DESCRIPTION
        After all collectors have populated the manifest, compute derived
        analyses (VM distribution balance #223, agent version drift #224, AHB
        cost/savings #222) and merge them into the appropriate domains.
        Idempotent — if a fixture already provided the values, they are kept.
    #>

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

    # #223 — VM distribution
    try {
        $vmSummary = $Manifest.domains.virtualMachines.summary
        if ($vmSummary -and $null -eq $vmSummary.vmDistributionBalanced) {
            $vmDist = Invoke-RangerVmDistributionAnalysis -Manifest $Manifest
            $vmSummary.vmDistribution          = @($vmDist.perNode)
            $vmSummary.vmDistributionBalanced  = [bool]$vmDist.balanced
            $vmSummary.vmDistributionCv        = [double]$vmDist.cv
            $vmSummary.vmDistributionStatus    = [string]$vmDist.status
        }
    } catch {
        Write-Warning "VM distribution analysis failed: $($_.Exception.Message)"
    }

    # #224 — agent version grouping
    try {
        $nodeSummary = $Manifest.domains.clusterNode.nodeSummary
        if ($nodeSummary -and $null -eq $nodeSummary.arcAgentVersionGroups) {
            $agentAnalysis = Invoke-RangerAgentVersionAnalysis -Manifest $Manifest
            $nodeSummary.arcAgentVersionGroups = @($agentAnalysis.agentVersionGroups)
            $nodeSummary.osVersionGroups       = @($agentAnalysis.osVersionGroups)
            $nodeSummary.agentVersionDrift     = $agentAnalysis.drift
        }
    } catch {
        Write-Warning "Agent version analysis failed: $($_.Exception.Message)"
    }

    # #222 — AHB cost/licensing
    try {
        if (-not $Manifest.domains.azureIntegration.costLicensing -or `
            -not $Manifest.domains.azureIntegration.costLicensing.summary -or `
            $null -eq $Manifest.domains.azureIntegration.costLicensing.summary.totalPhysicalCores) {
            $cost = Invoke-RangerCostLicensingAnalysis -Manifest $Manifest
            $Manifest.domains.azureIntegration.costLicensing = $cost
        } else {
            # Ensure pricing reference date is current.
            Invoke-RangerCostLicensingAnalysis -Manifest $Manifest | Out-Null
        }
    } catch {
        Write-Warning "Cost licensing analysis failed: $($_.Exception.Message)"
    }
}