Public/Get-QAFrameworkClusterTopology.ps1

function Get-QAFrameworkClusterTopology {
    <#
    .SYNOPSIS
        Describes the QAOps cluster from the QAFramework point of view.
    .DESCRIPTION
        Wraps Get-QAOpsBridge and Get-QAOpsCluster into the shape the QAFramework scheduler
        needs: the agents that may execute a test, the failover pairs, the lowest and
        highest DMA id, and the cluster-wide facts used by the legacy test filters.

        The orchestrator itself is deliberately not assumed to host a DataMiner: only
        bridges reporting HasDataMiner are eligible to run a test. Because those bridge
        properties are still being implemented in QAOps, the function degrades
        conservatively when they are absent:

          * HasDataMiner missing on every bridge -> every bridge is treated as an agent
          * DmaId missing -> an ordinal is assigned by bridge order
          * IsFailover missing -> the cluster is treated as non-failover,
                                                      which disables the failover phases
    .PARAMETER Bridge
        Pre-fetched bridges. When omitted, Get-QAOpsBridge is called.
    .PARAMETER Cluster
        Pre-fetched cluster information. When omitted, Get-QAOpsCluster is called.
    .PARAMETER ExcludeOrchestrator
        Never execute tests on the orchestrating bridge, even when it hosts a DataMiner.
    .EXAMPLE
        Get-QAFrameworkClusterTopology
    .OUTPUTS
        PSCustomObject describing the topology.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [object[]]$Bridge,
        [object]$Cluster,
        [switch]$ExcludeOrchestrator
    )

    if (-not $PSBoundParameters.ContainsKey('Bridge')) {
        if (-not (Get-Command -Name 'Get-QAOpsBridge' -ErrorAction SilentlyContinue)) {
            throw 'Get-QAOpsBridge is not available. Import the QAOps.PowerShell module before calling Get-QAFrameworkClusterTopology.'
        }

        $Bridge = @(Get-QAOpsBridge)
    }

    $bridges = @($Bridge | Where-Object { $null -ne $_ })

    if (-not $PSBoundParameters.ContainsKey('Cluster')) {
        $Cluster = $null
        if (Get-Command -Name 'Get-QAOpsCluster' -ErrorAction SilentlyContinue) {
            try { $Cluster = Get-QAOpsCluster }
            catch { Write-Verbose "Get-QAOpsCluster failed: $($_.Exception.Message). Continuing without cluster information." }
        }
    }

    $hasProperty = {
        param([object]$Object, [string]$Name)
        return ($null -ne $Object -and $null -ne ($Object.PSObject.Properties | Where-Object { $_.Name -eq $Name } | Select-Object -First 1))
    }

    # When no bridge reports HasDataMiner the property is simply not implemented yet.
    $hasDataMinerKnown = @($bridges | Where-Object { (& $hasProperty $_ 'HasDataMiner') -and $_.HasDataMiner }).Count -gt 0

    $agents = [System.Collections.Generic.List[object]]::new()
    $ordinal = 0

    foreach ($item in $bridges) {
        $isAgent = if ($hasDataMinerKnown) { [bool]$item.HasDataMiner } else { $true }

        if ($isAgent -and $ExcludeOrchestrator -and $item.IsOrchestrator) {
            $isAgent = $false
        }

        if (-not $isAgent) {
            Write-Verbose "Bridge '$($item.Id)' will not execute tests."
            continue
        }

        $ordinal++
        $dmaId = if ((& $hasProperty $item 'DmaId') -and $null -ne $item.DmaId) { [int]$item.DmaId } else { $ordinal }

        $agents.Add([pscustomobject]@{
                BridgeId                = $item.Id
                DisplayName             = if ($item.DisplayName) { $item.DisplayName } else { $item.Id }
                HostName                = $item.HostName
                DmaId                   = $dmaId
                DmaIdIsKnown            = ((& $hasProperty $item 'DmaId') -and $null -ne $item.DmaId)
                IsOrchestrator          = [bool]$item.IsOrchestrator
                IsSelf                  = [bool]$item.IsSelf
                IsFailover              = if (& $hasProperty $item 'IsFailover') { [bool]$item.IsFailover } else { $false }
                FailoverPartnerBridgeId = if (& $hasProperty $item 'FailoverPartnerBridgeId') { $item.FailoverPartnerBridgeId } else { $null }
                FailoverPartnerHostName = if (& $hasProperty $item 'FailoverPartnerHostName') { $item.FailoverPartnerHostName } else { $null }
                Labels                  = if ((& $hasProperty $item 'Labels') -and $item.Labels) { $item.Labels } else { @{} }
                TestPackageContentPath  = $item.TestPackageContentPath
                Bridge                  = $item
            })
    }

    # A failover pair normally has one QAOps Bridge: the standby DataMiner machine does not
    # run another bridge. The bridge hosting the pair is therefore sufficient to identify and
    # switch it; the partner host name is informational.
    $pairs = [System.Collections.Generic.List[object]]::new()
    $paired = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    foreach ($agent in $agents) {
        if (-not $agent.IsFailover) { continue }
        if ($paired.Contains($agent.BridgeId)) { continue }

        $partner = $null
        if ($agent.FailoverPartnerBridgeId) {
            $partner = $agents | Where-Object { $_.BridgeId -eq $agent.FailoverPartnerBridgeId } | Select-Object -First 1
        }

        [void]$paired.Add($agent.BridgeId)
        if ($partner) { [void]$paired.Add($partner.BridgeId) }

        $pairs.Add([pscustomobject]@{
                PairId  = if ($partner) { ('{0}|{1}' -f $agent.BridgeId, $partner.BridgeId) } else { $agent.BridgeId }
                Primary = $agent
                Partner = $partner
                PartnerHostName = $agent.FailoverPartnerHostName
            })
    }

    $dmaIds = @($agents | ForEach-Object { $_.DmaId } | Sort-Object)

    $dataMinerVersion = $null
    if ($Cluster -and $Cluster.DataMinerVersion) {
        $parsed = [version]::new()
        if ([version]::TryParse([string]$Cluster.DataMinerVersion, [ref]$parsed)) { $dataMinerVersion = $parsed }
        else { Write-Warning "Cluster reported an unparsable DataMiner version '$($Cluster.DataMinerVersion)'; version filtering is disabled." }
    }

    $solutionVersions = @{}
    if ($Cluster -and $Cluster.SolutionVersions) {
        if ($Cluster.SolutionVersions -is [System.Collections.IDictionary]) {
            foreach ($key in $Cluster.SolutionVersions.Keys) { $solutionVersions[$key] = $Cluster.SolutionVersions[$key] }
        }
        else {
            foreach ($entry in $Cluster.SolutionVersions.PSObject.Properties) { $solutionVersions[$entry.Name] = $entry.Value }
        }
    }

    return [pscustomobject]@{
        Name             = if ($Cluster) { $Cluster.Name } else { $null }
        Agents           = [object[]]$agents
        FailoverPairs    = [object[]]$pairs
        HasFailover      = ($pairs.Count -gt 0)
        LowestDmaId      = if ($dmaIds.Count -gt 0) { $dmaIds[0] } else { $null }
        HighestDmaId     = if ($dmaIds.Count -gt 0) { $dmaIds[-1] } else { $null }
        DataMinerVersion = $dataMinerVersion
        DbmsType         = if ($Cluster) { $Cluster.DbmsType } else { $null }
        IsCentralized    = if ($Cluster) { [bool]$Cluster.IsCentralized } else { $false }
        IsRedGreen       = if ($Cluster) { [bool]$Cluster.IsRedGreen } else { $false }
        Labels           = if ($Cluster -and $Cluster.Labels) { $Cluster.Labels } else { @{} }
        SolutionVersions = $solutionVersions
        IsClusterKnown   = ($null -ne $Cluster)
    }
}