Public/Connect-Horizon.ps1

#Requires -Version 5.1

<#
===============================================================================
 
File Name : Connect-Horizon.ps1
Project : Enterprise-HorizonToolkit
Author : Malik Oseni
Version : 1.1.1
 
.SYNOPSIS
    Connects to a VMware Horizon Connection Server.
 
.DESCRIPTION
    Establishes a connection to one of the configured VMware Horizon
    Connection Servers and initialises the Enterprise Horizon Toolkit
    connection state.
 
    Credential resolution order:
        1. -Credential parameter
        2. -CredentialPath parameter
        3. Credential file path from toolkit configuration
        4. Interactive Get-Credential prompt
 
.PARAMETER Server
    Optional Horizon Connection Server FQDN or IP. When supplied this server
    is tried first; configured servers follow as fallback.
 
.PARAMETER Credential
    A PSCredential object. Mutually exclusive with -CredentialPath.
 
.PARAMETER CredentialPath
    Path to a credential XML file exported with Export-Clixml.
    Mutually exclusive with -Credential.
 
.PARAMETER Force
    If the toolkit already holds an active connection, disconnect it before
    establishing a new one. Without -Force the existing connection is reused.
 
.EXAMPLE
    Connect-Horizon
 
.EXAMPLE
    Connect-Horizon -Credential (Get-Credential)
 
.EXAMPLE
    Connect-Horizon -Server horizon.example.com
 
.EXAMPLE
    Connect-Horizon -CredentialPath <path-to-credential-file> -Force
 
.NOTES
 
Enterprise-HorizonToolkit
Copyright (c) 2026 Malik Oseni
 
Change log
----------
1.1.0 Fix #1 -Credential and -CredentialPath are now properly mutually
               exclusive via named parameter sets.
       Fix #2 Guard against orphaned sessions: reuse an active connection
               unless -Force is supplied; if -Force is supplied, call
               Disconnect-HVServer before reconnecting.
       Fix #3 Raise a clear, early error when no connection servers are
               resolvable from either the parameter or configuration.
       Fix #5 Collect per-server failure reasons and include them in the
               final throw so the caller has full context.
 
===============================================================================
#>


Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Connect-Horizon {

    [CmdletBinding(DefaultParameterSetName = 'Interactive')]

    param(

        # ── Server selection ────────────────────────────────────────────────

        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(ParameterSetName = 'WithCredential')]
        [Parameter(ParameterSetName = 'CredentialFile')]
        [string]
        $Server,

        # ── Credential sources (mutually exclusive) ─────────────────────────

        # FIX #1: $Credential now belongs to its own named set so it cannot
        # be combined with -CredentialPath at parse time.

        [Parameter(
            Mandatory,
            ParameterSetName = 'WithCredential'
        )]
        [System.Management.Automation.PSCredential]
        $Credential,

        [Parameter(
            Mandatory,
            ParameterSetName = 'CredentialFile'
        )]
        [ValidateScript({
                if (-not (Test-Path $_.FullName)) {
                    throw "Credential file not found: $($_.FullName)"
                }
                $true
            })]
        [System.IO.FileInfo]
        $CredentialPath,

        # ── Behaviour flags ─────────────────────────────────────────────────

        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(ParameterSetName = 'WithCredential')]
        [Parameter(ParameterSetName = 'CredentialFile')]
        [switch]
        $Force

    )

    begin {

        Write-HorizonLog `
            -Message 'Starting Connect-Horizon.' `
            -Level Information

        Write-Verbose 'Loading toolkit configuration.'

        $Configuration = Import-HorizonConfiguration

        # ── Verify Horizon PowerCLI ──────────────────────────────────────────

        #
        # Initialise toolkit state
        #

        if (-not (Get-Variable Toolkit -Scope Script -ErrorAction SilentlyContinue)) {

            Write-Verbose 'Initialising toolkit state.'

            $script:Toolkit = [PSCustomObject]@{

                Connection      = $null

                Services        = $null

                ConnectedServer = $null

                ConnectedAt     = $null

                Configuration   = $Configuration

                Version         = '1.1.1'

            }

        }

        $PowerCLIModule = Get-Module `
            -ListAvailable `
            -Name VMware.VimAutomation.HorizonView

        if (-not $PowerCLIModule) {
            throw 'VMware.VimAutomation.HorizonView module is not installed.'
        }

        Write-Verbose "Detected VMware Horizon PowerCLI version $($PowerCLIModule.Version)"

        # ── Guard against orphaned / duplicate sessions ─────────────────────
        #
        # If the toolkit already holds a live connection object, either reuse
        # it (default) or disconnect it before proceeding (-Force).
        # Without this guard, calling Connect-Horizon a second time would
        # silently overwrite $script:Toolkit.Connection, leaving the previous
        # HVServer session dangling inside $global:DefaultHVServers.
        $ReuseExistingConnection = $false

        if ($script:Toolkit.Connection -and $script:Toolkit.Connection.IsConnected) {

            if (-not $Force) {

                Write-Verbose 'Reusing existing Horizon connection.'

                Write-HorizonLog `
                    -Message "Active connection to [$($script:Toolkit.ConnectedServer)] reused. Use -Force to reconnect." `
                    -Level Information

                # `return` from a begin block does not prevent PowerShell from
                # running the process block. Defer the cached result to the
                # start of process so no credential is resolved and no second
                # connection is made when the session is being reused.
                $ReuseExistingConnection = $true

            }


            Write-Verbose "-Force specified. Disconnecting existing session from [$($script:Toolkit.ConnectedServer)]."

            Write-HorizonLog `
                -Message "Disconnecting existing session from [$($script:Toolkit.ConnectedServer)] before reconnecting." `
                -Level Information

            try {

                Disconnect-HVServer `
                    -Server $script:Toolkit.Connection `
                    -Confirm:$false `
                    -ErrorAction SilentlyContinue

            }
            catch {

                # Non-fatal — the session may already have timed out on the
                # server side. Log and continue.
                Write-HorizonLog `
                    -Message "Disconnect warning: $($_.Exception.Message)" `
                    -Level Warning

            }
            finally {

                $script:Toolkit.Connection = $null
                $script:Toolkit.Services = $null
                $script:Toolkit.ConnectedServer = $null
                $script:Toolkit.ConnectedAt = $null

            }

        }

        # ── Build the ordered server list ────────────────────────────────────

        if (-not [string]::IsNullOrWhiteSpace($Server)) {

            # Requested server first, then any configured fallbacks not
            # already in the list.
            $ConnectionServers = [System.Collections.Generic.List[string]]::new()
            $ConnectionServers.Add($Server)

            foreach ($ConfiguredServer in $Configuration.Horizon.ConnectionServers) {

                if ($ConfiguredServer -notin $ConnectionServers) {

                    $ConnectionServers.Add($ConfiguredServer)

                }

            }

        }
        else {

            $ConnectionServers = $Configuration.Horizon.ConnectionServers

        }

        # ── FIX #3: Fail early when no servers are available ─────────────────
        #
        # Previously the foreach loop would silently do nothing if the list
        # was null/empty, producing a misleading "Unable to connect" message
        # that implied connection attempts had been made.

        if (-not $ConnectionServers -or $ConnectionServers.Count -eq 0) {

            throw (
                'No Horizon Connection Servers are configured and none was ' +
                'supplied via -Server. Add at least one entry to ' +
                'Horizon.ConnectionServers in the toolkit configuration.'
            )

        }

        Write-Verbose "Connection order: $($ConnectionServers -join ', ')"

    }

    process {

        if ($ReuseExistingConnection) {

            [PSCustomObject]@{
                PSTypeName       = 'Enterprise.Horizon.Connection'
                ConnectionServer = $script:Toolkit.ConnectedServer
                User             = "$($script:Toolkit.Connection.Domain)\$($script:Toolkit.Connection.User)"
                ServiceUri       = $script:Toolkit.Connection.ServiceUri
                Connected        = $script:Toolkit.Connection.IsConnected
                ConnectedAt      = $script:Toolkit.ConnectedAt
                ModuleVersion    = $script:Toolkit.Version
                HorizonVersion   = $PowerCLIModule.Version.ToString()
                Reused           = $true
            }

            return

        }

        # ── Resolve credentials ───────────────────────────────────────────────
        #
        # Parameter sets enforce mutual exclusivity for -Credential vs
        # -CredentialPath at parse time (Fix #1). The credential object
        # itself is resolved here for the file-path and interactive paths.

        if ($PSCmdlet.ParameterSetName -eq 'CredentialFile') {

            Write-Verbose "Importing credential from $($CredentialPath.FullName)"

            try {

                $Credential = Import-Clixml -Path $CredentialPath.FullName -ErrorAction Stop

            }
            catch {

                throw (
                    "Unable to import credential file [$CredentialPath]. " +
                    'Credential XML files are encrypted for the Windows user and computer that created them. ' +
                    'Create the file again with Export-Clixml under this account, or use -Credential.'
                )

            }

        }
        elseif ($PSCmdlet.ParameterSetName -eq 'Interactive') {

            # Try the credential file path from configuration before prompting.

            $ConfigCredPath = $Configuration.Horizon.CredentialPath

            if (
                -not [string]::IsNullOrWhiteSpace($ConfigCredPath) -and
                (Test-Path $ConfigCredPath)
            ) {

                Write-Verbose 'Importing configured credential.'

                try {

                    $Credential = Import-Clixml -Path $ConfigCredPath -ErrorAction Stop

                }
                catch {

                    Write-HorizonLog `
                        -Message "Unable to import configured credential file [$ConfigCredPath]: $($_.Exception.Message). Prompting for credentials instead." `
                        -Level Warning

                    $Credential = Get-Credential -Message 'Enter VMware Horizon credentials'

                }

            }
            else {

                Write-Verbose 'Prompting for Horizon credentials.'

                $Credential = Get-Credential -Message 'Enter VMware Horizon credentials'

            }

        }
        # For the 'WithCredential' parameter set, $Credential is already bound.

        if ($Credential -isnot [System.Management.Automation.PSCredential]) {

            throw 'The supplied credential source did not contain a PSCredential object.'

        }

        # ── Attempt connections ───────────────────────────────────────────────
        #
        # FIX #5: Collect individual failure reasons so the final throw can
        # surface all of them instead of just a generic "unable to connect."

        $Connection = $null
        $ConnectedServer = $null
        $FailureReasons = [System.Collections.Generic.List[string]]::new()

        foreach ($CurrentServer in $ConnectionServers) {

            try {

                Write-HorizonLog `
                    -Message "Attempting connection to [$CurrentServer]." `
                    -Level Information

                Write-Verbose "Connecting to $CurrentServer"

                # -ErrorAction Stop is kept explicitly here to make the
                # intent clear to future maintainers, even though
                # $ErrorActionPreference is already 'Stop'.
                $Connection = Connect-HVServer `
                    -Server $CurrentServer `
                    -Credential $Credential `
                    -ErrorAction Stop

                $ConnectedServer = $CurrentServer

                Write-HorizonLog `
                    -Message "Successfully connected to [$CurrentServer]." `
                    -Level Information

                break

            }
            catch {

                $Reason = "$CurrentServer - $($_.Exception.Message)"

                $FailureReasons.Add($Reason)

                Write-HorizonLog `
                    -Message "Connection to [$CurrentServer] failed: $($_.Exception.Message)" `
                    -Level Warning

                Write-Verbose "Failed: $($_.Exception.Message)"

            }

        }

        # ── Validate that we actually connected ───────────────────────────────

        if (-not $Connection) {

            $FailureSummary = $FailureReasons -join '; '

            throw "Unable to connect to any configured Horizon Connection Server. Failures: $FailureSummary"

        }

        # ── Cache toolkit state ───────────────────────────────────────────────

        $ConnectedAt = Get-Date

        $script:Toolkit.Connection = $Connection
        $script:Toolkit.Services = $Connection.ExtensionData
        $script:Toolkit.ConnectedServer = $ConnectedServer
        $script:Toolkit.ConnectedAt = $ConnectedAt

        Write-Verbose 'Toolkit connection cache updated.'

        Write-HorizonLog `
            -Message 'Connection cache initialised.' `
            -Level Information

        # ── Build and emit the output object ──────────────────────────────────

        [PSCustomObject]@{

            PSTypeName       = 'Enterprise.Horizon.Connection'
            ConnectionServer = $ConnectedServer
            User             = "$($Connection.Domain)\$($Connection.User)"
            ServiceUri       = $Connection.ServiceUri
            Connected        = $Connection.IsConnected
            ConnectedAt      = $ConnectedAt
            ModuleVersion    = $script:Toolkit.Version
            HorizonVersion   = $PowerCLIModule.Version.ToString()
            Reused           = $false

        }

    }

    end {

        Write-Verbose 'Connect-Horizon completed.'

    }

}