Private/AriaAutomationHelpers.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================
#region AriaAutomationHelpers
#
# Aria Automation has no PowerCLI/OpenAPI SDK coverage at all (unlike Aria Operations'
# VMware.Sdk.Vcf.Ops) - every call in this file is hand-written REST via Invoke-RestMethod.
#
# Aria Automation has no per-domain FQDN lookup - like VROPS/VRSLCM, Invoke-VcfGetCredentials
# -ResourceType VRA is the only source for both its FQDN and credential. Credential filtering
# on CredentialType -eq 'API' is done client-side because Invoke-VcfGetCredentials lacks a
# -CredentialType parameter (only -AccountType is available).
#
# Aria Automation can also be deployed entirely outside SDDC Manager's knowledge - see
# Get-VcfCheckEnvironmentAriaAutomationEndpoints, which resolves the user-declared Integrations
# list on an environment (Private/Environments.ps1) instead of Invoke-VcfGetCredentials.
# Connect-VcfCheckAriaAutomationEndpoint connects to one of those directly by FQDN + credential,
# with no SDDC Manager lookup at all.
#
# Authentication is a two-step exchange, confirmed against a live 8.18 appliance:
# 1. POST '/csp/gateway/am/api/login?access_token' (the '?access_token' query flag is
# required - without it the same endpoint instead returns an unrelated short-lived
# 'cspAuthToken' session token that '/iaas/api/login' rejects with "'refreshToken' is
# invalid") with {username, password, domain} -> {refresh_token}.
# 2. POST '/iaas/api/login' (documented in the public IaaS API spec) with {refreshToken} ->
# {token, tokenType: "Bearer"}. This bearer token is short-lived and is presented on every
# later call as 'Authorization: Bearer <token>'.
# Local/System Domain accounts do not use a consistent domain value across vIDM builds - this
# file tries 'System Domain' first, then '' (both were observed to succeed against different
# appliances), before falling back to the credential's own domain segment for directory-backed
# accounts ('DOMAIN\user' / 'user@domain'), matching the parsing Get-VcfCheckAriaOpsApiToken
# already uses for Aria Operations.

function Get-VcfCheckAriaAutomationCredential {
    <#
        .SYNOPSIS
        Resolves and caches Aria Automation's FQDN and API credential from SDDC Manager.

        .DESCRIPTION
        Calls Invoke-VcfGetCredentials -ResourceType VRA once per run and caches the result on
        $Context. Returns $null (not an exception) when no matching credential entry comes back -
        this is the expected, normal shape of "Aria Automation is not deployed in this
        environment", which every Aria Automation check must treat as Skipped rather than Error.

        .PARAMETER Context
        The VcfCheck.Context object. Must already be connected to SDDC Manager.

        .OUTPUTS
        [PSCustomObject] with Fqdn/Credential properties, or $null if Aria Automation is not
        deployed.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context
    )

    if ($Context.AriaAutomationCredential) {
        return $Context.AriaAutomationCredential
    }

    Write-LogMessage -Type DEBUG -Message 'Resolving Aria Automation FQDN and credential from SDDC Manager.'
    try {
        $response = Invoke-VcfGetCredentials -ResourceType VRA -ErrorAction Stop
    } catch {
        throw [System.InvalidOperationException]::new("Failed to query SDDC Manager for Aria Automation credentials: $($_.Exception.Message)")
    }

    $match = @($response.Elements) | Where-Object { $_.CredentialType -eq 'API' } | Select-Object -First 1
    if (-not $match) {
        Write-LogMessage -Type DEBUG -Message 'No Aria Automation credential entry returned by SDDC Manager - Aria Automation is not part of this environment.'
        return $null
    }

    $secure = ConvertTo-SecureStringForCredential -PlainText $match.Password
    $credential = [PSCredential]::new($match.Username, $secure)
    $resolved = [PSCustomObject]@{
        PSTypeName       = 'VcfCheck.AriaAutomationCredential'
        Fqdn             = $match.Resource.ResourceName
        Credential       = $credential
        AllowInsecureTls = [Bool]$Context.AllowInsecureTls
    }
    Remove-Variable -Name match, response -ErrorAction SilentlyContinue

    $Context.AriaAutomationCredential = $resolved
    return $resolved
}
function Get-VcfCheckAriaAutomationRefreshToken {
    <#
        .SYNOPSIS
        Acquires (and caches) an Aria Automation vIDM refresh token.

        .DESCRIPTION
        POSTs to '/csp/gateway/am/api/login?access_token' with the credential's username/
        password. The '?access_token' query flag is required - see AriaAutomationHelpers.ps1's
        file header for why. Directory-backed accounts ('DOMAIN\user' / 'user@domain') resolve
        their domain from the username; local/System Domain accounts try 'System Domain' first,
        then '' - both have been observed to work depending on appliance/vIDM version. The
        refresh token is cached as a 'RefreshToken' member added directly onto the CredentialInfo
        object so every call within a run re-uses it instead of re-authenticating.

        .PARAMETER CredentialInfo
        The AriaAutomationCredential object returned by Get-VcfCheckAriaAutomationCredential
        (Fqdn + Credential).

        .OUTPUTS
        [String] the refresh token.
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$CredentialInfo
    )

    if ($CredentialInfo.PSObject.Properties['RefreshToken'] -and $CredentialInfo.RefreshToken) {
        return $CredentialInfo.RefreshToken
    }

    $rawUsername = $CredentialInfo.Credential.UserName
    if ($rawUsername -match '^(?<domain>[^\\]+)\\(?<user>.+)$') {
        $username = $Matches.user
        $domainCandidates = @($Matches.domain)
    } elseif ($rawUsername -match '^(?<user>[^@]+)@(?<domain>.+)$') {
        $username = $Matches.user
        $domainCandidates = @($Matches.domain)
    } else {
        $username = $rawUsername
        $domainCandidates = @('System Domain', '')
    }

    $password = $CredentialInfo.Credential.GetNetworkCredential().Password
    $uri = "https://$($CredentialInfo.Fqdn)/csp/gateway/am/api/login?access_token"
    $refreshToken = $null
    $lastError = $null
    foreach ($domainCandidate in $domainCandidates) {
        $body = @{ username = $username; password = $password; domain = $domainCandidate } | ConvertTo-Json -Depth 2
        try {
            $response = Invoke-RestMethod -Uri $uri -Method POST -Body $body -ContentType 'application/json' -SkipCertificateCheck:$CredentialInfo.AllowInsecureTls -ErrorAction Stop
            if ($response.refresh_token) {
                $refreshToken = $response.refresh_token
                break
            }
        } catch {
            $lastError = $_
        }
    }

    if (-not $refreshToken) {
        $rawMessage = if ($lastError) { $lastError.Exception.Message } else { 'no refresh_token field was returned' }
        $message = Get-VcfCheckTlsTrustErrorMessage -ComponentName 'Aria Automation' -Fqdn $CredentialInfo.Fqdn -ErrorMessage $rawMessage
        if (-not $message) {
            $message = "Failed to acquire an Aria Automation refresh token for `"$($CredentialInfo.Fqdn)`": $rawMessage"
        }
        throw [System.InvalidOperationException]::new($message)
    }

    if ($CredentialInfo.PSObject.Properties['RefreshToken']) {
        $CredentialInfo.RefreshToken = $refreshToken
    } else {
        $CredentialInfo | Add-Member -NotePropertyName 'RefreshToken' -NotePropertyValue $refreshToken
    }
    return $refreshToken
}
function Get-VcfCheckAriaAutomationBearerToken {
    <#
        .SYNOPSIS
        Exchanges a cached refresh token for an Aria Automation IaaS API bearer token.

        .DESCRIPTION
        POSTs to '/iaas/api/login' with the refresh token from Get-VcfCheckAriaAutomationRefreshToken.
        The bearer token is cached as a 'BearerToken' member added directly onto the
        CredentialInfo object. Callers needing a fresh token after a 401 must call
        Clear-VcfCheckAriaAutomationBearerToken first, then call this function again.

        .PARAMETER CredentialInfo
        The AriaAutomationCredential object returned by Get-VcfCheckAriaAutomationCredential
        (Fqdn + Credential).

        .OUTPUTS
        [String] the bearer token.
    #>

    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$CredentialInfo
    )

    if ($CredentialInfo.PSObject.Properties['BearerToken'] -and $CredentialInfo.BearerToken) {
        return $CredentialInfo.BearerToken
    }

    $refreshToken = Get-VcfCheckAriaAutomationRefreshToken -CredentialInfo $CredentialInfo
    $body = @{ refreshToken = $refreshToken } | ConvertTo-Json
    try {
        $response = Invoke-RestMethod -Uri "https://$($CredentialInfo.Fqdn)/iaas/api/login" -Method POST -Body $body -ContentType 'application/json' -SkipCertificateCheck:$CredentialInfo.AllowInsecureTls -ErrorAction Stop
    } catch {
        $message = Get-VcfCheckTlsTrustErrorMessage -ComponentName 'Aria Automation' -Fqdn $CredentialInfo.Fqdn -ErrorMessage $_.Exception.Message
        if (-not $message) {
            $message = "Failed to exchange the Aria Automation refresh token for a bearer token on `"$($CredentialInfo.Fqdn)`": $($_.Exception.Message)"
        }
        throw [System.InvalidOperationException]::new($message)
    }

    if ($CredentialInfo.PSObject.Properties['BearerToken']) {
        $CredentialInfo.BearerToken = $response.token
    } else {
        $CredentialInfo | Add-Member -NotePropertyName 'BearerToken' -NotePropertyValue $response.token
    }
    return $response.token
}
function Clear-VcfCheckAriaAutomationBearerToken {
    <#
        .SYNOPSIS
        Clears a cached, expired/rejected Aria Automation bearer token so the next call
        re-acquires one.

        .PARAMETER CredentialInfo
        The AriaAutomationCredential object carrying the cached BearerToken member.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$CredentialInfo
    )

    if ($CredentialInfo.PSObject.Properties['BearerToken']) {
        $CredentialInfo.BearerToken = $null
    }
}
function Invoke-VcfCheckAriaAutomationApi {
    <#
        .SYNOPSIS
        Calls Aria Automation's REST API directly with a bearer token acquired via
        Get-VcfCheckAriaAutomationBearerToken.

        .DESCRIPTION
        Thin Invoke-RestMethod wrapper - Aria Automation has no SDK, so every check goes through
        this function. -SkipCertificateCheck is only passed when CredentialInfo.AllowInsecureTls
        is $true (the run's resolved AllowInsecureTls value, derived from PowerCLI's
        InvalidCertificateAction setting - see Invoke-VcfCheck in Orchestrator.ps1 and
        Get-VcfCheckAriaAutomationCredential) rather than being unconditional; an untrusted
        certificate encountered while that is $false surfaces as a clear, actionable error via
        Get-VcfCheckTlsTrustErrorMessage instead of being silently accepted. Retries up to 3 times with a 10-second delay on transient network failures
        (timeouts, connection resets). On an HTTP 401 (expired bearer token), clears the cached
        token and re-authenticates once before retrying the call - this connector has no SDK
        managing token lifetime for it, unlike Aria Operations.

        .PARAMETER CredentialInfo
        The AriaAutomationCredential object returned by Get-VcfCheckAriaAutomationCredential
        (Fqdn + Credential).

        .PARAMETER Method
        HTTP method. Defaults to GET.

        .PARAMETER Path
        The IaaS/CSP API path, e.g. '/iaas/api/about'.

        .PARAMETER Body
        Optional request body, already a Hashtable/PSCustomObject (will be converted to JSON).

        .OUTPUTS
        The parsed JSON response.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$CredentialInfo,
        [Parameter(Mandatory = $false)] [ValidateSet('GET', 'POST')] [String]$Method = 'GET',
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Path,
        [Parameter(Mandatory = $false)] [Object]$Body
    )

    $uri = "https://$($CredentialInfo.Fqdn)$Path"
    $maxAttempts = 3
    $retryDelaySeconds = 10
    $reauthenticated = $false

    for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
        $token = Get-VcfCheckAriaAutomationBearerToken -CredentialInfo $CredentialInfo
        $parameters = @{
            Uri                  = $uri
            Method               = $Method
            Headers              = @{ Accept = 'application/json'; Authorization = "Bearer $token" }
            SkipCertificateCheck = $CredentialInfo.AllowInsecureTls
            ErrorAction          = 'Stop'
        }
        if ($Body) {
            $parameters.Body = ($Body | ConvertTo-Json -Depth 10)
            $parameters.ContentType = 'application/json'
        }

        try {
            return Invoke-RestMethod @parameters
        } catch {
            $statusCode = $_.Exception.Response.StatusCode.value__
            if ($statusCode -eq 401 -and -not $reauthenticated) {
                Write-LogMessage -Type DEBUG -Message "Aria Automation bearer token for `"$($CredentialInfo.Fqdn)`" was rejected (HTTP 401) - re-authenticating."
                Clear-VcfCheckAriaAutomationBearerToken -CredentialInfo $CredentialInfo
                $reauthenticated = $true
                continue
            }
            $tlsMessage = Get-VcfCheckTlsTrustErrorMessage -ComponentName 'Aria Automation' -Fqdn $CredentialInfo.Fqdn -ErrorMessage $_.Exception.Message
            if ($tlsMessage) {
                throw [System.InvalidOperationException]::new($tlsMessage)
            }
            $isTransient = $_.Exception.Message -match '(?i)timed out|timeout|actively refused|connection refused|reset by peer|handshake'
            if (-not $isTransient -or $attempt -eq $maxAttempts) {
                throw
            }
            Write-LogMessage -Type DEBUG -Message "Transient error calling Aria Automation API '$Path' (attempt $attempt of $maxAttempts): $($_.Exception.Message). Retrying in $retryDelaySeconds seconds."
            Start-Sleep -Seconds $retryDelaySeconds
        }
    }
}
function Connect-VcfCheckAriaAutomation {
    <#
        .SYNOPSIS
        Validates connectivity and authentication against Aria Automation, using the credential
        SDDC Manager has on file.

        .DESCRIPTION
        Resolves the FQDN/credential via Get-VcfCheckAriaAutomationCredential, runs a TCP
        reachability pre-flight check (Test-VcfCheckTcpConnectivity) against <Fqdn>:443, then
        performs the full login exchange (Get-VcfCheckAriaAutomationBearerToken) to fail fast on
        a bad credential. Unlike Aria Operations there is no separate SDK connection object -
        the returned CredentialInfo object itself carries the cached bearer token and is passed
        directly to Invoke-VcfCheckAriaAutomationApi. The result (success or failure reason) is
        cached on $Context so every later check reuses it instead of reconnecting.

        .PARAMETER Context
        The VcfCheck.Context object. Must already be connected to SDDC Manager.

        .PARAMETER ConnectivityTimeoutSeconds
        Maximum time to wait for the TCP reachability pre-flight check. Defaults to 15 seconds.

        .OUTPUTS
        The AriaAutomationCredential object (now carrying a cached bearer token), or $null if
        Aria Automation is not deployed in this environment.

        .EXAMPLE
        Connect-VcfCheckAriaAutomation -Context $Context
    #>

    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $false)] [Int]$ConnectivityTimeoutSeconds = 15
    )

    if ($Context.UnreachableAriaAutomation) {
        throw [System.InvalidOperationException]::new($Context.UnreachableAriaAutomation)
    }

    if ($Context.AriaAutomationConnection) {
        return $Context.AriaAutomationConnection
    }

    $resolved = Get-VcfCheckAriaAutomationCredential -Context $Context
    if (-not $resolved) {
        return $null
    }

    if (-not (Test-VcfCheckTcpConnectivity -ComputerName $resolved.Fqdn -Port 443 -TimeoutSeconds $ConnectivityTimeoutSeconds)) {
        $reason = "Could not reach Aria Automation `"$($resolved.Fqdn)`" on port 443 within $ConnectivityTimeoutSeconds second(s). Check VPN/network connectivity to the environment, firewall rules, and that the FQDN resolves to the correct address, then retry."
        $Context.UnreachableAriaAutomation = $reason
        throw [System.InvalidOperationException]::new($reason)
    }

    Write-LogMessage -Type INFO -Message "Connecting to Aria Automation `"$($resolved.Fqdn)`"..." -NoNewline
    try {
        [void](Get-VcfCheckAriaAutomationBearerToken -CredentialInfo $resolved)
        # Write-Host: completes the -NoNewline INFO line above on the same console row; Write-LogMessage
        # always appends a newline, which would split the status suffix onto its own line.
        Write-Host " Connected" -ForegroundColor White
    } catch {
        # Write-Host: see comment above - completes the same -NoNewline console row.
        Write-Host " Failed" -ForegroundColor Red
        $reason = "Failed to authenticate to Aria Automation `"$($resolved.Fqdn)`". Verify network connectivity, that the credential SDDC Manager has on file is still valid, and that Aria Automation is reachable."
        $Context.UnreachableAriaAutomation = $reason
        throw [System.InvalidOperationException]::new($reason)
    }

    $Context.AriaAutomationConnection = $resolved
    return $resolved
}
function Get-VcfCheckEnvironmentAriaAutomationEndpoints {
    <#
        .SYNOPSIS
        Resolves the user-declared, standalone Aria Automation endpoints attached to an
        environment (Private/Environments.ps1's Integrations field) - components SDDC Manager
        has zero knowledge of.

        .DESCRIPTION
        Filters $Environment.Integrations to entries with Type -eq 'AriaAutomation' and flattens
        their Endpoints into one object per endpoint, applying each integration's
        SharedCredentials setting to resolve which Username applies (the integration-level one
        when shared, else the endpoint's own). Never resolves a password - see
        Connect-VcfCheckAriaAutomationEndpoint.

        .PARAMETER Environment
        An environment object as returned by Get-VcfCheckEnvironments.

        .OUTPUTS
        [Object[]] of PSCustomObject with Name, Fqdn, Username. Empty array if the environment
        has no AriaAutomation integration.

        .EXAMPLE
        Get-VcfCheckEnvironmentAriaAutomationEndpoints -Environment $environment
    #>

    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Environment
    )

    $results = [System.Collections.Generic.List[PSObject]]::new()
    $ariaIntegrations = @($Environment.Integrations) | Where-Object { $_ -and $_.Type -eq 'AriaAutomation' }

    foreach ($integration in $ariaIntegrations) {
        foreach ($endpoint in @($integration.Endpoints)) {
            if (-not $endpoint) {
                continue
            }
            $username = if ($integration.SharedCredentials) { $integration.Username } else { $endpoint.Username }
            $results.Add([PSCustomObject]@{
                PSTypeName = 'VcfCheck.AriaAutomationEndpoint'
                Name       = $endpoint.Name
                Fqdn       = $endpoint.Fqdn
                Username   = $username
            })
        }
    }

    return $results.ToArray()
}
function Connect-VcfCheckAriaAutomationEndpoint {
    <#
        .SYNOPSIS
        Validates connectivity and authentication against a user-declared, standalone Aria
        Automation endpoint by FQDN and credential - no SDDC Manager lookup.

        .DESCRIPTION
        Companion to Connect-VcfCheckAriaAutomation for Aria Automation instances SDDC Manager
        has zero knowledge of (see Get-VcfCheckEnvironmentAriaAutomationEndpoints). Runs the same
        TCP reachability pre-flight and login exchange, caching the resulting CredentialInfo on
        $Context.AriaAutomationEndpointConnections (keyed by Fqdn, since there can be more than
        one) so repeat checks against the same endpoint reuse it. Unreachable/failed endpoints
        are cached on $Context.UnreachableAriaAutomationEndpoints (also keyed by Fqdn).

        .PARAMETER Context
        The VcfCheck.Context object.

        .PARAMETER Fqdn
        The endpoint's FQDN, as entered by the user.

        .PARAMETER Credential
        The credential to connect with. The caller is responsible for resolving the password
        (session-only prompt or launcher parameter) - this function never persists or looks one
        up itself.

        .PARAMETER ConnectivityTimeoutSeconds
        Maximum time to wait for the TCP reachability pre-flight check. Defaults to 15 seconds.

        .OUTPUTS
        The AriaAutomationCredential object (now carrying a cached bearer token).

        .EXAMPLE
        Connect-VcfCheckAriaAutomationEndpoint -Context $Context -Fqdn 'vra-standalone.example.com' -Credential $credential
    #>

    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Fqdn,
        [Parameter(Mandatory = $true)] [PSCredential]$Credential,
        [Parameter(Mandatory = $false)] [Int]$ConnectivityTimeoutSeconds = 15
    )

    if (-not $Context.AriaAutomationEndpointConnections) {
        $Context.AriaAutomationEndpointConnections = @{}
    }
    if (-not $Context.UnreachableAriaAutomationEndpoints) {
        $Context.UnreachableAriaAutomationEndpoints = @{}
    }

    if ($Context.UnreachableAriaAutomationEndpoints.ContainsKey($Fqdn)) {
        throw [System.InvalidOperationException]::new($Context.UnreachableAriaAutomationEndpoints[$Fqdn])
    }
    if ($Context.AriaAutomationEndpointConnections.ContainsKey($Fqdn)) {
        return $Context.AriaAutomationEndpointConnections[$Fqdn]
    }

    if (-not (Test-VcfCheckTcpConnectivity -ComputerName $Fqdn -Port 443 -TimeoutSeconds $ConnectivityTimeoutSeconds)) {
        $reason = "Could not reach Aria Automation `"$Fqdn`" on port 443 within $ConnectivityTimeoutSeconds second(s). Check VPN/network connectivity to the environment, firewall rules, and that the FQDN resolves to the correct address, then retry."
        $Context.UnreachableAriaAutomationEndpoints[$Fqdn] = $reason
        throw [System.InvalidOperationException]::new($reason)
    }

    $credentialInfo = [PSCustomObject]@{ PSTypeName = 'VcfCheck.AriaAutomationCredential'; Fqdn = $Fqdn; Credential = $Credential }
    Write-LogMessage -Type INFO -Message "Connecting to Aria Automation `"$Fqdn`"..." -NoNewline
    try {
        [void](Get-VcfCheckAriaAutomationBearerToken -CredentialInfo $credentialInfo)
        # Write-Host: completes the -NoNewline INFO line above on the same console row; Write-LogMessage
        # always appends a newline, which would split the status suffix onto its own line.
        Write-Host " Connected" -ForegroundColor White
    } catch {
        # Write-Host: see comment above - completes the same -NoNewline console row.
        Write-Host " Failed" -ForegroundColor Red
        $reason = "Failed to authenticate to Aria Automation `"$Fqdn`". Verify network connectivity and the credential entered for this endpoint."
        $Context.UnreachableAriaAutomationEndpoints[$Fqdn] = $reason
        throw [System.InvalidOperationException]::new($reason)
    }

    $Context.AriaAutomationEndpointConnections[$Fqdn] = $credentialInfo
    return $credentialInfo
}
function Resolve-VcfCheckAriaAutomationEndpointCredentials {
    <#
        .SYNOPSIS
        Resolves and caches a [PSCredential] per standalone Aria Automation endpoint's Username.

        .DESCRIPTION
        For each endpoint returned by Get-VcfCheckEnvironmentAriaAutomationEndpoints, uses a
        matching entry (by Fqdn) in PreSuppliedCredentials (the launcher/browser session-only
        password path) when present, otherwise prompts once via Read-Host -AsSecureString.
        Results are cached on $Context.AriaAutomationEndpointCredentials, keyed by Fqdn, so a
        second run within the same $Context never re-prompts.

        .PARAMETER Context
        The VcfCheck.Context object.

        .PARAMETER Endpoints
        Endpoint objects as returned by Get-VcfCheckEnvironmentAriaAutomationEndpoints
        (Name/Fqdn/Username).

        .PARAMETER PreSuppliedCredentials
        Optional array of PSCustomObject/Hashtable with Fqdn/Username/Password (SecureString),
        e.g. resolved from the browser's per-endpoint session-only password field. Endpoints not
        matched here fall back to an interactive prompt.

        .EXAMPLE
        Resolve-VcfCheckAriaAutomationEndpointCredentials -Context $Context -Endpoints $endpoints -PreSuppliedCredentials $AriaAutomationEndpointCredentials
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'PreSuppliedCredentials', Justification = 'Object[] of Fqdn/Username/Password triplets, not a password itself - each element''s Password field is already a SecureString.')]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $true)] [Object[]]$Endpoints,
        [Parameter(Mandatory = $false)] [Object[]]$PreSuppliedCredentials = @()
    )

    if (-not $Context.AriaAutomationEndpointCredentials) {
        $Context.AriaAutomationEndpointCredentials = @{}
    }

    foreach ($endpoint in @($Endpoints)) {
        if (-not $endpoint -or $Context.AriaAutomationEndpointCredentials.ContainsKey($endpoint.Fqdn)) {
            continue
        }

        $preSupplied = @($PreSuppliedCredentials) | Where-Object { $_ -and $_.Fqdn -eq $endpoint.Fqdn } | Select-Object -First 1
        if ($preSupplied) {
            $Context.AriaAutomationEndpointCredentials[$endpoint.Fqdn] = [PSCredential]::new($endpoint.Username, $preSupplied.Password)
            continue
        }

        $securePassword = Read-Host -Prompt "Enter the password for $($endpoint.Username)@$($endpoint.Fqdn) (Aria Automation - $($endpoint.Name))" -AsSecureString
        if ($securePassword.Length -eq 0) {
            throw [System.InvalidOperationException]::new("Password for Aria Automation endpoint `"$($endpoint.Fqdn)`" must not be empty.")
        }
        $Context.AriaAutomationEndpointCredentials[$endpoint.Fqdn] = [PSCredential]::new($endpoint.Username, $securePassword)
    }
}
function Get-VcfCheckAriaAutomationTargets {
    <#
        .SYNOPSIS
        Resolves every Aria Automation instance a check should evaluate - the SDDC-Manager-known
        instance (if deployed) plus every standalone endpoint declared on the current
        environment - and authenticates to each.

        .DESCRIPTION
        Every Test-VcfAriaAutomation* check calls this once instead of
        Connect-VcfCheckAriaAutomation directly, so a single check fans out across every known
        Aria Automation instance rather than only the SDDC-Manager-known one. An authentication
        failure for one target (unreachable, bad credential, not deployed) never blocks
        evaluation of the others - it is surfaced on that target's ConnectError instead of
        throwing, so the caller can emit one Error result per failed target and keep evaluating
        the rest.

        A standalone endpoint whose FQDN matches the SDDC-Manager-known instance's FQDN is
        skipped, since that is the same physical appliance registered twice and would otherwise
        double every check result.

        .PARAMETER Context
        The VcfCheck.Context object. $Context.AriaAutomationEndpoints and
        $Context.AriaAutomationEndpointCredentials must already be populated by Invoke-VcfCheck
        (via Resolve-VcfCheckAriaAutomationEndpointCredentials) for standalone endpoints to be
        included.

        .PARAMETER ConnectivityTimeoutSeconds
        Maximum time to wait for each target's TCP reachability pre-flight check. Defaults to 15
        seconds.

        .OUTPUTS
        [Object[]] of PSCustomObject with Name, Fqdn, CredentialInfo, ConnectError.
        CredentialInfo (Fqdn/Credential, plus a cached bearer token) is the same shape
        Get-VcfCheckAriaAutomationCredential returns - pass it to Invoke-VcfCheckAriaAutomationApi.
        Empty array if Aria Automation is not deployed via SDDC Manager and the environment has
        no standalone endpoints declared - callers must treat that as Skipped.

        .EXAMPLE
        Get-VcfCheckAriaAutomationTargets -Context $Context
    #>

    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $true)] [PSObject]$Context,
        [Parameter(Mandatory = $false)] [Int]$ConnectivityTimeoutSeconds = 15
    )

    $targets = [System.Collections.Generic.List[PSObject]]::new()

    try {
        $credentialInfo = Connect-VcfCheckAriaAutomation -Context $Context -ConnectivityTimeoutSeconds $ConnectivityTimeoutSeconds
        if ($credentialInfo) {
            $targets.Add([PSCustomObject]@{ Name = 'SDDC Manager'; Fqdn = $credentialInfo.Fqdn; CredentialInfo = $credentialInfo; ConnectError = $null })
        }
    } catch {
        $targets.Add([PSCustomObject]@{ Name = 'SDDC Manager'; Fqdn = $Context.AriaAutomationCredential.Fqdn; CredentialInfo = $null; ConnectError = $_.Exception.Message })
    }

    $knownFqdns = @($targets | Where-Object { $_.Fqdn } | ForEach-Object { $_.Fqdn })
    foreach ($endpoint in @($Context.AriaAutomationEndpoints) | Where-Object { $_ }) {
        if ($endpoint.Fqdn -and ($knownFqdns -icontains $endpoint.Fqdn)) {
            continue
        }
        $credential = $Context.AriaAutomationEndpointCredentials[$endpoint.Fqdn]
        if (-not $credential) {
            $targets.Add([PSCustomObject]@{ Name = $endpoint.Name; Fqdn = $endpoint.Fqdn; CredentialInfo = $null; ConnectError = "No password was supplied for Aria Automation endpoint `"$($endpoint.Fqdn)`"." })
            continue
        }
        try {
            $credentialInfo = Connect-VcfCheckAriaAutomationEndpoint -Context $Context -Fqdn $endpoint.Fqdn -Credential $credential -ConnectivityTimeoutSeconds $ConnectivityTimeoutSeconds
            $targets.Add([PSCustomObject]@{ Name = $endpoint.Name; Fqdn = $endpoint.Fqdn; CredentialInfo = $credentialInfo; ConnectError = $null })
        } catch {
            $targets.Add([PSCustomObject]@{ Name = $endpoint.Name; Fqdn = $endpoint.Fqdn; CredentialInfo = $null; ConnectError = $_.Exception.Message })
        }
    }

    return $targets.ToArray()
}