AzStackHCIDNS/AzStackHci.DNS.Helpers.psm1

Import-LocalizedData -BindingVariable ldTxt -FileName AzStackHci.DNS.Strings.psd1

# This file contains a list of discreet tests that can be run against the environment
# Each test named Test-* is exported and discovered to be run by the user-facing function.
# The user uses Include and Exclude parameters to run specific tests. (this provides a consistent experience across validators)
# If tests have dependencies on other tests, or they should be run in a specific order, the pattern describe above should be removed.
function Test-ExternalDnsResolution
{
    <#
    .SYNOPSIS
        Validates external hostname resolution from all cluster nodes.
    .DESCRIPTION
        Tests that each configured DNS server on every cluster node can resolve an external hostname
        (default: microsoft.com). Uses Resolve-DnsName -DnsOnly against each DNS server independently.
        Retries up to 3 times with 5-second delays. Enables the DNS Client operational event log on
        the final retry attempt for diagnostic capture.
 
        If a web proxy is enabled (via netsh winhttp show advproxy), the test is skipped because the
        proxy may handle DNS resolution.
 
        Asserts: All DNS servers on all nodes can resolve the external hostname.
        Severity: CRITICAL - failure indicates broken outbound DNS which blocks deployment.
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node.
    .PARAMETER ExternalName
        The external hostname to resolve. Defaults to 'microsoft.com' if not specified.
        Exactly one name must be provided.
    #>

    [CmdletBinding()]
    param (
        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession,

        [string[]]
        $ExternalName
    )

    $SkipDnsWithProxy = $ldTxt.SkipDnsWithProxy
    # Test if proxy is enabled and return because proxy maybe doing dns resolution
    function IsProxyEnabled
    {
        $line1, $line2, $line3, $JsonLines = netsh winhttp show advproxy
        $proxy = $JsonLines | ConvertFrom-Json -ErrorAction SilentlyContinue
        [bool]$proxy.Proxy
    }

    if (IsProxyEnabled)
    {
        $testDnsServer = @{
            Resource  = $SkipDnsWithProxy
            Status    = 'SUCCESS'
            TimeStamp = [datetime]::UtcNow
            Source    = $ENV:COMPUTERNAME
            Detail    = $SkipDnsWithProxy -f $ENV:COMPUTERNAME
        }
    }
    else
    {
        if ([string]::IsNullOrEmpty($PSBoundParameters['ExternalName']))
        {
            Log-Info "No DNS target found, using microsoft.com as fall back" -ConsoleOut -Type Warning
            $ExternalName = @('microsoft.com')
        }
        if ($ExternalName.count -ne 1)
        {
            throw "Expected 1 System_Check_DNS_External_Hostname_Resolution, found $($ExternalName.count)"
        }

        $TestDNSResolutionParams = @{}
        if ($PSBoundParameters['PsSession'])
        {
            $TestDNSResolutionParams.Add('PsSession', $PsSession)
        }

        # Use the local $ExternalName so the microsoft.com fallback above is respected.
        # (PSBoundParameters is a binding-time snapshot and would miss the fallback assignment.)
        if ($ExternalName)
        {
            $TestDNSResolutionParams.Add('TargetName', $ExternalName)
        }

        $testDnsServer = TestDNSResolution @TestDNSResolutionParams
    }

    # Write result to verbose log
    $testDnsServer | Foreach-Object {
        Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } )
    }

    # Collect lightweight per-node results for aggregation
    $detailResults = @()
    $detailResults += $testDnsServer | Foreach-Object {
        New-LightweightResult `
            -Name 'AzStackHci_DNS_Test_External_Hostname_Resolution' `
            -Status $PsItem.Status `
            -Severity 'Critical' `
            -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" `
            -Source $PsItem.Source `
            -Resource $PsItem.Resource `
            -Detail $PsItem.Detail
    }

    return @(New-AggregatedTestResult `
        -TestName 'Test-ExternalDnsResolution' `
        -DisplayName 'External DNS Resolution' `
        -Description 'Validates external hostname resolution from each cluster node. Uses Resolve-DnsName -DnsOnly against all configured DNS servers to verify that the target hostname (default: microsoft.com) can be resolved. Tests each DNS server independently with up to 3 retries and 5-second delays between attempts. Enables DNS Client operational log on final retry for diagnostics.' `
        -DetailResults $detailResults `
        -ValidatorName 'DNS' `
        -ResourceType 'DNS' `
        -Remediation $ldTxt.ExternalDnsRemediation)
}

function Test-ActiveDirectoryDomainName
{
    <#
    .SYNOPSIS
        Validates Active Directory domain FQDN resolution from all cluster nodes.
    .DESCRIPTION
        Tests that each configured DNS server on every cluster node can resolve the Active Directory
        domain FQDN. Uses Resolve-DnsName -DnsOnly against each DNS server independently with up to
        3 retries and 5-second delays between attempts. Enables the DNS Client operational event log
        on the final retry for diagnostics.
 
        A resolvable AD domain name is required for domain join and cluster lifecycle operations.
 
        Asserts: All DNS servers on all nodes return a valid response for the domain FQDN.
        Severity: CRITICAL - failure blocks domain join.
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node.
    .PARAMETER DomainFQDN
        The Active Directory domain FQDN to resolve (e.g., 'contoso.local').
    #>

    [CmdletBinding()]
    param (
        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession,

        [string[]]
        $DomainFQDN
    )

    $TestDNSResolutionParams = @{}
    if ($PSBoundParameters['PsSession'])
    {
        $TestDNSResolutionParams.Add('PsSession', $PsSession)
    }

    if ($PSBoundParameters['DomainFQDN'])
    {
        $TestDNSResolutionParams.Add('TargetName', $DomainFQDN)
    }

    $testDnsServer = TestDNSResolution @TestDNSResolutionParams

    # Write result to verbose log
    $testDnsServer | Foreach-Object {
        Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } )
    }

    # Collect lightweight per-node results for aggregation
    $detailResults = @()
    $detailResults += $testDnsServer | Foreach-Object {
        New-LightweightResult `
            -Name 'AzStackHci_DNS_Test_ActiveDirectory_DomainName_Resolution' `
            -Status $PsItem.Status `
            -Severity 'Critical' `
            -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" `
            -Source $PsItem.Source `
            -Resource $PsItem.Resource `
            -Detail $PsItem.Detail
    }

    return @(New-AggregatedTestResult `
        -TestName 'Test-ActiveDirectoryDomainName' `
        -DisplayName 'Active Directory Domain Name Resolution' `
        -Description 'Validates that the Active Directory domain FQDN can be resolved from each cluster node. Queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries per server. A resolvable AD domain name is required for domain join and cluster operations.' `
        -DetailResults $detailResults `
        -ValidatorName 'DNS' `
        -ResourceType 'DNS' `
        -Remediation $ldTxt.DomainNameDnsRemediation)
}

function Test-IPMapForClusterNodes
{
    <#
    .SYNOPSIS
        Validates that DNS A records for cluster nodes resolve to their expected IP addresses.
    .DESCRIPTION
        For each node in the physicalNodesSettings, constructs the FQDN (nodeName.domainFQDN) and
        queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries and
        5-second delays. Compares the returned IP addresses against the expected management IP from
        the deployment answer file.
 
        Asserts: Each node FQDN resolves to its expected IP address on every DNS server.
        Severity: INFORMATIONAL - mismatches are reported but do not block deployment because
        DNS records may be created dynamically during domain join.
    .PARAMETER PhysicalMachineIpMap
        Hashtable mapping node hostnames to their expected management IP addresses.
    .PARAMETER DomainFQDN
        The Active Directory domain FQDN used to construct node FQDNs.
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node.
    #>

    [CmdletBinding()]
    param (
        [hashtable]
        $PhysicalMachineIpMap,

        [string]
        $DomainFQDN,

        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession
    )

    try {
        $TestDNSResolutionParams = @{}
        if ($PSBoundParameters['PsSession'])
        {
            $TestDNSResolutionParams.Add('PsSession', $PsSession)
        }

        $IpMap = @{}
        foreach ($key in $PhysicalMachineIpMap.Keys)
        {
            $fqdnKey = "$key.$DomainFQDN"
            $IpMap.Add($fqdnKey, $PhysicalMachineIpMap[$key])
        }
        $TestDNSResolutionParams.Add('IpMap', $IpMap)
        $result = TestDNSResolution @TestDNSResolutionParams

        $detailResults = @()
        foreach ($res in $result)
        {
            Log-Info $res.Detail -Type $(if ( $res.Status -eq 'FAILURE' ){ "Critical" } else { "INFO" } )
            $detailResults += New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_IPMap_For_Cluster_Nodes' `
                -Status $res.Status `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "$($res.Source)/$($res.Resource)" `
                -Source $res.Source `
                -Resource $res.Resource `
                -Detail $res.Detail
        }

        return @(New-AggregatedTestResult `
            -TestName 'Test-IPMapForClusterNodes' `
            -DisplayName 'IP Map For Cluster Nodes' `
            -Description 'Validates that DNS A records for each node in physicalNodesSettings resolve to the expected IP addresses. For each node, constructs the FQDN (nodeName.domainFQDN) and queries all configured DNS servers using Resolve-DnsName -DnsOnly. Compares returned IP addresses against the expected IP from the deployment answer file. Retries up to 3 times with 5-second delays.' `
            -DetailResults $detailResults `
            -ValidatorName 'DNS' `
            -ResourceType 'DNS' `
            -Remediation $ldTxt.IPMapRemediation)
    }
    catch {
        Log-Info "Test-IPMapForClusterNodes failed with error: $_" -Type WARNING
    }
}

function Test-IPMapForCNO
{
    <#
    .SYNOPSIS
        Validates that the required DNS A record for the cluster name object (CNO) is pre-registered
        and resolves to the reserved cluster IP, before the cluster is created.
    .DESCRIPTION
        This check only runs for local identity (ADLess) deployments that use an external DNS server.
        In that configuration the operator must pre-register the cluster name (CNO) DNS A record before
        deployment, because the cluster cannot register its own record against an external DNS server it
        does not own. (For AD-integrated DNS the cluster registers its own record at bring-online, and
        for local identity with internal DNS the DNS server does not yet exist during validation - in
        both of those cases the check is excluded upstream and never runs.)
 
        Resolves the cluster FQDN (ClusterName.DomainFQDN) on every configured DNS server and compares
        the returned A record against the reserved cluster IP (ClusterIP). The reserved IP is the first
        IP address of the infrastructure network range allocated for the system - the same address the
        platform reserves for the CNO during deployment. Nothing is derived from a heuristic.
 
        Because pre-registration is REQUIRED in this scenario, three outcomes are reported (all
        INFORMATIONAL severity, non-blocking per SDP Phase 1):
          - No record found -> FAILURE. The required pre-registered record is missing; left uncreated
            it makes deploy-time cluster DNS verification fail after the cluster is created.
          - Record matches -> SUCCESS. The pre-registered record points at the reserved cluster IP.
          - Record mismatch -> FAILURE. A stale or incorrect record points at the wrong IP; left in
            place it makes deploy-time cluster DNS verification fail with a hard-to-diagnose timeout.
 
        If the cluster name, DNS zone, or reserved cluster IP cannot be determined the test reports a
        FAILURE (rather than skipping), because in this scenario the record is required and cannot be
        validated without those inputs.
    .PARAMETER ClusterName
        The cluster name object (CNO) short name. The FQDN is built as ClusterName.DomainFQDN.
    .PARAMETER DomainFQDN
        The DNS zone name (local identity) used to build the cluster FQDN.
    .PARAMETER ClusterIP
        The IP address that Azure Local reserves for the cluster name (CNO) - the first IP address of
        the infrastructure network range allocated for the system.
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node.
    #>

    [CmdletBinding()]
    param (
        [string]
        $ClusterName,

        [string]
        $DomainFQDN,

        [string]
        $ClusterIP,

        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession
    )

    try {
        $detailResults = @()
        $ipMapForCnoDescription = 'Validates that the required DNS A record for the cluster name object (CNO) is pre-registered and resolves to the reserved cluster IP before the cluster is created. This pre-registration is required for local identity deployments that use an external DNS server; a missing record or a record pointing at the wrong IP is reported so it can be corrected before it fails deploy-time cluster DNS verification.'

        # In the only scenario where this test runs (local identity + external DNS) the cluster name,
        # DNS zone, and reserved cluster IP are all required to validate the mandatory pre-registration.
        # If any is missing we cannot validate the required record, so report a FAILURE rather than skip.
        if ([string]::IsNullOrWhiteSpace($ClusterName) -or [string]::IsNullOrWhiteSpace($DomainFQDN) -or [string]::IsNullOrWhiteSpace($ClusterIP))
        {
            $inputDetail = $ldTxt.IPMapForCNOInputMissing
            Log-Info $inputDetail -Type Critical
            $detailResults += New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' `
                -Status 'FAILURE' `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "CNO/$ClusterName" `
                -Source 'CNO' `
                -Resource $ClusterName `
                -Detail $inputDetail

            return @(New-AggregatedTestResult `
                -TestName 'Test-IPMapForCNO' `
                -DisplayName 'IP Map For CNO' `
                -Description $ipMapForCnoDescription `
                -DetailResults $detailResults `
                -ValidatorName 'DNS' `
                -ResourceType 'DNS' `
                -Remediation $ldTxt.IPMapForCNORemediation)
        }

        $clusterFqdn = "$ClusterName.$DomainFQDN"
        $IpMap = @{ $clusterFqdn = $ClusterIP }

        $TestDNSResolutionParams = @{ IpMap = $IpMap }
        if ($PSBoundParameters['PsSession'])
        {
            $TestDNSResolutionParams.Add('PsSession', $PsSession)
        }
        $result = TestDNSResolution @TestDNSResolutionParams

        foreach ($res in $result)
        {
            # Local identity + external DNS REQUIRES the CNO record to be pre-registered and correct:
            # FAILURE + a record was returned -> stale/incorrect record (wrong IP) -> FAILURE
            # FAILURE + no record was returned -> required record missing -> FAILURE
            # SUCCESS -> record present and correct -> SUCCESS
            $status = $res.Status
            $detail = $res.Detail
            if ($res.Status -eq 'FAILURE')
            {
                if (-not [string]::IsNullOrWhiteSpace($res.ResolvedIps))
                {
                    $status = 'FAILURE'
                    $detail = $ldTxt.IPMapForCNOMismatch -f $clusterFqdn, $res.Resource, $res.ResolvedIps, $ClusterIP
                }
                else
                {
                    $status = 'FAILURE'
                    $detail = $ldTxt.IPMapForCNOMissing -f $clusterFqdn, $res.Resource, $ClusterIP
                }
            }
            else
            {
                $detail = $ldTxt.IPMapForCNOMatch -f $clusterFqdn, $res.Resource, $ClusterIP
            }

            Log-Info $detail -Type $(if ( $status -eq 'FAILURE' ){ "Critical" } else { "INFO" } )
            $detailResults += New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' `
                -Status $status `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "$($res.Source)/$($res.Resource)" `
                -Source $res.Source `
                -Resource $res.Resource `
                -Detail $detail
        }

        return @(New-AggregatedTestResult `
            -TestName 'Test-IPMapForCNO' `
            -DisplayName 'IP Map For CNO' `
            -Description $ipMapForCnoDescription `
            -DetailResults $detailResults `
            -ValidatorName 'DNS' `
            -ResourceType 'DNS' `
            -Remediation $ldTxt.IPMapForCNORemediation)
    }
    catch {
        # A thrown error must not look like "passed". Emit an explicit FAILURE aggregated result
        # (INFORMATIONAL severity, SDP Phase 1) so the missing validation is visible and actionable.
        $errorDetail = $ldTxt.IPMapForCNOUnexpectedError -f $_.Exception.Message
        Log-Info $errorDetail -Type Critical
        $errorResult = New-LightweightResult `
            -Name 'AzStackHci_DNS_Test_IPMap_For_CNO' `
            -Status 'FAILURE' `
            -Severity 'INFORMATIONAL' `
            -TargetResourceName "CNO/$ClusterName" `
            -Source 'CNO' `
            -Resource $ClusterName `
            -Detail $errorDetail
        return @(New-AggregatedTestResult `
            -TestName 'Test-IPMapForCNO' `
            -DisplayName 'IP Map For CNO' `
            -Description 'Validates that the required DNS A record for the cluster name object (CNO) is pre-registered and resolves to the reserved cluster IP before the cluster is created.' `
            -DetailResults @($errorResult) `
            -ValidatorName 'DNS' `
            -ResourceType 'DNS' `
            -Remediation $ldTxt.IPMapForCNORemediation)
    }
}

function Test-LocalClusterDNSResolution
{
    <#
    .SYNOPSIS
        Validates that all cluster node names and the cluster name are resolvable in DNS.
    .DESCRIPTION
        Constructs FQDNs for each node (nodeName.domainFqdn) and the cluster (clusterName.domainFqdn),
        then queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries
        and 5-second delays. All names must be resolvable for cluster formation and lifecycle operations.
 
        If resolution fails and the environment uses Active Directory (not LocalIdentity), a secondary
        test checks for AD-integrated DNS zones via LDAP (TestAdIntegratedDns). If AD-integrated DNS
        is present, the local resolution failure is downgraded to INFORMATIONAL because cluster names
        will be dynamically registered during domain join.
 
        Asserts: Every DNS server can resolve all node FQDNs and the cluster FQDN.
        Severity: CRITICAL (downgraded to INFORMATIONAL if AD-integrated DNS passes).
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node.
    .PARAMETER PhysicalMachineNames
        Array of node hostnames (short names without domain suffix).
    .PARAMETER ClusterName
        The cluster name (short name without domain suffix).
    .PARAMETER DomainFqdn
        The Active Directory domain FQDN for constructing fully-qualified names.
    .PARAMETER DomainCredential
        Credential for AD-integrated DNS zone lookup (used in fallback test).
    .PARAMETER IsLocalIdentityEnvironment
        Switch indicating an AD-less (LocalIdentity) deployment. When set, the AD-integrated
        DNS fallback test is skipped and failures remain CRITICAL.
    #>

    [CmdletBinding()]
    param (
        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession,

        [string[]]
        $PhysicalMachineNames,

        [string]
        $ClusterName,

        # AD integration parameters
        [Parameter()]
        [System.String]
        $DomainFqdn,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        $DomainCredential,

        [Parameter()]
        [switch]
        $IsLocalIdentityEnvironment
    )

    $TestDNSResolutionParams = @{}
    $severity = 'Critical'
    if ($PSBoundParameters['PsSession'])
    {
        $TestDNSResolutionParams.Add('PsSession', $PsSession)
    }

    # Add fully qualified cluster and node names to resolve.
    # Cluster name is already fully qualified, so just add it to the list.
    $targetNames = @()
    $targetNames += $PhysicalMachineNames | ForEach-Object { "$_.$DomainFqdn" }
    $targetNames += "$ClusterName.$DomainFqdn"
    $TestDNSResolutionParams.Add('TargetName', $targetNames)

    $testDnsServer = TestDNSResolution @TestDNSResolutionParams

    # Write result to verbose log
    $testDnsServer | Foreach-Object {
        Log-Info $_.Detail -Type $(if ( $_.Status -eq 'FAILURE' ){ $severity } else { "INFO" } )
    }

    # Collect lightweight per-node results for aggregation
    $detailResults = @()
    $detailResults += $testDnsServer | Foreach-Object {
        New-LightweightResult `
            -Name 'AzStackHci_DNS_Test_Local_Cluster_DNS_Resolution' `
            -Status $PsItem.Status `
            -Severity $severity `
            -TargetResourceName "$($PsItem.Source)/$($PsItem.Resource)" `
            -Source $PsItem.Source `
            -Resource $PsItem.Resource `
            -Detail $PsItem.Detail
    }

    $LocalClusterDNSResult = @(New-AggregatedTestResult `
        -TestName 'Test-LocalClusterDNSResolution' `
        -DisplayName 'Local Cluster DNS Resolution' `
        -Description 'Validates that each DNS server can resolve all cluster node names and the cluster name. Constructs FQDNs for each node (nodeName.domainFqdn) and the cluster (clusterName.domainFqdn), then queries all configured DNS servers using Resolve-DnsName -DnsOnly with up to 3 retries and 5-second delays. All names must be resolvable for cluster formation and lifecycle operations.' `
        -DetailResults $detailResults `
        -ValidatorName 'DNS' `
        -ResourceType 'DNS' `
        -Remediation $ldTxt.LocalClusterDnsRemediation)

    # Check if the test failed and if so, check if AD integration test parameters are set
    # If AD integration test parameters are set, run the AD integrated DNS test
    # If the AD integrated DNS test fails, return the result of the AD integrated DNS test (critical) and the local cluster DNS test (critical)
    # If the AD integrated DNS test passes, return the result of the non AD integrated DNS test
    if ('FAILURE' -in $LocalClusterDNSResult.Status)
    {
        Log-Info -Message "Local Cluster member DNS resolution test failed. Checking if AD integration test parameters are set." -Type $severity

        # AD deployment's have DomainCredential and DomainFqdn set, AD-less deployments do not have these parameters set.
        if (-not $PSBoundParameters['IsLocalIdentityEnvironment'])
        {
            Log-Info -Message "AD integration test parameters are set. Running AD integrated DNS test." -Type $severity
            $adIntDnsResult = TestAdIntegratedDns -DomainFqdn $DomainFqdn -DomainCredential $DomainCredential

            # both tests have failed return both results
            if ($adIntDnsResult.Status -eq 'FAILURE')
            {
                Log-Info -Message "AD integrated DNS test failed. Returning both local resolution and AD integrated together" -Type $severity
                return ($LocalClusterDNSResult + $adIntDnsResult)
            }
            else
            {
                # This is happy path AD deployment has AD integrated DNS.
                Log-Info -Message "AD integrated DNS test passed. We don't need to block the life cycle operation. Returning AD Integrated DNS test result and downgrading A record test to informational."
                $LocalClusterDNSResult | ForEach-Object {
                    $_.Severity = 'INFORMATIONAL'
                    if ($_.AdditionalData -and $_.AdditionalData.ContainsKey('Detail')) { $_.AdditionalData['Detail'] += "`n[Severity downgraded from CRITICAL to INFORMATIONAL: AD Integrated DNS passed, cluster names will be dynamically registered]" }
                }
                return ($LocalClusterDNSResult + $adIntDnsResult)
            }
        }
        else
        {
            Log-Info -Message "AD integration test parameters are not set. Local Cluster member DNS resolution test failed. This will block lifecycle operation." -Type $severity
            return $LocalClusterDNSResult
        }
    }
    else
    {
        Log-info -Message "Local Cluster member DNS resolution test passed. Returning."
        return $LocalClusterDNSResult
    }
}

function TestDNSResolution
{
    <#
    .SYNOPSIS
        Test DNS Resolution of a target name by testing each dns server configured on any remote machine.
    .DESCRIPTION
        This function will test the DNS resolution of a target name by testing each DNS server configured on the machine. It will return the status of each DNS server and the result of the DNS query.
        It will also enable the DNS client operational log to get the logs for last attempt during failures. It will then disable the log if it was not enabled in the first place.
        It's intended to be a reusable function to facilitate the testing of DNS resolution in a consistent manner across types of names internal/external/node/cluster etc.
 
        When -DnsServer is supplied, the per-target query is sent only to those server IPs and the
        local Get-DnsClientServerAddress enumeration is skipped. This lets callers target specific
        authoritative name servers (for example, the cluster DNS posture probe) while still reusing
        the retry, -QuickTimeout, and DNS Client Operational Log capture behaviour of this helper.
    .PARAMETER TargetName
        One or more DNS names to resolve. May also be supplied via $IpMap keys.
    .PARAMETER IpMap
        Optional hashtable of TargetName -> expected IPv4. When supplied, a returned record is only
        SUCCESS when the observed IP set contains the expected IP for that target.
    .PARAMETER PsSession
        Optional remoting sessions to fan the resolution out across. When omitted, runs locally.
    .PARAMETER DnsServer
        Optional explicit list of DNS server IPs to query. When supplied with one or more entries,
        the helper queries only these servers and skips the local-resolver enumeration. When $null
        or empty, behaviour is unchanged: the helper enumerates DNS servers via Get-NetAdapter +
        Get-DnsClientServerAddress on the node executing the probe.
    #>

    [CmdletBinding()]
    param (
        [string[]]
        $TargetName,

        [hashtable]
        $IpMap,

        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession,

        [string[]]
        $DnsServer
    )

    try {
        # scriptblock to test dns resolution for each dns server
        $testDnsSb = {
            $TargetName = $args[0] -split ' '
            $IpMap = $args[1]

            # Pass in localized strings
            $NoDnsConfigured = $args[2]
            $QueryDnsFail = $args[3]
            $QueryDnsPass = $args[4]
            $ExpectedDnsResultPass = $args[5]
            $ExpectedDnsResultFail = $args[6]

            # Optional caller-supplied DNS server override. When non-empty we skip the local
            # Get-DnsClientServerAddress enumeration and use these servers instead. This is the
            # reuse seam for callers that need to target specific authoritative name servers
            # (for example Test-ClusterDnsPosture) without re-implementing the retry / QuickTimeout /
            # DNS Client Operational Log capture wrapper around Resolve-DnsName.
            $SuppliedDnsServers = $args[7]

            $AdditionalData = @()

            # DNS servers: prefer caller-supplied list, else enumerate local resolvers
            $dnsServers = @()
            if ($SuppliedDnsServers -and @($SuppliedDnsServers).Count -gt 0)
            {
                $dnsServers = @($SuppliedDnsServers | Where-Object { $_ } | Sort-Object -Unique)
            }
            else
            {
                $netAdapter = Get-NetAdapter | Where-Object Status -EQ Up
                $dnsServer = Get-DnsClientServerAddress -InterfaceIndex $netAdapter.ifIndex -AddressFamily IPv4
                $dnsServers += $dnsServer | ForEach-Object { $PSITEM.Address } | Sort-Object | Get-Unique
            }

            # set target name to ipmap key if applicable
            $UseMap = ![string]::IsNullOrEmpty($IpMap)
            if ($UseMap)
            {
                $TargetName = $IpMap.Keys
            }

            if (-not $dnsServers)
            {
                return @{
                    Resource  = $NoDnsConfigured
                    Status    = 'FAILURE'
                    TimeStamp = [datetime]::UtcNow
                    Source    = $ENV:COMPUTERNAME
                    Detail    = $NoDnsConfigured
                }
            }
            else
            {
                foreach ($target in $TargetName)
                {
                    foreach ($dnsServer in $dnsServers)
                    {
                        $status = 'FAILURE'
                        $attempt = 0
                        $maxRetry = 3
                        $sleepInSeconds = 5
                        $dnsLogNeedsToBeDisabled = $false
                        $dnsLogAlreadyEnabled = $false
                        $preDnsTimeStamp = $null
                        while ($attempt -lt $maxRetry -and $status -eq 'FAILURE')
                        {
                            try
                            {
                                $attempt++
                                $dnsFailure = $null
                                $dnsResult = $null
                                # if this is the last attempt, check if the DNS client operational log is enabled, enabled it and reads the log for debug information
                                if ($attempt -eq ($maxRetry - 1))
                                {
                                    $wevtutilglcmd = "& wevtutil gl Microsoft-Windows-DNS-Client/Operational"
                                    $dnsLogState = Invoke-Expression -command $wevtutilglcmd | select-string -pattern "enabled: (.*)"
                                    if ($dnsLogState -like '*false')
                                    {
                                        # enable the dns log for 5 minutes to get the logs and set the size to 10MB
                                        & wevtutil sl Microsoft-Windows-DNS-Client/Operational /e:true /ms:10485760
                                        $dnsLogNeedsToBeDisabled = $true
                                    }
                                    else
                                    {
                                        $dnsLogAlreadyEnabled = $true
                                    }
                                    $preDnsTimeStamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffZ" -AsUTC
                                }
                                $dnsResult = Resolve-DnsName -Name $target -Server $dnsServer -DnsOnly -ErrorAction SilentlyContinue -QuickTimeout -Type A
                            }
                            catch {
                                $dnsFailure = $_.Exception.Message
                            }

                            if ([int]($dnsResult.count) -eq 0)
                            {
                                $detail = $QueryDnsFail -f $dnsServer, $target, $ENV:COMPUTERNAME, "$attempt/$maxRetry", [int]($dnsResult.count), $dnsFailure
                            }
                            else
                            {
                                $detail = $QueryDnsPass -f $dnsServer, $target, $ENV:COMPUTERNAME, "$attempt/$maxRetry", [int]($dnsResult.count), ($dnsResult.IpAddress -join ',')
                            }

                            if ($dnsResult)
                            {
                                if ($dnsResult[0] -is [Microsoft.DnsClient.Commands.DnsRecord] -or $dnsResult[0].PSObject.Properties['IPAddress'])
                                {
                                    # If there is no IpMap provided, consider any valid response as success
                                    if (!$UseMap)
                                    {
                                        $status = 'SUCCESS'
                                        break
                                    }
                                    else
                                    {
                                        # If there the IpMap is provided but we don't have an expected IP for this target, consider any valid response as success
                                        # When we do have an expected IP for this target, check if the returned IPs contain the expected IP
                                        if ( -not $IpMap.ContainsKey($target) )
                                        {
                                            $status = 'SUCCESS'
                                            break
                                        }
                                        else
                                        {
                                            # Check if the returned IPs contain the expected IP
                                            if ($dnsResult.IpAddress -contains $IpMap[$target])
                                            {
                                                $status = 'SUCCESS'
                                                $detail = "`r`n" + ($ExpectedDnsResultPass -f $target, $ENV:COMPUTERNAME, $IpMap[$target], ($dnsResult.IpAddress -join ','), $dnsServer, "$attempt/$maxRetry")
                                                break
                                            }
                                            else
                                            {
                                                $status = 'FAILURE'
                                                $detail = "`r`n" + ($ExpectedDnsResultFail -f $target, $ENV:COMPUTERNAME, $IpMap[$target], ($dnsResult.IpAddress -join ','), $dnsServer, "$attempt/$maxRetry")
                                            }
                                        }
                                    }
                                }
                                else
                                {
                                    $status = 'FAILURE'
                                }
                            }
                            else
                            {
                                $status = 'FAILURE'
                            }
                            Start-Sleep -Second $sleepInSeconds
                        }

                        # If the dns log is enabled get the dns logs for this domain
                        if ($dnsLogNeedsToBeDisabled -eq $true -or $dnsLogAlreadyEnabled)
                        {
                            $xPathFilter = "*[System[Provider[@Name='Microsoft-Windows-DNS-Client']]] and *[EventData[Data[@Name='QueryName']='$target']] and *[System[TimeCreated[@SystemTime > '$preDnsTimeStamp']]]"
                            $dnsEvents = Get-WinEvent -LogName "Microsoft-Windows-DNS-Client/Operational" -FilterXPath $xPathFilter -ErrorAction SilentlyContinue | sort-object TimeCreated | Foreach-Object { "[{0}] [{1}] - {2}" -f $_.TimeCreated, $_.LevelDisplayName, $_.Message}
                            if ($dnsEvents)
                            {
                                $detail += "`n`nDNS Client Operational Log:`n$($dnsEvents -join "`r`n" | Out-String)"
                            }
                            # Disable the DNS client operational log if it was enabled in the first place
                            if ($dnsLogNeedsToBeDisabled -eq $true)
                            {
                                $wevtutilslCmd = "& wevtutil sl Microsoft-Windows-DNS-Client/Operational /e:false"
                                Invoke-Expression -Command $wevtutilslCmd
                            }
                        }

                        $resolvedIps = if ($status -eq 'SUCCESS' -and $dnsResult) { $dnsResult.IpAddress -join ',' } else { $null }
                        # RecordIps captures the resolved A record(s) regardless of SUCCESS/FAILURE so callers can
                        # distinguish "no record returned" from "record returned but IP mismatched" without parsing Detail.
                        $recordIps = if ($dnsResult -and $dnsResult.IpAddress) { $dnsResult.IpAddress -join ',' } else { $null }
                        $AdditionalData += @{
                            Resource    = $dnsServer
                            Status      = $status
                            TimeStamp   = [datetime]::UtcNow
                            Source      = $ENV:COMPUTERNAME
                            Detail      = if ($resolvedIps) { "$detail`nIPs: $resolvedIps" } else { $detail }
                            ResolvedIps = $recordIps
                        }
                    }
                }

            }
            $AdditionalData
        }

        # run scriptblock
        $dnsargs = @{
            ArgumentList = $TargetName, $IpMap, $ldTxt.NoDnsConfigured, $ldTxt.QueryDnsFail, $ldTxt.QueryDnsPass, $ldTxt.ExpectedDnsResultPass, $ldTxt.ExpectedDnsResultFail, $DnsServer
        }
        $testDnsServer = if ($PsSession)
        {
            Invoke-Command -Session $PsSession -ScriptBlock $testDnsSb @dnsArgs
        }
        else
        {
            Invoke-Command -ScriptBlock $testDnsSb @dnsArgs
        }
        return $testDnsServer
    }
    catch
    {
        throw "Failed to run TestDNSResolution: $($_.Exception.Message)"
    }
}

function Get-LdapDomain {
    [CmdletBinding()]
    [OutputType([System.String])]
    Param (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Domain
    )

    $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop

    $ldapDomain = ($Domain.Split('.') | ForEach-Object {"DC=$_"}) -join ','

    return $ldapDomain
}

function TestAdIntegratedDns {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        [Parameter(Mandatory = $true)]
        [System.String]
        $DomainFqdn,

        [Parameter(Mandatory = $true)]
        [System.Management.Automation.PSCredential]
        $DomainCredential
    )

    try {
        $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop
        $severity = 'Critical'
        Log-Info -Message 'Importing module ActiveDirectory.'
        Import-Module ActiveDirectory -Verbose:$false

        # Convert domain name to fully qualified LDAP name
        $ldapDomain = Get-LdapDomain -Domain $DomainFqdn
        Log-Info -Message "LdapDomain to search is '$ldapDomain'."

        # Get forest root domain so we can search for forest zones as well as domain zones
        $rootDomain = Get-LdapDomain -Domain (Get-ADForest -Credential $DomainCredential -Server $DomainFqdn).RootDomain
        Log-Info -Message "RootDomain to search is '$rootDomain'."

        # Build list of locations to search - including legacy Windows 2000 locations
        $searchBases = @(
            "DC=$DomainFqdn,CN=MicrosoftDNS,DC=DomainDnsZones,$ldapDomain"
            "DC=$DomainFqdn,CN=MicrosoftDNS,CN=System,$ldapDomain"
            "DC=$DomainFqdn,CN=MicrosoftDNS,DC=ForestDnsZones,$rootDomain"
            "DC=$DomainFqdn,CN=MicrosoftDNS,CN=System,$rootDomain"
        )

        $result = $false

        # Search each location until one is found
        foreach ($searchBase in $searchBases) {
            if (-not $result) {
                Log-Info -Message "Searching for DNS zone container '$searchBase'."
                try {
                    $dnsZone = Get-ADObject -SearchBase $searchBase -LDAPFilter '(objectClass=dnsZone)' -Properties @('dnsProperty') -Credential $DomainCredential -Server $DomainFqdn
                    Log-Info -Message "Found DNS zone container '$searchBase'."
                    $result = $true
                }
                catch {
                    Log-Info -Message "DNS zone container '$searchBase' does not exist."
                }
            }
        }

        # Write to log file and set status
        if ($result) {
            $dtl = $ldTxt.AdIntDnsPass -f $rootDomain, $dnsZone.DistinguishedName
            Log-Info -Message $dtl
            $Status = 'SUCCESS'
        }
        else {
            $dtl = $ldTxt.AdIntDnsFail -f $rootDomain
            Log-Info -Message $dtl -Type $severity
            $Status = 'FAILURE'
        }

        # Write result
        $now = [datetime]::UtcNow
        $params = @{
            Name               = 'AzStackHci_DNS_Test_ActiveDirectory_Integrated_DNS'
            Title              = 'Test Active Directory Integrated DNS'
            DisplayName        = 'Test Active Directory Integrated DNS'
            Severity           = $severity
            Description        = 'Test Active Directory Integrated DNS'
            Tags               = @{
                Service        = 'System'
            }
            Remediation        = $ldTxt.AdIntDnsRemediation
            TargetResourceID   = "$ENV:COMPUTERNAME/$rootDomain"
            TargetResourceName = $rootDomain
            TargetResourceType = 'DNS'
            Timestamp          = $now
            Status             = $Status
            AdditionalData     = @{
                Resource = $rootDomain
                Source   = $ENV:COMPUTERNAME
                Status   = $Status
                Detail  = $dtl
            }
            HealthCheckSource  = $ENV:EnvChkrId
        }
        return (New-AzStackHciResultObject @params)
    }
    catch {
        throw "Error checking Active Directory Integrated DNS: $_"
    }
}

function Get-ClusterDnsPostureVerdict
{
    <#
    .SYNOPSIS
        Pure function that maps a probe-fact hashtable to one of the 13 verdict codes.
    .DESCRIPTION
        Implements the decision tree from the Test-ClusterDnsPosture design doc (Section 5).
        Kept as a separate function so the verdict logic is independently unit-testable
        without needing to mock Resolve-DnsName end-to-end.
    .PARAMETER Probe
        Hashtable carrying probe outcomes: SoaResolved (bool), AllNsUnreachable (bool),
        ExistingRecordPresent (bool), RdataMatchesClusterIp ('True'/'False'/'unknown'),
        AllNsReplicaParity (bool), ZoneIsADIntegrated (bool).
    .PARAMETER IsLocalIdentity
        True when this is an AD-less (LocalIdentity) deployment.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]$Probe,

        [Parameter(Mandatory = $false)]
        [bool]$IsLocalIdentity
    )

    if (-not $Probe.SoaResolved)   { return 'SKIP_NoZone' }
    if ($Probe.AllNsUnreachable)   { return 'AMBER_NsUnreachable' }

    if ($IsLocalIdentity)
    {
        # ADLess decision branch. With no DACL read, we use the AD-integrated zone
        # heuristic as a proxy for Secure-only policy. AD-integrated zones are the
        # path where Secure-only blocks anonymous DDNS; file-backed zones typically
        # allow Both / unauthenticated UPDATE.
        if ($Probe.ZoneIsADIntegrated)
        {
            return 'RED_AdLess_SecureOnly'
        }
        if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'True')
        {
            return 'GREEN_AdLess_IdempotentMatch'
        }
        if ($Probe.ExistingRecordPresent)
        {
            return 'AMBER_AdLess_ForeignOwner'
        }
        return 'GREEN_AdLess_WriteWillSucceed'
    }
    else
    {
        # AD-Joined decision branch
        if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'True')
        {
            return 'GREEN_AdJoined_PreStaged'
        }
        if ($Probe.ExistingRecordPresent -and $Probe.RdataMatchesClusterIp -eq 'False')
        {
            return 'RED_AdJoined_WrongIp'
        }
        if (-not $Probe.AllNsReplicaParity)
        {
            return 'AMBER_AdJoined_ReplicationDrift'
        }
        if ($Probe.ZoneIsADIntegrated)
        {
            return 'GREEN_AdJoined_FreshWrite'
        }
        return 'AMBER_AdJoined_FileBackedZone'
    }
}

function Test-ClusterDnsPosture
{
    <#
    .SYNOPSIS
        Read-only probe of authoritative DNS posture for the cluster CNO record.
    .DESCRIPTION
        Predicts whether the Failover Cluster CNO will be able to write its DNS A
        record at bring-online by querying the authoritative name servers for the
        deployment-target zone. Distinguishes the AD-Joined Kerberos identity path
        from the ADLess anonymous DDNS path.
 
        Three stages, all read-only:
          1) Resolve SOA + NS list for the zone.
          2) Per authoritative NS: resolve NS IP, then query the cluster FQDN A record
             directly against that NS.
          3) Infer posture (existence, RDATA, replica parity, AD-integrated heuristic)
             and compute one of 13 verdict codes.
 
        The function NEVER writes to DNS. The synthetic-write tool (Send-DnsUpdate)
        stays lab-only.
 
        Maps verdict codes to per-row Status:
          RED_* -> FAILURE
          AMBER_* -> WARNING
          GREEN_* / SKIP_* -> SUCCESS
 
        Severity is INFORMATIONAL for SDP Phase 1; promotion to CRITICAL/WARNING happens
        in a follow-up after 30+ days of telemetry confirms low false-positive rate.
    .PARAMETER PsSession
        PowerShell remoting sessions to each cluster node. When omitted, runs locally.
    .PARAMETER ClusterName
        The cluster short name (the CNO label) without domain suffix.
    .PARAMETER DomainFQDN
        The DNS zone name. For AD-Joined deployments this is the AD domain FQDN; for
        ADLess deployments this is the customer-provided DNS zone name.
    .PARAMETER IsLocalIdentityEnvironment
        Routes the verdict mapping into the ADLess (anonymous DDNS) decision branch.
    .PARAMETER ClusterIP
        Optional expected cluster IP. When supplied, the function compares observed
        RDATA against it. When omitted, RdataMatchesClusterIp is reported as 'unknown'
        and the AD-Joined branch cannot return RED_WrongIp / GREEN_PreStaged.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [System.Management.Automation.Runspaces.PSSession[]]
        $PsSession,

        [Parameter(Mandatory = $false)]
        [string]$ClusterName,

        [Parameter(Mandatory = $false)]
        [string]$DomainFQDN,

        [Parameter(Mandatory = $false)]
        [switch]$IsLocalIdentityEnvironment,

        [Parameter(Mandatory = $false)]
        [string]$ClusterIP
    )

    # SDP Phase 1: this posture probe is INFORMATIONAL and non-blocking. An unexpected internal
    # error must never break a deployment, so the whole body is guarded. On error we emit an
    # explicit result (mirroring Test-IPMapForCNO) so the failed probe is visible and actionable
    # rather than silently absent, and the caught exception is captured in telemetry to gate the
    # future INFORMATIONAL -> WARNING/CRITICAL promotion.
    try
    {
        $description = 'Read-only probe of the authoritative DNS server(s) for the deployment-target zone. Predicts whether the Failover Cluster CNO will be able to write its DNS A record at bring-online, distinguishing AD-Joined Kerberos identity and ADLess anonymous identity paths. Does not write to DNS.'
        $displayName = 'Cluster DNS Posture (CNO write feasibility)'

        # ---- Short-circuit: missing required inputs
        if ([string]::IsNullOrWhiteSpace($ClusterName) -or [string]::IsNullOrWhiteSpace($DomainFQDN))
        {
            $skipDetail = "Verdict : SKIP_MissingInput`nReason : $($ldTxt.ClusterDnsPosture_SKIP_MissingInput)`nClusterName : $(if ($ClusterName) { $ClusterName } else { '<missing>' })`nDomainFQDN : $(if ($DomainFQDN) { $DomainFQDN } else { '<missing>' })"
            Log-Info 'Test-ClusterDnsPosture: skipped (ClusterName or DomainFQDN not supplied)'
            $skipResult = New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' `
                -Status 'SUCCESS' `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "$env:COMPUTERNAME/SKIP" `
                -Source $env:COMPUTERNAME `
                -Resource 'DNS' `
                -Detail $skipDetail
            return @(New-AggregatedTestResult `
                -TestName 'Test-ClusterDnsPosture' `
                -DisplayName $displayName `
                -Description $description `
                -DetailResults @($skipResult) `
                -ValidatorName 'DNS' `
                -ResourceType 'DNS' `
                -Remediation $ldTxt.ClusterDnsPostureRemediation)
        }

        # Stage 1 probe scriptblock: SOA + NS list + NS IP resolution.
        # Runs via PsSession so zone resolution reflects the node's network perspective.
        # Returns a structured hashtable: Skip=$false with NsRows, or Skip=$true with SkipReason.
        $probeSb = {
            param($zoneName, $clusterShort)

            $clusterFqdn = "$clusterShort.$zoneName"
            $soaPrimary  = $null
            $soaResolved = $false

            try {
                $soa = Resolve-DnsName -Name $zoneName -Type SOA -DnsOnly -QuickTimeout -ErrorAction Stop
                $soaRecord = $soa | Select-Object -First 1
                if ($soaRecord.PrimaryServer) { $soaPrimary = $soaRecord.PrimaryServer }
                $soaResolved = $true
            } catch {
                $soaResolved = $false
            }

            if (-not $soaResolved) {
                return @{
                    Skip        = $true
                    Verdict     = 'SKIP_NoZone'
                    ZoneName    = $zoneName
                    ClusterFqdn = $clusterFqdn
                    Source      = $env:COMPUTERNAME
                    SkipReason  = 'SOA query failed for zone'
                }
            }

            $authoritativeNs = @()
            try {
                $nsRecords = Resolve-DnsName -Name $zoneName -Type NS -DnsOnly -QuickTimeout -ErrorAction Stop
                $authoritativeNs = @($nsRecords | ForEach-Object { $_.NameHost } | Where-Object { $_ } | Sort-Object -Unique)
            } catch {
                $authoritativeNs = @()
            }

            if ($authoritativeNs.Count -eq 0) {
                return @{
                    Skip        = $true
                    Verdict     = 'SKIP_NoZone'
                    ZoneName    = $zoneName
                    ClusterFqdn = $clusterFqdn
                    Source      = $env:COMPUTERNAME
                    SkipReason  = 'NS query returned no authoritative servers'
                }
            }

            # Cap the authoritative-NS fan-out. Replica parity across a handful of name
            # servers is sufficient signal, and this bounds the probe cost in large AD
            # domains (100+ DCs). Sort-Object above makes the selection deterministic.
            $maxNsToProbe = 5
            if ($authoritativeNs.Count -gt $maxNsToProbe) {
                $authoritativeNs = @($authoritativeNs | Select-Object -First $maxNsToProbe)
            }

            # NS IP resolution uses the node's local resolver; results become Stage 2 input.
            # QuickTimeout so stale NS glue cannot stall the probe.
            $nsRows = @()
            foreach ($ns in $authoritativeNs) {
                $row = @{ NameServer = $ns; NsIp = $null; Err = $null }
                try {
                    $nsA = Resolve-DnsName -Name $ns -Type A -DnsOnly -QuickTimeout -ErrorAction Stop
                    $nsIpRecord = $nsA | Where-Object { $_.IPAddress } | Select-Object -First 1
                    if ($nsIpRecord) { $row.NsIp = [string]$nsIpRecord.IPAddress }
                } catch {
                    $row.Err = "NS IP lookup failed: $($_.Exception.Message)"
                }
                $nsRows += $row
            }

            return @{
                Skip            = $false
                Source          = $env:COMPUTERNAME
                SoaPrimary      = $soaPrimary
                ZoneName        = $zoneName
                ClusterFqdn     = $clusterFqdn
                AuthoritativeNs = $authoritativeNs
                NsRows          = $nsRows
            }
        }

        # Execute Stage 1 via PsSession or locally.
        # A single Stage 1 probe is sufficient: DNS zone config is cluster-wide.
        $isLocalBool = [bool]$IsLocalIdentityEnvironment
        if ($PsSession) {
            $stage1 = Invoke-Command -Session $PsSession[0] -ScriptBlock $probeSb -ArgumentList $DomainFQDN, $ClusterName
        } else {
            $stage1 = Invoke-Command -ScriptBlock $probeSb -ArgumentList $DomainFQDN, $ClusterName
        }

        # Handle zone-resolution failure early
        if ($stage1.Skip) {
            $skipDetail = "Verdict : $($stage1.Verdict)`nZoneName : $($stage1.ZoneName)`nClusterFqdn : $($stage1.ClusterFqdn)`nReason : $($stage1.SkipReason)"
            Log-Info 'Test-ClusterDnsPosture: zone not resolvable, emitting SKIP'
            $skipRow = New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' `
                -Status 'SUCCESS' `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "$($stage1.Source)/DNS" `
                -Source $stage1.Source `
                -Resource 'DNS' `
                -Detail $skipDetail
            return @(New-AggregatedTestResult `
                -TestName 'Test-ClusterDnsPosture' `
                -DisplayName $displayName `
                -Description $description `
                -DetailResults @($skipRow) `
                -ValidatorName 'DNS' `
                -ResourceType 'DNS' `
                -Remediation $ldTxt.ClusterDnsPostureRemediation)
        }

        $zoneName        = $stage1.ZoneName
        $clusterFqdn     = $stage1.ClusterFqdn
        $soaPrimary      = $stage1.SoaPrimary
        $authoritativeNs = $stage1.AuthoritativeNs
        $nodeSource      = $stage1.Source
        $clusterIpExpected = if ($ClusterIP) { $ClusterIP } else { $null }

        # Stage 2: per-NS reachability + cluster A-record query (direct, QuickTimeout, read-only).
        # A cheap SOA probe on the same NS disambiguates unreachable from record-absent on a miss.
        $perNs = @()
        foreach ($nsRow in $stage1.NsRows) {
            $row = @{
                NameServer    = $nsRow.NameServer
                NsIp          = $nsRow.NsIp
                Reachable     = $false
                RecordPresent = $null
                RdataObserved = $null
                Ttl           = $null
                Err           = $nsRow.Err
            }
            if ($nsRow.NsIp) {
                # Read-only prediction: query the cluster A record directly against this NS with
                # QuickTimeout. An NXDOMAIN / empty answer is a first-class "record absent" result
                # (the expected pre-deploy state for ADLess), NOT a retryable failure. This
                # deliberately does NOT route through TestDNSResolution, whose hardcoded 3x5s
                # deploy-oriented retry/sleep would add ~15s per name server on the absent path.
                $aRecords = @()
                try {
                    $aRecords = @(Resolve-DnsName -Name $clusterFqdn -Type A -Server $nsRow.NsIp -DnsOnly -QuickTimeout -ErrorAction Stop |
                        Where-Object { $_.IPAddress })
                } catch {
                    $aRecords = @()
                }

                if ($aRecords.Count -gt 0) {
                    $row.Reachable = $true
                    $row.RecordPresent = $true
                    $row.RdataObserved = [string]($aRecords | Select-Object -First 1).IPAddress
                } else {
                    # No A record returned. Disambiguate "NS unreachable" from "record absent"
                    # with a single cheap SOA probe (QuickTimeout) against the same NS.
                    try {
                        $null = Resolve-DnsName -Name $zoneName -Type SOA -Server $nsRow.NsIp -DnsOnly -QuickTimeout -ErrorAction Stop
                        $row.Reachable = $true
                        $row.RecordPresent = $false
                    } catch {
                        $row.Reachable = $false
                        if (-not $row.Err) { $row.Err = "Unreachable on Server $($nsRow.NsIp): $($_.Exception.Message)" }
                    }
                }
            }
            $perNs += $row
        }

        # Stage 3: posture inference
        $reachable = @($perNs | Where-Object { $_.Reachable })
        $allUnreachable = ($reachable.Count -eq 0)
        $presentRows = @($reachable | Where-Object { $_.RecordPresent })
        $existingRecordPresent = ($presentRows.Count -gt 0 -and $presentRows.Count -eq $reachable.Count)

        $observedRdataSet = @($presentRows | ForEach-Object { $_.RdataObserved } | Sort-Object -Unique)
        $partialPresence = ($presentRows.Count -gt 0 -and $presentRows.Count -lt $reachable.Count)
        $allNsReplicaParity = (-not $partialPresence) -and ($observedRdataSet.Count -le 1)

        $zoneIsADIntegrated = $false
        $zoneConfidence = 'Low'
        if ($soaPrimary -and $authoritativeNs.Count -gt 0) {
            $primaryLc = $soaPrimary.ToString().ToLower().TrimEnd('.')
            $nsLc = $authoritativeNs | ForEach-Object { $_.ToString().ToLower().TrimEnd('.') }
            if ($primaryLc -in $nsLc) {
                $zoneIsADIntegrated = $true
                $zoneConfidence = 'High'
            }
        }

        $rdataMatchesClusterIp = 'unknown'
        if ($existingRecordPresent -and -not [string]::IsNullOrWhiteSpace($clusterIpExpected)) {
            if ($observedRdataSet.Count -eq 1 -and $observedRdataSet[0] -eq $clusterIpExpected) {
                $rdataMatchesClusterIp = 'True'
            } else {
                $rdataMatchesClusterIp = 'False'
            }
        }

        # Verdict mapping: delegates to Get-ClusterDnsPostureVerdict (pure function, separately unit-tested)
        $probeHt = @{
            SoaResolved           = $true
            AllNsUnreachable      = $allUnreachable
            ExistingRecordPresent = $existingRecordPresent
            RdataMatchesClusterIp = $rdataMatchesClusterIp
            AllNsReplicaParity    = $allNsReplicaParity
            ZoneIsADIntegrated    = $zoneIsADIntegrated
        }
        $verdict = Get-ClusterDnsPostureVerdict -Probe $probeHt -IsLocalIdentity $isLocalBool

        $rowStatus = if ($verdict -like 'RED_*') { 'FAILURE' }
                     elseif ($verdict -like 'AMBER_*') { 'WARNING' }
                     else { 'SUCCESS' }

        $emitted = @()
        foreach ($r in $perNs) {
            $detailLines = @(
                "Verdict : $verdict",
                "ZoneName : $zoneName",
                "ClusterFqdn : $clusterFqdn",
                "NameServer : $($r.NameServer)",
                "NsIp : $(if ($r.NsIp) { $r.NsIp } else { '-' })",
                "SoaPrimaryNs : $soaPrimary",
                "ZoneIsADIntegrated : $zoneIsADIntegrated (confidence: $zoneConfidence)",
                "ExistingRecordPresent: $(if ($null -eq $r.RecordPresent) { 'unknown' } else { $r.RecordPresent })",
                "RdataObserved : $(if ($r.RdataObserved) { $r.RdataObserved } else { '-' })",
                "ExpectedClusterIp : $(if ($clusterIpExpected) { $clusterIpExpected } else { '<not supplied>' })",
                "RdataMatchesClusterIp: $rdataMatchesClusterIp",
                "AllNsReplicaParity : $allNsReplicaParity",
                "IsLocalIdentity : $isLocalBool"
            )
            if ($r.Err) { $detailLines += "Error : $($r.Err)" }

            $emitted += @{
                Source   = $nodeSource
                Resource = $r.NameServer
                Status   = $rowStatus
                Verdict  = $verdict
                Detail   = $detailLines -join "`n"
            }
        }

        foreach ($r in $emitted) {
            $logType = switch ($r.Status) {
                'FAILURE' { 'Critical' }
                'WARNING' { 'Warning' }
                default   { 'INFO' }
            }
            $line = "Test-ClusterDnsPosture: {0} on {1} (NS={2}) -> {3}" -f $r.Verdict, $r.Source, $r.Resource, $r.Status
            Log-Info -Message $line -Type $logType
        }

        $detailResults = @()
        foreach ($r in $emitted) {
            $detailResults += New-LightweightResult `
                -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' `
                -Status $r.Status `
                -Severity 'INFORMATIONAL' `
                -TargetResourceName "$($r.Source)/$($r.Resource)" `
                -Source $r.Source `
                -Resource $r.Resource `
                -Detail $r.Detail
        }

        return @(New-AggregatedTestResult `
            -TestName 'Test-ClusterDnsPosture' `
            -DisplayName $displayName `
            -Description $description `
            -DetailResults $detailResults `
            -ValidatorName 'DNS' `
            -ResourceType 'DNS' `
            -Remediation $ldTxt.ClusterDnsPostureRemediation)
    }
    catch
    {
        # A thrown error must not look like "passed". Emit an explicit FAILURE aggregated result
        # (INFORMATIONAL severity, non-blocking per SDP Phase 1) so the failed probe is visible and
        # actionable rather than silently absent. Logged as Warning because the check is advisory.
        $errorDetail = $ldTxt.ClusterDnsPostureUnexpectedError -f $_.Exception.Message
        Log-Info $errorDetail -Type Warning
        $errorResult = New-LightweightResult `
            -Name 'AzStackHci_DNS_Test_ClusterDnsPosture' `
            -Status 'FAILURE' `
            -Severity 'INFORMATIONAL' `
            -TargetResourceName "$env:COMPUTERNAME/DNS" `
            -Source $env:COMPUTERNAME `
            -Resource 'DNS' `
            -Detail $errorDetail
        return @(New-AggregatedTestResult `
            -TestName 'Test-ClusterDnsPosture' `
            -DisplayName 'Cluster DNS Posture (CNO write feasibility)' `
            -Description 'Read-only probe of the authoritative DNS server(s) for the deployment-target zone. Predicts whether the Failover Cluster CNO will be able to write its DNS A record at bring-online. Does not write to DNS.' `
            -DetailResults @($errorResult) `
            -ValidatorName 'DNS' `
            -ResourceType 'DNS' `
            -Remediation $ldTxt.ClusterDnsPostureRemediation)
    }
}

Export-ModuleMember -Function Test-*
# SIG # Begin signature block
# MIInKAYJKoZIhvcNAQcCoIInGTCCJxUCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC8A7g/jmdDeCSO
# wy/nEJfItmjhJkzHHKXfloMVjPSAuaCCDLowggX1MIID3aADAgECAhMzAAACHU0Z
# yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD
# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1
# OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE
# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD
# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB
# DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8
# o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg
# 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4
# Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R
# X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk
# ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B
# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O
# BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL
# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw
# HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg
# UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0
# JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh
# MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy
# dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9
# s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H
# VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3
# w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n
# 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs
# A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo
# Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb
# SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6
# 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z
# V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v
# 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs
# /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA
# AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX
# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg
# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl
# IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow
# VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo
# MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ
# KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh
# emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h
# KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd
# M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp
# yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t
# Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5
# REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs
# 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK
# Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5
# pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW
# eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ
# 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC
# NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB
# gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU
# ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny
# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0
# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx
# MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI
# MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4
# NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh
# ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q
# hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU
# nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb
# H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z
# uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u
# vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW
# 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV
# DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10
# 1cY2L4A7GTQG1h32HHAvfQESWP0xghnEMIIZwAIBATBuMFcxCzAJBgNVBAYTAlVT
# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv
# c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w
# DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ
# KoZIhvcNAQkEMSIEIAuTFe0zJF8EA2O5Ydb0KCWwDJs82f87SwUCvtfNyxhuMEIG
# CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v
# d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAZQ3AVnRXBW/uPefL
# WfDh3IfszTP4etx8hP6rlgbFiM79H4gxMxo9ebF+K7us+ijCBMzCRSvgGkEOvpkI
# azO1pewr2fQwLNdnfYrJE8cRf22v4r2O1TfscP2ptC005Sojn56lhcBH5HCW7iyh
# 1LlwKXn5++2Qt7cO/QNrHoDUpVksaFLV7LpYL3VruD089nXTAwH/RbWofS7ywTQX
# fGif4iHll0tSKJmoTppX3yew17bwY7bw0LFVjyBaTFgy0kKVyER7b50T2ssAQCWA
# Lmyo3m3FhWgdjphNu2/AD0jUCcrAxk47uXoFnubihfQfs3yh6dQANNsSh2VAmc7s
# DsNnbqGCF5QwgheQBgorBgEEAYI3AwMBMYIXgDCCF3wGCSqGSIb3DQEHAqCCF20w
# ghdpAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9
# MIIBOQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCDEAen3qOCpkT0w
# j/k1ALiry5BALoGJllgYATRT378EewIGaoVZBxBwGBMyMDI2MDgyNzE2Mzg1MC40
# MTlaMASAAgH0oIHRpIHOMIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu
# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv
# cmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScw
# JQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMT
# HE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WgghHqMIIHIDCCBQigAwIBAgIT
# MwAAAiY1tD5nQ5P2HwABAAACJjANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJV
# UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE
# ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt
# ZS1TdGFtcCBQQ0EgMjAxMDAeFw0yNjAyMTkxOTQwMDJaFw0yNzA1MTcxOTQwMDJa
# MIHLMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH
# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL
# ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxk
# IFRTUyBFU046OTYwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1l
# LVN0YW1wIFNlcnZpY2UwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/
# /w+ZZIL5RFFpVI8D3ZyuNu8IzcAEOD30OLYjh337rXjcrIlOSzpJc4ZeUxEyli6x
# 6F6zm4NR8dbPb9diDp/hOUzHWGxiA1Z3RXKBb/4F/ojyvN43SEGWqSfVc3I3BlsY
# T35ecVAJ9kVf90YOv29tFjJBBZkYvrT/DwwyRLscOyP4p+9/lyJjD+ULs3YXBhVr
# fZ+MbQB+BYKLqRvBKbj/wR9akNrMxQINoGaD5jZO/N/nSsmG2P1zv/cv4gSoMBnW
# eQIBkjd2I5w1DeXupp2vSiNmR5sA2ZkBK3yiQWaJvRxODlkfiyHk9Mkk/TrYTjmj
# PCbhe+uqhHNRy8UlbOvWsCq0tRtUykHv39DgqAfJNrE8OSt835rBzDprrcAhwmgf
# hoVi4AKeqwikY0nUa48K0Qy80XT4fiEA3ExEZNaRFo9Nq/GwbfgqKqGmc9xhKuRF
# cjtua4KHZvnAvpWgEFSOCkovXs/BcLnkEHM9xZ8iUag5CyhNqXYYE/z0pcXdYaNI
# kQ68EWmuvLm7g9oofV2vOm5GVNoghnkWG6nGPo/JwEgmA9oSS0EfvFRMWPA/gpSv
# F3shArKHnaEpVSSi3DNbyiuYiEs9Ko0IkZc8xKFeQRaqGRxrB+2r/7B3X81Tps99
# KhFwg+wD87od22F2MUg1x7twt3gaVnFk0IZIwUPCGwIDAQABo4IBSTCCAUUwHQYD
# VR0OBBYEFF3hn9fYJN2Y/Z9LVbBPIxAzXHsQMB8GA1UdIwQYMBaAFJ+nFV0AXmJd
# g/Tl0mWnG1M1GelyMF8GA1UdHwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9z
# b2Z0LmNvbS9wa2lvcHMvY3JsL01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0El
# MjAyMDEwKDEpLmNybDBsBggrBgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6
# Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGlt
# ZS1TdGFtcCUyMFBDQSUyMDIwMTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0l
# AQH/BAwwCgYIKwYBBQUHAwgwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUA
# A4ICAQA2Ux0tr9sYCjsq0FRyiVpx15OurNXv6Qk7iX+ArVPlz3w4tqjcTNm1dt3t
# Tua2wJMpJhPH8n7UXhmT98d5Du44Ll4adnse4SQfVg3QL6aRkXHnJUn8y9iftB/P
# y22n9xnwPFfj3QlDOSgLuHleu97U0iH2ZaluYabWXJihdiYpK8cPHFlqZOAiot0+
# GD8dP+RMuvpxt/F2LmYelpoZwriiFOUmlxEUV7xJHyZZlDquskeyuq01DTv91N4q
# M8cfPPhl/2pc4HeMf/nd2HouifJbDQFNd4WPhLzn0Sy3u1Zh3+S3tjQdqN+dyw60
# RaV+RXCoOLgFZ3MAg/GoDl+fvb5hy/1a71ctX8wEad1Pf6def2pqfl3wFc++hkF8
# DXXTZofJN4YVaN3InwbAGQDDkNK4lqecCixxmSKwidPynGeE5OtvNoK1pkLsm/i8
# F1RjGczZ/kSF2VDkqG866iQ+jVbGOQ6Du3eyyFcFKZoDJ4B5mEAS9aT2SKqllLey
# bOboH6r67siR5B/2Hnu7+KYuYZy0BEadtA6ngG4cnSR9JsrkhhsKmb11ujqwgJyN
# x92MsoGGwNgN1aI0QID8CsjCFwpfmMzlA44xHKYv3hmjxeqBS4uU5rQeiAnVgpJe
# aVGKm/lzPDtnppGV+7XhRp5b1ZxT/Z7Xxc+I7H7/jCtQDZoaZTCCB3EwggVZoAMC
# AQICEzMAAAAVxedrngKbSZkAAAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNV
# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w
# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29m
# dCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIy
# NVoXDTMwMDkzMDE4MzIyNVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hp
# bmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jw
# b3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9
# DtWC0/3unAcH0qlsTnXIyjVX9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2
# Z4VGIwy1jRPPdzLAEBjoYH1qUoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N
# 7dcP2CZTfDlhAnrEqv1yaa8dq6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXc
# ag/PXfT+jlPP1uyFVk3v3byNpOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJ
# j361VI/c+gVVmG1oO5pGve2krnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjk
# lqwBSru+cakXW2dg3viSkR4dPf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37Zy
# L9t9X4C626p+Nuw2TPYrbqgSUei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M
# 269ewvPV2HM9Q07BMzlMjgK8QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLX
# pyDwwvoSCtdjbwzJNmSLW6CmgyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLU
# HMVr9lxSUV0S2yW6r1AFemzFER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode
# 2o+eFnJpxq57t7c+auIurQIDAQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEA
# ATAjBgkrBgEEAYI3FQIEFgQUKqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYE
# FJ+nFV0AXmJdg/Tl0mWnG1M1GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEB
# MEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv
# RG9jcy9SZXBvc2l0b3J5Lmh0bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEE
# AYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB
# /zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEug
# SaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9N
# aWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsG
# AQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jv
# b0NlckF1dF8yMDEwLTA2LTIzLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt
# 4SwfZwExJFvhnnJL/Klv6lwUtj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsP
# MeTCj/ts0aGUGCLu6WZnOlNN3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++
# Kctu2D9IdQHZGN5tggz1bSNU5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9
# QKC/GbYSEhFdPSfgQJY4rPf5KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2
# wh3DYXMuLGt7bj8sCXgU6ZGyqVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aR
# AfbOxnT99kxybxCrdTDFNLB62FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5z
# bcqOCb2zAVdJVGTZc9d/HltEAY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nx
# t67I6IleT53S0Ex2tVdUCbFpAUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3
# Fb+0zj6lMVGEvL8CwYKiexcdFYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+AN
# uOaMmdbhIurwJ0I9JZTmdHRbatGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/Z
# cGNTTY3ugm2lBRDBcQZqELQdVTNYs6FwZvKhggNNMIICNQIBATCB+aGB0aSBzjCB
# yzELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl
# ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMc
# TWljcm9zb2Z0IEFtZXJpY2EgT3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBU
# U1MgRVNOOjk2MDAtMDVFMC1EOTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1T
# dGFtcCBTZXJ2aWNloiMKAQEwBwYFKw4DAhoDFQCi/fMxFtkqr7XMXdsRyWU0lSKH
# Z6CBgzCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw
# DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x
# JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3
# DQEBCwUAAgUA7jpiKjAiGA8yMDI2MDgyNzA3MTM0NloYDzIwMjYwODI4MDcxMzQ2
# WjB0MDoGCisGAQQBhFkKBAExLDAqMAoCBQDuOmIqAgEAMAcCAQACAhVpMAcCAQAC
# AhPjMAoCBQDuO7OqAgEAMDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKg
# CjAIAgEAAgMHoSChCjAIAgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBACmZ5BpF
# 5VzLTbuseoKUd9DxZIgATFOdA1KGiVETIjDPBAxV3S0u1hNWJJzzEQU6jikRpR+N
# cWikOUxzy3T6XQ+//M89XY2Gt7rm2TXMC0w4gahEVeafEPPJxzLWuc/tFAbv9TvZ
# h2V9g4RhLX9SrOPqWCwLoV/lKIjcpKt9F883ojAKEjwocBuD9LQxcQgKdqQgMKaV
# TM0mL5OF6E+ls9jCFDg3kJgBFCheYZxcUnIbsL6H223FSaCh3UJRtKgC/9bnHUWH
# VZvwXL7q5+MofdYYVsrbSsjy65K0ZI6wEeVlBlzMV8rKidGrb3xf9h0AVUI4ibvA
# lQSoblSGXu9bKQ4xggQNMIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UE
# CBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9z
# b2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQ
# Q0EgMjAxMAITMwAAAiY1tD5nQ5P2HwABAAACJjANBglghkgBZQMEAgEFAKCCAUow
# GgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCAXXmfG
# WUnjizNEtJlI1cjYe2vJhcsggRCyJ3RMcBbK8TCB+gYLKoZIhvcNAQkQAi8xgeow
# gecwgeQwgb0EIMwyXGFnTNsZRBrs6GN/BbV0okaNP3VBYqLFjUsFnbgqMIGYMIGA
# pH4wfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcT
# B1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UE
# AxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAImNbQ+Z0OT9h8A
# AQAAAiYwIgQgC4my0mwzs7AZeVA2HlUzT7nZYwn0tZrZw5csNeOFNW4wDQYJKoZI
# hvcNAQELBQAEggIAszMGnQrPXkydT2XVzhlxtg5zN7+gtCcYtWKQIbKh9wK1fS7o
# 0nMdKHDjlGasu0GO0oxt+Opod/ceK66+gi4zLnYGTzObq4oqongMGvC7wSOitVLH
# YFMfns+iVQmWpjprd9nlyPDdXiB4sdR/C6xPDKstQncOv2OwSh6GzVDP1uc8qOpL
# lP9bcrn0zvH9xs34E1t4pQ4JeIiBdnsVwQrMlM7Mtdk26B6ULuEIDIgxGGYmiHzq
# zrVGxDQ63/uQIvtuj/UpAu4dZulEfpPgkA0fSe4vuR2RqWSJePeFwZa9TImYxCYe
# gCv7PxakTDBSVpP9rSFEKjdZNhjZLpmfboQGn4p9UVG4fEpR9FSN3pg9hVrrAXyf
# 6+bTGjLV+hZy+yJ/HZpPwgfI5dpqN9jYB1t5aq1LZyT3v42t/+/sRoYpFOCZFbum
# tcUj86U9eriJgx66bjPosOOg/SupApwao/b7yrBq7bJRtGhTOUpqlOrhgpLumXgY
# LxA2TLdBMs4XoB/3CPIBWaHbN3yO8rR8d/ULDkOMbD70GukQKH+r8PlQ7MayqDKl
# kS1p7rSv6im+Mq2vN4E96xNmfQqP04lnpieDJcKKiFGC5KuiU8cVT6vdIHKYfXDK
# 84u8XgWnxfrdYzMS9qCbtIbrQvxowWdWytJz37eF8JZGZYYFrvqZq+oT7kc=
# SIG # End signature block