Nexthink-Citrix-Connector.psm1

<#PSScriptInfo
.VERSION 1.7.2
.GUID 8249e67f-497d-4cef-aed1-4b2ccf56f974
.AUTHOR integration.madrid@nexthink.com
.COMPANYNAME Nexthink
.PROJECTURI https://github.com/nexthink/connectors-platform.copla-citrix-onprem-connector
#>


<#
.DESCRIPTION
This script gathers information from Citrix devices and send it to Enrichment API
#>


# End of parameters definition
$env:Path = "$env:SystemRoot\system32;$env:SystemRoot;$env:SystemRoot\System32\Wbem;$env:SystemRoot\System32\WindowsPowerShell\v1.0\"

#
# Constants definition
#
New-Variable -Name 'LOG_FORMAT' -Value "[%{timestamp:+yyyy-MM-dd HH:mm:ss.fffffzzz}][%{level:-7}][%{lineno:3}] %{message}" -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_RETENTION_DAYS' -Value 7 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'LOG_LEVEL' -Value 'INFO' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'CITRIX_ENV_CONFIG_NOT_FOUND' -Value 'Configuration for Citrix environment not found' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'MISSING_FIELDS' -Value 'Missing required fields' -Option ReadOnly -Scope Script -Force

# CITRIX REST API
New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value 'localhost' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'FULL_ENRICHMENT_QUERY' `
    -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' +
        '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' +
        '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null)') `
    -Option ReadOnly -Scope Script -Force
New-Variable -Name 'DELTA_ENRICHMENT_QUERY' `
    -Value ('http://{0}/citrix/monitor/odata/v4/data/Machines?$select=DnsName,HostingServerName,IsPendingUpdate' +
        '&$expand=DesktopGroup($select=Name,DesktopKind,SessionSupport),Hypervisor($select=Name),Catalog($select=ProvisioningSchemeId)' +
        '&$filter=(DnsName ne null) and (DnsName ne '''') and (DesktopGroup ne null) and (PoweredOnDate ge {1})') `
    -Option ReadOnly -Scope Script -Force

# Buffer to cover delays in reporting data from Citrix
# Use a configurable(constant) buffer added to the last delta retrieval time to filter PoweredOnDate, compensating for unknown event/API latency and clock misalignments.
New-Variable -Name 'DELTA_BUFFER_IN_SECONDS' -Value 10 -Option ReadOnly -Scope Script -Force

# HYPERVISOR TYPE & VIRTUALIZATION TYPE
New-Variable -Name 'UNKNOWN' -Value 0 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'SHARED' -Value 1 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'PERSONAL' -Value 2 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'POOLED' -Value 3 -Option ReadOnly -Scope Script -Force

# DESKTOP BROKER
New-Variable -Name 'CITRIX_CVAD' -Value 1 -Option ReadOnly -Scope Script -Force

# ENRICHMENT API
New-Variable -Name 'ENRICHMENT_API_HOST' -Value 'api.eu-west-3.dev.nexthink.cloud' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value 'nxt-citrix-credentials' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'JWT_URL' -Value 'https://{0}/api/v1/token' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'ENRICHMENT_URL' -Value 'https://{0}/api/v1/enrichment/data/fields' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'REQUEST_BATCH_SIZE' -Value 1000 -Option ReadOnly -Scope Script -Force

# EXIT CODES
New-Variable -Name 'EXIT_CODE_OK' -Value 0 -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXIT_CODE_ERROR' -Value 1 -Option ReadOnly -Scope Script -Force

# TRACE ID
New-Variable -Name 'TRACE_ID_HEADER' -Value 'x-enrichment-trace-id' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'TRACE_ID_VALUE' -Value ([Guid]::NewGuid()) -Option ReadOnly -Scope Script -Force

# JOBS
New-Variable -Name 'GET_HYPERVISOR_TYPES_JOB' -Value 'Get-HypervisorTypes' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'GET_DISK_IMAGES_JOB' -Value 'Get-DiskImages' -Option ReadOnly -Scope Script -Force

# TIMESTAMP
New-Variable -Name 'TIMESTAMP' -Value '1970-01-01T00:00:00Z' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'EXECUTION_START_DATE' -Value (Get-Date) -Option ReadOnly -Scope Script -Force


#
# Invoke Main
#
function Invoke-Main {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )
    
    $Version = "1.7.2"
    $exitCode = $EXIT_CODE_OK
    $StoreFolder = "$ScriptRootPath\Store"
    try {
        Initialize-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment -ScriptRootPath $ScriptRootPath -StoreFolder $StoreFolder
        Write-CustomLog -Message "Starting Citrix connector $Version for environment $CitrixEnvironment" -Severity 'INFO'
        Start-EnrichmentProcess -CitrixEnvironment $CitrixEnvironment -StoreFolder $StoreFolder
    } catch {
        Write-CustomLog -Message "The execution stopped unexpectedly. Details: $($_.Exception.Message)" -Severity 'ERROR'
        $exitCode = $EXIT_CODE_ERROR
    }
    Write-CustomLog -Message "Stopping Citrix connector $Version with exit code $exitCode`n" -Severity 'INFO'
    Wait-Logging
    return $exitCode
}

function Initialize-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath,
        [Parameter(Mandatory = $true)]
        [string]$StoreFolder
    )
    
    # Initialize path-dependent variables
    $logsFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Logs") -ChildPath $CitrixEnvironment
    $configFileFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Config") -ChildPath "config.json"
    
    New-Variable -Name 'SCRIPT_FOLDER' -Value $ScriptRootPath -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGS_FOLDER' -Value $logsFolder -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOGFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "CitrixConnector-%{+yyyyMMdd}.log") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ZIPFILE_NAME' -Value (Join-Path -Path $logsFolder -ChildPath "RotatedLogs.zip") -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CONFIG_FILE_NAME' -Value $configFileFolder -Option ReadOnly -Scope Script -Force
    
    Initialize-Folder -Path $logsFolder
    Initialize-Folder -Path $StoreFolder
    Initialize-Logger
    Get-ConfigData -CitrixEnvironment $CitrixEnvironment -ConfigFilePath $configFileFolder
    # Needed to update log level after reading it from the configuration
    Initialize-Logger
    New-Variable -Name 'TIMESTAMP' -Value $(Get-Date -Format o) -Option ReadOnly -Scope Script -Force
}

function Test-ShouldRunFullScan {
    param(
        [Parameter(Mandatory = $true)]
        [string]$LastFullRun
    )

    try {
        $lastRunTime = Get-StringToDate -DateString $LastFullRun
        $hourAgo = (Get-NowDateInUTC).AddHours(-1)
        $isOlderThanHour = $lastRunTime -lt $hourAgo
        Write-CustomLog -Message "Last full run ($lastRunTime) was $(if ($isOlderThanHour) { 'more' } else { 'less' }) than an hour ago ($hourAgo)" -Severity 'INFO'
        return $isOlderThanHour
    }
    catch {
        Write-CustomLog -Message "Error checking last full run timestamp: $($_.Exception.Message)" -Severity 'ERROR'
        # If there's an error reading/parsing the timestamp, consider it as older than an hour
        return $true
    }
}

function Start-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$StoreFolder
    )
    $nextLink = $null
    $hypervisorTypes = Get-HypervisorTypes
    $diskImages = Get-DiskImages
    $StoreFilePath = "$StoreFolder\timestamps.json"
    $timestampsFromFileStore = Read-TimestampFile -FilePath $StoreFilePath

    $devices = @()
    do {
        $isFullScan = $true
        if ($null -ne $timestampsFromFileStore -and $null -ne $timestampsFromFileStore.last_full_run) {
            $isFullScan = Test-ShouldRunFullScan -LastFullRun $timestampsFromFileStore.last_full_run
        }
        $citrixQuery = if ($isFullScan) {
            [String]::Format($FULL_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST)
        } else {
            Get-MachineDeltaQuery -LastDeltaRun $timestampsFromFileStore.last_delta_run
        }
        $citrixResponse = Read-ApiCitrixData -CitrixQuery $citrixQuery
        $nextLink = $citrixResponse.nextLink
        $devices = $devices + $citrixResponse.devices
    } while ($nextLink)
    $totalDevicesToSend = $devices.Count

    if (!$totalDevicesToSend) {
        return
    }

    Write-CustomLog -Message "$totalDevicesToSend device(s) retrieved in total from Citrix API." -Severity 'INFO'
    for ($devicesOffset = 0; $devicesOffset -lt $totalDevicesToSend; $devicesOffset += $REQUEST_BATCH_SIZE) {
        $deviceLimit = [Math]::Min($totalDevicesToSend - 1, $devicesOffset + $REQUEST_BATCH_SIZE - 1)
        $fullCitrixData = Expand-CitrixDataToSend -CitrixData $devices[$devicesOffset..$deviceLimit] -HypervisorTypes $hypervisorTypes -DiskImages $diskImages
        Send-EnrichmentRequest -FullCitrixData $fullCitrixData -FullScan $isFullScan -CitrixEnvironment $CitrixEnvironment
    }

    $lastFullRun = $timestampsFromFileStore.last_full_run;
    $now = Get-NowDateInUTCAsString
    if ($isFullScan) {
        $lastFullRun = $now;
    }
    # Always set lastDeltaRun to now, if it was a full run means the delta data is included
    # Should write all timestamps in Universal Time
    Write-TimestampFile -FilePath $StoreFilePath -LastFullRun $lastFullRun -LastDeltaRun $now
}

function Get-MachineDeltaQuery {
    param(
        [Parameter(Mandatory = $true)]
        [string]$LastDeltaRun
    )
    $LastDeltaRunDate = Get-StringToDate -DateString $LastDeltaRun
    $lastDeltaRunWithBuffer = Get-DateAsString -Date ($LastDeltaRunDate.AddSeconds(-$DELTA_BUFFER_IN_SECONDS))
    Write-CustomLog -Message "Retrieving delta data only. Powered on Machines since [$lastDeltaRunWithBuffer]" -Severity 'INFO'
    return [String]::Format($DELTA_ENRICHMENT_QUERY, $CITRIX_DIRECTOR_HOST, $lastDeltaRunWithBuffer)
}

function Read-ApiCitrixData ([String]$CitrixQuery) {
    try {
        $citrixUserCredentials = Get-CitrixCredentials

        $citrixResponse = Invoke-WebRequest -Credential $citrixUserCredentials -Uri $CitrixQuery -UseBasicParsing
        $citrixResponseContent = ConvertFrom-Json $([String]::new($citrixResponse.Content))
        $nextLink = $citrixResponseContent.'@odata.nextLink'
        $listCitrixDevices = $citrixResponseContent.value
    } catch [Net.WebException], [IO.IOException] {
        Write-CustomLog "Error sending request to Citrix API. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR"
        throw "Unable to access Citrix API. Details: $($_.Exception.Message)"
    } catch {
        throw "Error retrieving devices from Citrix API. Details: $($_.toString())"
    }
    Write-CustomLog -Message "$($listCitrixDevices.Count) device(s) retrieved from Citrix API." -Severity 'DEBUG'
    return @{ devices = $listCitrixDevices; nextLink = $nextLink }
}

function Get-CitrixCredentials {
    return $(Get-StoredCredential -Target $CITRIX_CREDENTIALS_NAME)
}

#
# Local Store
#
function Write-TimestampFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath,
        [Parameter(Mandatory = $true)]
        [string]$LastFullRun,
        [Parameter(Mandatory = $true)]
        [string]$LastDeltaRun
    )
    try {
        $timestamps = @{
            last_full_run = $LastFullRun
            last_delta_run = $LastDeltaRun
        }

        # Convert to JSON and write to file
        $jsonContent = $timestamps | ConvertTo-Json
        Write-CustomLog -Message "Updating timestamps in store file - Last Full Run: [$($LastFullRun)], Last Delta Run: [$($LastDeltaRun)]" -Severity 'INFO'
        $jsonContent | Out-File -FilePath $FilePath -Force -Encoding UTF8

        Write-CustomLog -Message "Successfully wrote timestamps to $FilePath" -Severity 'INFO'
    }
    catch {
        Write-CustomLog -Message "Failed to write timestamps to file: $($_.Exception.Message)" -Severity 'ERROR'
        throw "Failed to write timestamps to file: $($_.Exception.Message)"
    }
}
function Read-TimestampFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath
    )
    try {
        if (Test-Path $FilePath) {
            $content = Get-Content -Path $FilePath -Raw | ConvertFrom-Json
            return @{
                last_full_run = $content.last_full_run
                last_delta_run = $content.last_delta_run
            }
        }
        else {
            Write-CustomLog -Message "Timestamp file not found at $FilePath. If this is the first run, this is expected and the file will be created." -Severity 'INFO'
            return $null
        }
    }
    catch {
        Write-CustomLog -Message "Failed to read timestamps from file: $($_.Exception.Message)" -Severity 'ERROR'
        throw "Failed to read timestamps from file: $($_.Exception.Message)"
    }
}

#
# Date functions
#
function Get-StringToDate {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DateString
    )
    return ([DateTime]::ParseExact($DateString, "yyyy-MM-ddTHH:mm:ssZ", [System.Globalization.CultureInfo]::InvariantCulture)).ToUniversalTime()
}

function Get-NowDateInUTC {
    return (Get-Date).ToUniversalTime()
}

function Get-DateAsString {
    param(
        [Parameter(Mandatory = $true)]
        [DateTime]$Date
    )
    return $Date.ToString("yyyy-MM-ddTHH:mm:ssZ")
}

function Get-NowDateInUTCAsString {
    return Get-DateAsString -Date $(Get-NowDateInUTC)
}

#
# Logging
#
function Write-CustomLog ([String]$Message, [String]$Severity = 'INFO') {
    Write-Log -Message $Message -Level $Severity
}

function Initialize-Folder ([String]$Path) {
    try {
        if (-not (Test-Path -Path $Path)) {
            [Void](New-Item -Path $Path -ItemType 'Directory' -Force -ErrorAction Stop)
        }
    } catch {
        throw "Error creating folder at $Path."
    }
}

function Initialize-Logger {
    Add-LoggingTarget -Name File -Configuration @{
        Path              = $LOGFILE_NAME
        Encoding          = 'unicode'
        Level             = $LOG_LEVEL
        Format            = $LOG_FORMAT
        RotateAfterAmount = $LOG_RETENTION_DAYS
        RotateAmount      = 1
        CompressionPath   = $ZIPFILE_NAME
    }
    Set-LoggingCallerScope 2
}

#
# Config functions
#
function Get-ConfigData {
    param(
        [Parameter(Mandatory = $true)]
        [string]$CitrixEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )
    
    $configData = Read-ConfigFile -ConfigFilePath $ConfigFilePath
    $citrixEnvironmentConfigData = Get-CitrixEnvironmentConfigData -ConfigData $configData -CitrixEnvName $CitrixEnvironment

    if ($null -eq $citrixEnvironmentConfigData) {
        throw "$CITRIX_ENV_CONFIG_NOT_FOUND"
    }

    if (-not (Test-ConfigData -ConfigData $configData -CitrixEnvConfigData $citrixEnvironmentConfigData)) {
        throw "$MISSING_FIELDS"
    }

    New-Variable -Name 'LOG_RETENTION_DAYS' -Value $configData.Logging.LogRetentionDays -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'LOG_LEVEL' -Value $configData.Logging.LogLevel -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_DIRECTOR_HOST' -Value $citrixEnvironmentConfigData.CitrixDirectorFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_CONTROLLER_HOST' -Value $citrixEnvironmentConfigData.CitrixControllerFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'CITRIX_CREDENTIALS_NAME' -Value $citrixEnvironmentConfigData.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'ENRICHMENT_API_HOST' -Value $configData.NexthinkAPI.HostFQDN -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'TARGET_CREDENTIALS_NAME' -Value $configData.NexthinkAPI.WindowsCredentialEntry -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'REQUEST_BATCH_SIZE' -Value $configData.NexthinkAPI.RequestBatchSize -Option ReadOnly -Scope Script -Force
}

function Read-ConfigFile {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )
    try {
        return (Get-Content "$ConfigFilePath" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
    } catch {
        throw "Error loading config file. Details: $($_.toString())"
    }
}

function Get-CitrixEnvironmentConfigData($ConfigData, $CitrixEnvName) {
    return $ConfigData.CitrixEnvironments | Where-Object { $_.Name -eq $CitrixEnvName }
}

function Test-ConfigData($ConfigData, $CitrixEnvConfigData) {
    return ($ConfigData.Logging.LogRetentionDays -and $ConfigData.Logging.LogLevel -and $ConfigData.NexthinkAPI.HostFQDN -and `
            $ConfigData.NexthinkAPI.WindowsCredentialEntry -and $ConfigData.NexthinkAPI.RequestBatchSize -and `
            $CitrixEnvConfigData.Name -and $CitrixEnvConfigData.CitrixDirectorFQDN -and $CitrixEnvConfigData.CitrixControllerFQDN -and `
            $CitrixEnvConfigData.WindowsCredentialEntry)
}

#
# Citrix functions
#
function Set-HypervisorType([Object[]]$CitrixData, [hashtable]$HypervisorTypes) {
    if ($HypervisorTypes.Count) {
        foreach ($device in $CitrixData) {
            $hypervisorType = ""
            if ($null -eq $device.Hypervisor -or $null -eq $device.Hypervisor.Name) {
                $device.Hypervisor = [PSCustomObject]@{}
            } else {
                $hypervisorType = $HypervisorTypes[$device.Hypervisor.Name]
            }
            $device.Hypervisor | Add-Member -NotePropertyName Type -NotePropertyValue $hypervisorType
        }
    }

    return $CitrixData
}

function Get-HypervisorTypes() {
    [hashtable]$hypervisorTypes = @{}
    try {
        $hypervisorTypesList = Get-HypervisorTypesFromBroker
        if (-not $hypervisorTypesList) {
            throw
        }
        foreach ($hypervisorType in $hypervisorTypesList) {
            $hypervisorTypes[$hypervisorType.Name] = $hypervisorType.HypHypervisorType
            if ('' -eq $hypervisorType.HypHypervisorType) {
                Write-CustomLog -Message "Empty hypervisor type for hypervisor '$($hypervisorType.Name)'" -Severity 'WARNING'
            }
        }
    } catch {
        Write-CustomLog -Message "Error retrieving hypervisors. Hypervisor name won't be enriched." -Severity 'ERROR'
    }

    return $hypervisorTypes
}

function Get-HypervisorTypesFromBroker() {
    $scriptBlock = { Add-PSSnapin -Name Citrix.Broker* -ErrorAction SilentlyContinue;
        (Get-BrokerHypervisorConnection -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property Name, HypHypervisorType) }

    [PSCredential]$citrixUserCredentials = Get-CitrixCredentials

    [Void](Start-Job -Name $GET_HYPERVISOR_TYPES_JOB -ScriptBlock $scriptBlock -Credential $citrixUserCredentials)
    [Void](Wait-Job -Name $GET_HYPERVISOR_TYPES_JOB)
    return Receive-Job -Name $GET_HYPERVISOR_TYPES_JOB
}

function Set-DiskImage([Object[]]$CitrixData, [hashtable]$DiskImages) {
    if ($DiskImages.Count) {
        foreach ($device in $CitrixData) {
            $diskImage = ""
            if ($null -eq $device.Catalog -or $null -eq $device.Catalog.ProvisioningSchemeId) {
                $device.Catalog = [PSCustomObject]@{}
            } else {
                $fullDiskImage = $DiskImages[$device.Catalog.ProvisioningSchemeId]
                $diskImage = if ($null -ne $fullDiskImage) { $fullDiskImage.Split('\')[-1] } else { $null }
            }
            $device.Catalog | Add-Member -NotePropertyName DiskImage -NotePropertyValue $diskImage
        }
    }

    return $CitrixData
}

function Get-DiskImages() {
    [hashtable]$diskImages = @{}
    try {
        $provSchemesList = Get-DiskImagesFromProvScheme
        if (-not $provSchemesList) {
            throw
        }
        foreach ($provScheme in $provSchemesList) {
            $diskImages[[String]$provScheme.ProvisioningSchemeUid] = $provScheme.MasterImageVM
            if ('' -eq $provScheme.MasterImageVM) {
                Write-CustomLog -Message "Empty disk image for prov scheme '$($provScheme.ProvisioningSchemeUid)'" -Severity 'WARNING'
            }
        }
    } catch {
        Write-CustomLog -Message "Error retrieving disk images. Disk image won't be enriched." -Severity 'ERROR'
    }

    return $diskImages
}

function Get-DiskImagesFromProvScheme() {
    $scriptBlock = { Add-PSSnapin -Name Citrix.Broker* -ErrorAction SilentlyContinue;
    (Get-ProvScheme -AdminAddress $Using:CITRIX_CONTROLLER_HOST | Select-Object -Property ProvisioningSchemeUid, MasterImageVM) }

    [PSCredential]$citrixUserCredentials = Get-CitrixCredentials

    [Void](Start-Job -Name $GET_DISK_IMAGES_JOB -ScriptBlock $scriptBlock -Credential $citrixUserCredentials)
    [Void](Wait-Job -Name $GET_DISK_IMAGES_JOB)
    return Receive-Job -Name $GET_DISK_IMAGES_JOB
}

function Expand-CitrixDataToSend ([Object[]]$CitrixData, [hashtable]$HypervisorTypes, [hashtable]$DiskImages) {
    $citrixDataWithHypervisor = Set-HypervisorType -CitrixData $CitrixData -HypervisorTypes $HypervisorTypes
    return Set-DiskImage -CitrixData $citrixDataWithHypervisor -DiskImages $DiskImages
}

function Send-EnrichmentRequest ([Object[]]$FullCitrixData, [Boolean]$FullScan, [String]$CitrixEnvironment) {
    # Time in milliseconds since the script started up to when the enrichment request is sent
    $executionDuration = [Int](((Get-Date) - $EXECUTION_START_DATE).TotalMilliseconds)
    $clientTelemetry = Get-ClientTelemetry -ExecutionDuration $executionDuration -FullScan $FullScan

    $jwt = Get-Jwt
    $enrichmentEndpoint = [String]::Format($ENRICHMENT_URL, $ENRICHMENT_API_HOST)

    $enrichmentBody = Get-JsonFromCitrixData -CitrixData $FullCitrixData -ClientTelemetry $clientTelemetry -CitrixEnvironment $CitrixEnvironment
    $response = Invoke-SendDataToEnrichmentAPI -EndpointURL $enrichmentEndpoint -JsonPayload $enrichmentBody -Jwt $jwt
    switch ( $response.statusCode ) {
        '200' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices successfully processed by Enrichment API." -Severity 'INFO'
        }
        '207' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullCitrixData.Count) devices partially processed by Enrichment API." -Severity 'INFO'
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Partial success response: $($response.content)" -Severity 'INFO'
        }
        default {
            $message = "[$TRACE_ID_VALUE] Error sending request to Enrichment API with status code: $($response.statusCode)"
            Write-CustomLog -Message $message -Severity 'ERROR'
            throw $message
        }
    }
}

function Get-Jwt () {
    $jwtUrl = [String]::Format($JWT_URL, $ENRICHMENT_API_HOST)
    if (-not (Test-ValidWebUrl($jwtUrl))) {
        throw "Invalid URL to retrieve the token: $jwtUrl"
    }

    try {
        $credentials = Get-ClientCredentials -Target $TARGET_CREDENTIALS_NAME

        $basicHeader = Get-StringAsBase64 -InputString "$($credentials.clientId):$($credentials.clientSecret)"
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Authorization', "Basic $basicHeader")
        $response = Invoke-WebRequest -Uri $jwtUrl -Method 'POST' -Headers $headers -UseBasicParsing
        $parsedResponse = ConvertFrom-Json $([String]::new($response.Content))
        return $parsedResponse.access_token
    } catch [Net.WebException], [IO.IOException] {
        Write-CustomLog "Error sending request to get the JWT token. Details: [$($_.Exception.response.StatusCode.value__)] $($_.ErrorDetails)" -Severity "ERROR"
        throw "Unable to access token endpoint. Details: $($_.Exception.Message)"
    } catch {
        throw "An error occurred that could not be resolved. Details: $($_.Exception.Message)"
    }
}

function Test-ValidWebUrl($UrlToValidate) {
    $uri = $UrlToValidate -as [System.Uri]
    $null -ne $uri.AbsoluteURI -and $uri.Scheme -match '[http|https]'
}

function Get-ClientCredentials ([String]$Target) {
    $storedCredentials = Get-StoredCredential -Target $Target
    if ($storedCredentials -and $null -ne $storedCredentials.UserName -and $null -ne $storedCredentials.Password ) {
        $userName = $storedCredentials.UserName
        $securePassword = $storedCredentials.Password
        $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword)
        $unsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

        return @{ clientId = $userName; clientSecret = $unsecurePassword }
    } else {
        throw "Credentials not found or they are empty for Target: $Target"
    }
}

function Get-StringAsBase64 ([String]$InputString) {
    $Bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
    $EncodedText = [Convert]::ToBase64String($Bytes)
    return $EncodedText
}

function Get-JsonFromCitrixData ([Object[]]$CitrixData, [PSCustomObject]$ClientTelemetry, [String]$CitrixEnvironment) {
    $jsonResult = '{"enrichments": ['
    foreach ($device in $CitrixData) {
        try {
            if (Test-MachineIsValid($device)) {
                $deviceName = Get-MachineName($device.DnsName)

                $virtualizationType = Get-VirtualizationType -DesktopKind $device.DesktopGroup.DesktopKind -SessionSupport $device.DesktopGroup.SessionSupport

                $currentRow = '{"identification":[{"name":"device/device/name","value":"' + $deviceName + '"}],'
                $currentRow = $currentRow + '"fields":[{"name":"device/device/virtualization/desktop_pool","value":"' + $device.DesktopGroup.Name + '"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/type","value": ' + $virtualizationType + '}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/hostname","value":"' + $device.HostingServerName + '"}'
                if ($null -ne $device.Hypervisor.Type) {
                    $currentRow = $currentRow + ',{"name":"device/device/virtualization/hypervisor_name","value":"' + $device.Hypervisor.Type + '"}'
                }
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/environment_name","value":"' + $CitrixEnvironment +'"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/desktop_broker","value": ' + $CITRIX_CVAD + '}'
                if ($false -eq $device.IsPendingUpdate -and $null -ne $device.Catalog.DiskImage) {
                    $currentRow = $currentRow + ',{"name":"device/device/virtualization/disk_image","value":"' + $device.Catalog.DiskImage + '"}'
                }
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/last_update","value":"' + $TIMESTAMP + '"}'
                $currentRow = $currentRow + ']},'
                $jsonResult = $jsonResult + $currentRow
            } else {
                Write-CustomLog -Message "Invalid device: '$($device | ConvertTo-Json -Compress)'" -Severity 'DEBUG'
            }
        } catch {
            Write-CustomLog -Message "Error processing device '$deviceName'. Details: $($_.Exception.Message)" -Severity 'ERROR'
        }
    }
    if ($jsonResult.EndsWith(',')) {
        $jsonResult = $jsonResult.Substring(0, $jsonResult.Length - 1)
    }

    $clientTelemetryJson = $clientTelemetry | ConvertTo-Json -Compress

    return $jsonResult + '], "domain":"citrix", "clientTelemetry":' +  $clientTelemetryJson + '}'
}

function Get-ClientTelemetry([Int]$ExecutionDuration, [Boolean]$FullScan) {
    $Version = "1.7.2" # Find a way to read from module manifest

    return [PSCustomObject]@{
        version = $Version
        executionDurationInMs = $ExecutionDuration
        fullScan = $FullScan
    }
}

function Test-MachineIsValid([PSCustomObject]$Device) {
   if ($null -ne $Device.DnsName -and
        $null -ne $Device.DesktopGroup -and
        $null -ne $Device.DesktopGroup.Name -and
        '' -ne $Device.DesktopGroup.Name -and
        $null -ne $Device.DesktopGroup.DesktopKind -and
        $null -ne $Device.DesktopGroup.SessionSupport
    ) {
        $deviceName = Get-MachineName($Device.DnsName)
        if ('' -ne $deviceName) {
            return $true
        }
    }
    return $false
}

function Get-MachineName([String]$DnsName) {
    return $DnsName.Split('.')[0].ToUpper()
}

function Get-VirtualizationType([Int]$DesktopKind, [Int]$SessionSupport) {
    $separator = '@'
    $switchKey = '' + $DesktopKind + $separator + $SessionSupport

    $result = switch ( $switchKey ) {
        '1@2' { $SHARED }
        '0@1' { $PERSONAL }
        '1@1' { $POOLED }
        default { $UNKNOWN }
    }
    return $result
}

function Invoke-SendDataToEnrichmentAPI ([String]$EndpointUrl, [String]$JsonPayload, [String]$Jwt) {
    try {
        $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $headers.Add('Content-Type', 'application/json')
        $headers.Add('Authorization', "Bearer $Jwt")
        $headers.Add($TRACE_ID_HEADER, $TRACE_ID_VALUE)

        $response = Invoke-WebRequest -Uri $EndpointUrl -Method 'POST' -Headers $headers -Body $JsonPayload -UseBasicParsing
        $statusCode = $response.StatusCode
        $content = $response.Content
    } catch {
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error sending request to Enrichment API. Details: $($_.Exception.Message)" -Severity 'ERROR'
        Write-CustomLog -Message "[$TRACE_ID_VALUE] Error message: $_" -Severity 'ERROR'
        $statusCode = $_.Exception.response.StatusCode.value__
    }
    return @{ statusCode = $statusCode; content = $content }
}

#
# Module exports - Only export when running as a module
#
try {
    Export-ModuleMember -Function Invoke-Main, Initialize-EnrichmentProcess, Start-EnrichmentProcess, Get-ConfigData, Read-ConfigFile, Get-CitrixEnvironmentConfigData, Test-ConfigData, Get-HypervisorTypes, Get-HypervisorTypesFromBroker, Get-DiskImages, Get-DiskImagesFromProvScheme, Set-HypervisorType, Set-DiskImage, Expand-CitrixDataToSend, Get-JsonFromCitrixData, Test-MachineIsValid, Get-MachineName, Get-VirtualizationType, Get-Jwt, Get-ClientCredentials, Get-StringAsBase64, Test-ValidWebUrl, Send-EnrichmentRequest, Invoke-SendDataToEnrichmentAPI, Read-ApiCitrixData, Get-CitrixCredentials, Get-MachineDeltaQuery, Test-ShouldRunFullScan, Write-TimestampFile, Read-TimestampFile, Get-StringToDate, Get-NowDateInUTC, Get-DateAsString, Get-NowDateInUTCAsString, Write-CustomLog, Initialize-Folder, Initialize-Logger
} catch [System.InvalidOperationException] {
    # Expected when dot-sourced as script during testing
} catch {
    # Silently ignore any other export errors
}