Nexthink-Omnissa-Connector.psm1

<#PSScriptInfo
.VERSION 0.1.0
.GUID 6e063c9e-4db5-4d3b-9f1d-omnissa202406
.AUTHOR integration.madrid@nexthink.com
.COMPANYNAME Nexthink
.PROJECTURI https://github.com/nexthink/vdi-experience.connector-omnissa-horizon-server
#>


<#
.DESCRIPTION
This script gathers machine and desktop pool information from Omnissa Horizon REST API and logs the results.
#>


#
# Constants definition
New-Variable -Name 'SCRIPT_VERSION' -Value "0.1.0" -Option ReadOnly -Scope Script -Force
New-Variable -Name 'START_TIME' -Value (Get-Date) -Option ReadOnly -Scope Script -Force

# LOGGING
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

# CONFIG
New-Variable -Name 'OMNISSA_ENV_CONFIG_NOT_FOUND' -Value 'Configuration for Omnissa environment not found' -Option ReadOnly -Scope Script -Force
New-Variable -Name 'MISSING_FIELDS' -Value 'Missing required fields' -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-ctx-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

# 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

# VIRTUALIZATION CONSTANTS
# horizon_on_prem = 5
New-Variable -Name 'DESKTOP_BROKER' -Value 5 -Option ReadOnly -Scope Script -Force

# CACHE
New-Variable -Name 'BASE_IMAGES_CACHE' -Value (@{}) -Option ReadOnly -Scope Script -Force
New-Variable -Name 'BASE_SNAPSHOTS_CACHE' -Value (@{}) -Option ReadOnly -Scope Script -Force

#
# Main function
#
function Invoke-Main {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    $Version = "0.1.0"
    $exitCode = $EXIT_CODE_OK
    try {
        Initialize-EnrichmentProcess -OmnissaEnvironment $OmnissaEnvironment -ScriptRootPath $ScriptRootPath
        Write-CustomLog -Message "Starting Omnissa connector $Version for environment $OmnissaEnvironment" -Severity 'INFO'
        Start-EnrichmentProcess -OmnissaEnvironment $OmnissaEnvironment
    }
    catch {
        Write-CustomLog -Message "The execution stopped unexpectedly. Details: $($_.Exception.Message)" -Severity 'ERROR'
        $exitCode = $EXIT_CODE_ERROR
    }
    Write-CustomLog -Message "Stopping Omnissa connector $Version with exit code $exitCode`n" -Severity 'INFO'
    Wait-Logging
    return $exitCode
}

function Initialize-EnrichmentProcess {
    param(
        [Parameter(Mandatory = $true)]
        [string]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    # Initialize path-dependent variables
    $logsFolder = Join-Path -Path (Join-Path -Path $ScriptRootPath -ChildPath "Logs") -ChildPath $OmnissaEnvironment
    $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 "OmnissaConnector-%{+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-Logger
    Get-ConfigData -OmnissaEnvironment $OmnissaEnvironment -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 Start-EnrichmentProcess {
    $pools = @(Get-DesktopPools)

    $machines = @(Get-Machines -Pools $pools)
    $rdsServers = @(Get-RdsServers -Pools $pools)

    $devices = $machines + $rdsServers

    $totalDevicesToSend = $devices.Count
    Write-CustomLog -Message "$totalDevicesToSend device(s) retrieved in total from Omnissa API." -Severity 'INFO'

    for ($devicesOffset = 0; $devicesOffset -lt $totalDevicesToSend; $devicesOffset += $REQUEST_BATCH_SIZE) {
        $deviceLimit = [Math]::Min($totalDevicesToSend - 1, $devicesOffset + $REQUEST_BATCH_SIZE - 1)
        $fullData = $devices[$devicesOffset..$deviceLimit]
        Send-EnrichmentRequest -FullData $fullData -OmnissaEnvironment $OmnissaEnvironment
    }
}

function Send-EnrichmentRequest ([Object[]]$FullData, [String]$OmnissaEnvironment) {
    # 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

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

    $enrichmentBody = Get-JsonFromOmnissaData -OmnissaData $FullData -ClientTelemetry $clientTelemetry -OmnissaEnvironment $OmnissaEnvironment
    $response = Invoke-SendDataToEnrichmentAPI -EndpointURL $enrichmentEndpoint -JsonPayload $enrichmentBody -Jwt $jwt
    switch ( $response.statusCode ) {
        '200' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullData.Count) devices successfully processed by Enrichment API." -Severity 'INFO'
        }
        '207' {
            Write-CustomLog -Message "[$TRACE_ID_VALUE] Batch with $($FullData.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
}

#
# Omnissa Horizon functions
#
function Get-DesktopPools {
        
    Write-CustomLog -Message "Fetching desktop pools..." -Severity 'DEBUG'
    $headers = Get-RequestHeaders

    try {
        $desktopPoolsResponse = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/inventory/v7/desktop-pools" -Headers $headers -Method Get
        $desktopPools = $desktopPoolsResponse | Where-Object { $null -ne $_.id }
        $desktopPoolsCount = $desktopPools.Count

        Write-CustomLog -Message "$desktopPoolsCount desktop pool IDs found" -Severity 'DEBUG'
        return $desktopPools
    }
    catch {
        throw "Failed to get desktop pools. Details: $($_.Exception.Message)"
    }
}

function Get-Machines {
    param (
        [Object[]]$Pools
    )

    try {
        $machinesOutput = @()

        $headers = Get-RequestHeaders
        $machinesResponse = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/inventory/v5/machines" -Headers $headers -Method Get

        $machineCount = $machinesResponse.Count
        Write-CustomLog -Message "$machineCount machines found" -Severity 'DEBUG'

        if ($machineCount -eq 0) {
            # Early return if no machines were found
            return $machinesOutput
        }

        $poolLookup = Get-ObjectLookup -KeySelector { param($p) $p.id } -Objects $Pools

        foreach ($machine in $machinesResponse) {
            $pool = $poolLookup[$machine.desktop_pool_id]
            $vCenterId = $machine.managed_machine_data.virtual_center_id
            $baseVmId = $machine.managed_machine_data.base_vm_id
            $snapshotId = $machine.managed_machine_data.base_vm_snapshot_id

            $baseImage = Get-BaseImage -VCenterId $vCenterId -BaseVmId $baseVmId -SnapshotId $snapshotId

            $machinesOutput += [PSCustomObject]@{
                MachineName    = $machine.name
                DesktopPool    = $pool.name
                PoolType       = $pool.type
                Hostname       = $machine.managed_machine_data.host_name
                UserAssignment = $pool.user_assignment
                DiskImage      = $baseImage
            }
        }

        return $machinesOutput
    }
    catch {
        throw "Failed to get machines. Details: $($_.Exception.Message)"
    }
}

function Get-RdsServers {
    param (
        [Object[]]$Pools
    )

    try {
        $rdsServersOutput = @()

        $headers = Get-RequestHeaders
        $rdsServersResponse = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/inventory/v2/rds-servers" -Headers $headers -Method Get

        $rdsServersCount = $rdsServersResponse.Count
        Write-CustomLog -Message "$rdsServersCount RDS servers found" -Severity 'DEBUG'

        if ($rdsServersCount -eq 0) {
            # Early return if there are no RDS servers
            return $rdsServersOutput
        }

        $farmsResponse = @(Get-Farms)
        $farmLookup = Get-ObjectLookup -Objects $farmsResponse -KeySelector { param($p) $p.id }
        $poolLookup = Get-ObjectLookup -KeySelector { param($p) $p.farm_id } -Objects $Pools

        foreach ($rdsServer in $rdsServersResponse) {
            $pool = $poolLookup[$rdsServer.farm_id]
            $farm = $farmLookup[$rdsServer.farm_id]

            $vCenterId = $farm.automated_farm_settings.vcenter_id
            $baseVmId = $farm.automated_farm_settings.provisioning_settings.parent_vm_id
            $snapshotId = $farm.automated_farm_settings.provisioning_settings.base_snapshot_id

            $baseImage = Get-BaseImage -VCenterId $vCenterId -BaseVmId $baseVmId -SnapshotId $snapshotId

            $rdsServersOutput += [PSCustomObject]@{
                MachineName    = $rdsServer.name
                DesktopPool    = $pool.name
                Hostname       = $rdsServer.dns_name
                UserAssignment = $pool.user_assignment
                DiskImage      = $baseImage
            }
        }

        return $rdsServersOutput
    }
    catch {
        throw "Failed to get RDS servers. Details: $($_.Exception.Message)"
    }
}

function Get-BaseImage {
    param (
        [string]$VCenterId,
        [string]$BaseVmId,
        [string]$SnapshotId
    )

    if ([string]::IsNullOrEmpty($VCenterId) -or
        [string]::IsNullOrEmpty($BaseVmId) -or
        [string]::IsNullOrEmpty($SnapshotId)) {
        Write-CustomLog -Message "Required parameters missing. Skipping base image retrieval." -Severity 'DEBUG'

        return $null
    }

    $baseImagesCacheKey = "$VCenterId"
    $baseVMLookup = Get-FromCacheOrInvoke -Cache $BASE_IMAGES_CACHE -Key $baseImagesCacheKey -Invoke { Get-BaseVmLookup -VCenterId $VCenterId }
    $baseVmPath = $baseVMLookup[$BaseVmId].path

    $baseSnapshotsCacheKey = "$VCenterId" + "_" + "$BaseVmId"
    $snapshotResponse = Get-FromCacheOrInvoke -Cache $BASE_SNAPSHOTS_CACHE -Key $baseSnapshotsCacheKey -Invoke { Get-BaseSnapshotLookup -VCenterId $VCenterId -BaseVmId $BaseVmId }

    $snaphotPath = $snapshotResponse[$SnapshotId].path

    return $baseVmPath + $snaphotPath
}

function Get-BaseVmLookup {
    param (
        [Parameter(Mandatory = $true)][string]$VCenterId
    )

    Write-CustomLog -Message "Fetching base VMs for VCenterId='$VCenterId'..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $baseVMsResponse = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/external/v3/base-vms?vcenter_id=$VCenterId" -Headers $headers -Method Get
        $baseVMsCount = $baseVMsResponse.Count

        Write-CustomLog -Message "$baseVMsCount base VMs found" -Severity 'DEBUG'

        return Get-ObjectLookup -Objects $baseVMsResponse -KeySelector { param($p) $p.id }
    }
    catch {
        throw "Failed to get base VMs. Details: $($_.toString())"
    }
}

function Get-BaseSnapshotLookup {
    param (
        [Parameter(Mandatory = $true)][string]$VCenterId,
        [Parameter(Mandatory = $true)][string]$BaseVmId
    )

    Write-CustomLog -Message "Fetching base snapshots for VCenterId='$VCenterId' and BaseVmId='$BaseVmId'..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $baseSnapshotsResponse = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/external/v2/base-snapshots?vcenter_id=$VCenterId&base_vm_id=$BaseVmId" -Headers $headers -Method Get
        $baseSnapshotsCount = $baseSnapshotsResponse.Count

        Write-CustomLog -Message "$baseSnapshotsCount base snapshots found" -Severity 'DEBUG'

        return Get-ObjectLookup -Objects $baseSnapshotsResponse -KeySelector { param($p) $p.id }
    }
    catch {
        throw "Failed to get base snapshots. Details: $($_.toString())"
    }
}

function Get-Farms {
    Write-CustomLog -Message "Fetching farms..." -Severity 'DEBUG'

    try {
        $headers = Get-RequestHeaders
        $farms = Invoke-RestMethod -Uri "$OMNISSA_HOST/rest/inventory/v7/farms" -Headers $headers -Method Get
        $farmsCount = $farms.Count

        Write-CustomLog -Message "$farmsCount farms found" -Severity 'DEBUG'
        return $farms
    }
    catch {
        throw "Failed to get farms. Details: $($_.toString())"
    }
}

function Get-RequestHeaders {
    $credentials = Get-ClientCredentials -Target $OMNISSA_CREDENTIALS_NAME
    $authBody = @{
        domain   = "NEXTHINK-OMNISSA-CONNECTOR"
        username = $credentials.clientId
        password = $credentials.clientSecret
    } | ConvertTo-Json

    try {
        $response = Invoke-WebRequest -Uri "$OMNISSA_HOST/rest/login" -Method POST -Body $authBody -ContentType "application/json" -UseBasicParsing
        $token = ($response.Content | ConvertFrom-Json).access_token

        if (-not $token) {
            throw "Authentication failed: No token returned."
        }
    }
    catch {
        throw "Authentication failed. Details: $($_.ToString())"
    }

    return @{
        "Authorization" = "Bearer $token"
        "Accept"        = "application/json"
        "Content-Type"  = "application/json"
    }
}

# Function to determine virtualization type based on input string
function Get-VirtualizationType {
    param(
        [Parameter(Mandatory = $true)]
        [string]$UserAssignment,

        [Parameter(Mandatory = $true)]
        [string]$PoolType
    )

    # Default to UNSPECIFIED (0) if no match is found
    $result = 0

    # Determine virtualization type based on UserAssignment and PoolType
    if ($UserAssignment -eq "DEDICATED") {
        # PERSONAL = 1
        $result = 1
    }
    elseif ($UserAssignment -eq "FLOATING") {
        # POOLED = 2
        $result = 2
    }
    elseif ($PoolType -eq "RDS") {
        # SHARED = 3
        $result = 3
    }

    return $result
}

#
# 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]$OmnissaEnvironment,
        [Parameter(Mandatory = $true)]
        [string]$ConfigFilePath
    )
    
    $configData = Read-ConfigFile -ConfigFilePath $ConfigFilePath
    $omnissaEnvironmentConfigData = Get-OmnissaEnvironmentConfigData -ConfigData $configData -OmnissaEnvName $OmnissaEnvironment

    if ($null -eq $omnissaEnvironmentConfigData) {
        throw "$OMNISSA_ENV_CONFIG_NOT_FOUND"
    }

    if (-not (Test-ConfigData -ConfigData $configData -OmnissaEnvConfigData $omnissaEnvironmentConfigData)) {
        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 'OMNISSA_HOST' -Value $omnissaEnvironmentConfigData.Host -Option ReadOnly -Scope Script -Force
    New-Variable -Name 'OMNISSA_CREDENTIALS_NAME' -Value $omnissaEnvironmentConfigData.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-OmnissaEnvironmentConfigData($ConfigData, $OmnissaEnvName) {
    return $ConfigData.OmnissaEnvironments | Where-Object { $_.Name -eq $OmnissaEnvName }
}

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

#
# Utilities
#
function Get-ObjectLookup {
    param(
        [Object[]]$Objects,

        [Parameter(Mandatory = $true)]
        [ScriptBlock]$KeySelector
    )

    $lookup = @{}

    foreach ($obj in $Objects) {
        $key = & $KeySelector $obj

        if ([string]::IsNullOrEmpty($key)) {
            continue
        }

        if (-not $lookup.ContainsKey($key)) {
            $lookup.Add($key, $obj)
        }
    }

    return $lookup
}

function Get-FromCacheOrInvoke {
    param (
        [hashtable]$Cache,
        [string]$Key,
        [ScriptBlock]$Invoke
    )

    if ($Cache.ContainsKey($Key)) {
        return $Cache[$Key]
    }

    $result = & $Invoke
    $Cache[$Key] = $result
    return $result
}

#
# Enrichment API
#
function Get-JsonFromOmnissaData ([Object[]]$OmnissaData, [PSCustomObject]$ClientTelemetry, [String]$OmnissaEnvironment) {
    $jsonResult = '{"enrichments": ['
    foreach ($device in $OmnissaData) {
        try {
            if (Test-MachineIsValid($device)) {
                $virtualizationType = Get-VirtualizationType -UserAssignment $device.UserAssignment -PoolType $device.PoolType
                $currentRow = '{"identification":[{"name":"device/device/name","value":"' + $device.MachineName + '"}],'
                $currentRow = $currentRow + '"fields":[{"name":"device/device/virtualization/desktop_pool","value":"' + $device.DesktopPool + '"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/type","value": ' + $virtualizationType + '}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/hostname","value":"' + $device.Hostname + '"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/environment_name","value":"' + $OmnissaEnvironment + '"}'
                $currentRow = $currentRow + ',{"name":"device/device/virtualization/desktop_broker","value": ' + $DESKTOP_BROKER + '}'
                if ($null -ne $device.DiskImage) {
                    $currentRow = $currentRow + ',{"name":"device/device/virtualization/disk_image","value":"' + $device.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 '$($device.MachineName)'. Details: $($_.Exception.Message)" -Severity 'ERROR'
        }
    }
    if ($jsonResult.EndsWith(',')) {
        $jsonResult = $jsonResult.Substring(0, $jsonResult.Length - 1)
    }

    $clientTelemetryJson = $clientTelemetry | ConvertTo-Json -Compress

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

function Get-ClientTelemetry([Int]$ExecutionDuration) {
    return [PSCustomObject]@{
        version               = $SCRIPT_VERSION
        executionDurationInMs = $ExecutionDuration
        fullScan              = "true"
    }
}

function Test-MachineIsValid([PSCustomObject]$Device) {
    if ($null -ne $Device.MachineName -and
        $null -ne $Device.DesktopPool -and
        $null -ne $Device.Hostname
    ) {
        return $true
    }
    return $false
}

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 *
}
catch [System.InvalidOperationException] {
    # Expected when dot-sourced as script during testing
}
catch {
    # Silently ignore any other export errors
}
# SIG # Begin signature block
# MIIpiAYJKoZIhvcNAQcCoIIpeTCCKXUCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCyTCxiRbkVBG3z
# +Ii3Z94J/zF4qoP6UTmRUsgoq7gxgaCCDnswggawMIIEmKADAgECAhAIrUCyYNKc
# TJ9ezam9k67ZMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0z
# NjA0MjgyMzU5NTlaMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg
# SW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcg
# UlNBNDA5NiBTSEEzODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
# ggIKAoICAQDVtC9C0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0
# JAfhS0/TeEP0F9ce2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJr
# Q5qZ8sU7H/Lvy0daE6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhF
# LqGfLOEYwhrMxe6TSXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+F
# LEikVoQ11vkunKoAFdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh
# 3K3kGKDYwSNHR7OhD26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJ
# wZPt4bRc4G/rJvmM1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQay
# g9Rc9hUZTO1i4F4z8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbI
# YViY9XwCFjyDKK05huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchAp
# QfDVxW0mdmgRQRNYmtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRro
# OBl8ZhzNeDhFMJlP/2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IB
# WTCCAVUwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+
# YXsIiGX0TkIwHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0P
# AQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAk
# BggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAC
# hjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9v
# dEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAED
# MAgGBmeBDAEEATANBgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql
# +Eg08yy25nRm95RysQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFF
# UP2cvbaF4HZ+N3HLIvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1h
# mYFW9snjdufE5BtfQ/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3Ryw
# YFzzDaju4ImhvTnhOE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5Ubdld
# AhQfQDN8A+KVssIhdXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw
# 8MzK7/0pNVwfiThV9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnP
# LqR0kq3bPKSchh/jwVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatE
# QOON8BUozu3xGFYHKi8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bn
# KD+sEq6lLyJsQfmCXBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQji
# WQ1tygVQK+pKHJ6l/aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbq
# yK+p/pQd52MbOoZWeE4wggfDMIIFq6ADAgECAhAHJlJYMMxBAtpVem3mdhUEMA0G
# CSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg
# SW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcg
# UlNBNDA5NiBTSEEzODQgMjAyMSBDQTEwHhcNMjUwMzA2MDAwMDAwWhcNMjgwMzA3
# MjM1OTU5WjCByzETMBEGCysGAQQBgjc8AgEDEwJDSDEVMBMGCysGAQQBgjc8AgEC
# EwRWYXVkMR0wGwYDVQQPDBRQcml2YXRlIE9yZ2FuaXphdGlvbjEYMBYGA1UEBRMP
# Q0hFLTExMi4wMDAuNTc5MQswCQYDVQQGEwJDSDEPMA0GA1UEBxMGUHJpbGx5MRYw
# FAYDVQQKEw1ORVhUaGluayBTLkEuMRYwFAYDVQQLEw1ORVhUaGluayBTLkEuMRYw
# FAYDVQQDEw1ORVhUaGluayBTLkEuMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
# CgKCAgEAyQBSj4N+dRT4Ft/77JxH1TCVXklR2+qgPiRzd1DHcjEu5mlpGbfFfGht
# NiZ1yDKC05ieExBSO6qIfkw6Lcod5yDTd1ojwU13b2SroZUUG6tLcwHLVnnSDiBH
# wi03dG7VmS9pqmgCLBRz7N3eF00AGY/cFJc8tT96HwXWSlVMAJ3DLkS6Db+1GLTi
# /ne9f9uWyksWSR3fW/sskS4/X6jIR26jJjPSfA4PhrnjRTSUJiMostKTRlHIIZtH
# T5m0Nf4adyU/4QCHHCSwx2cq+1z3H7C98yV7sZVb5hr0VUyVUYA+YP9xYQsKy1BQ
# XXLBd8WxT1QT+/UlUl5reK/oo6mjfcOt5Sz3ZGj+24SYGZ2zgrTWEboe3rZHKoKB
# 7fHqtKv4wrKXVkZt7J+kLuXg3fOusZ65V9NMyZx1gvL+t0RXd49dHGuBhg3ddVw7
# T4NZJ5M9XaWfwWUA04+aBouM4hqZLNKA9xpqv2JLFdg/UBof6JPETZ2cSP2tNKM1
# Ai+F00mm1Md196RC/+hltVGnmHqrw3rTScjiFT+HwHy0QWY5pdu275J9zCZcYZXz
# qUEkFmJN5aU9Nz6TZlgK5sNy1TJensLcnFw4hMK/y0GLkjGoXht4VQUJFGP51Lyh
# GSYo6EZJscVPx+kBg4c7I5P/4nEnFKguV3IjISiJbdDqNgbLHOsCAwEAAaOCAgIw
# ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBT0
# DqAKYCVBQLXZvFpqeHPu5oUVtjA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
# B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p
# bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT
# QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA
# A4ICAQArEaG0O/idYThPmYwLNwjvWSTevsonyUhvSGjSnkshdGZ1ClZdCmeMisXc
# jHystVNVtLZgy7q4d5a0m5lNKguI2p2/yPvWb9mGw9pateQ6v9AZGYrkL93FzvQK
# b/cVp/pcfAmAtjAbhP4FaFQlEfPHyLJSswJMkZHa3lt+scmSjG0DwLbw3J4MK8MJ
# sQubqt/Po0hMEm5IXfIAhTUMVrgtD2iVQOIv4YchJQePkOhfoa6T3QAbQGisioT1
# GXpaw+cMQuakS03Dj59KVzwZKgKWf2WttIcByN+iSI8oIBbzj7uFDbaH1BpocbYb
# sEG1+4zXkX92e45aetxuKGMe3DdH5dP+f2Mf820NHc7awetz2h4ejcX2Iw4VXLiu
# Gd2qz2+My8ayqXbpEdXDkRcnOOUPk00Z4SekIvmmdu/R75Ri8ApQbYcLq7OJWn4F
# 3KHTlztKTdcaBeYK3AsYNcap50uKnceS+eW7CXmEGFj93z6PUAiMkKabj/LHmd47
# hi9qdvumQ1lPzjilxHUrfbI5ijSq58mbgyG/LU5+wGh00mxSyLQ6dEYE7OdwEW3x
# 4KUjXWj4/9lmdCqAv25+qOR6HikM3dDKBgJQN+tk9soHD+MEyoyldDHBRjf8Gt6g
# MDnJ0Bvk1tfxFBx/KE7xwib8KS594kK7Q89nFgH3Uo1reXKkejGCGmMwghpfAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQByZSWDDMQQLaVXpt5nYVBDANBglghkgBZQMEAgEFAKB8
# MBAGCisGAQQBgjcCAQwxAjAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwG
# CisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAgER1w
# NjD1IyxW3mxgU3RzoCTV4ZGkhjqMnKm+SKICczANBgkqhkiG9w0BAQEFAASCAgCa
# 3+SFjDhOGL+YRzrxeM3V1L+kleOTSE8yA1XGEage01Cofa9Hb2AiFKia0Q9uUldl
# Q9vnfnxkKTr/7chx7VT4GJAXVnTyDc9I4/wRV1vslsplC1TN281bpsVsBOq/etBC
# E+hifmqdfjmCNR0e6GWqZwmd8RM3jV4v6/6IJ3mCiJMN2HKjZ7EXfH7nzUpnyNQq
# mAnznGAIcnux/4+7VKjCPqkH2jqwxXCWgzS8Gtmq6iWvsMAdMxlntLMMQjOgzAtS
# wSHurzk5cDOBFdach0IxVpzGWglgwDNmQZiXMQ/Pzsp0sV/NxLrnD2VfW3lXTIxw
# LLpkoGKfLgaabEsU0NTY9uYDRQpYhl6mmUtroYhr0/fRkhKlJQHXIeeoIM43J+Eb
# mL8FhXdgXiuqGu7YILHrkpdWVBuwRey42eB1h+XA/ptRbctdsUq7MQBEmtutpfoZ
# Qj19fgVogeLhVW5nGQ0qBogV0bbZEzQebUp1hM66h4Ow9rVRJEA1jhtUcwk+FI5+
# QuyPP66sYWchOUvuWsJKkvbF3XFwfutd2127OCJ+ZBhA3VvaA+42LycOwCvvy1dy
# 1jG51rmL32Ly+5ZfznReEoLEaOxhMbVHqTNEAn7+GgdR5dXSCku4LeGlErq8gqLV
# WLXi6pJ+K63P6WeEtfYnk8KRUIkPaaFkXzN+hU+eW6GCFzkwghc1BgorBgEEAYI3
# AwMBMYIXJTCCFyEGCSqGSIb3DQEHAqCCFxIwghcOAgEDMQ8wDQYJYIZIAWUDBAIB
# BQAwdwYLKoZIhvcNAQkQAQSgaARmMGQCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFl
# AwQCAQUABCAmGLJtanUhirspWdebpzbCPbAcueaxqYAuyCTelWknagIQOpPZj5Hs
# n7cxXese22D5OBgPMjAyNTA3MDQwODE5MzBaoIITAzCCBrwwggSkoAMCAQICEAuu
# Zrxaun+Vh8b56QTjMwQwDQYJKoZIhvcNAQELBQAwYzELMAkGA1UEBhMCVVMxFzAV
# BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVk
# IEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTAeFw0yNDA5MjYwMDAw
# MDBaFw0zNTExMjUyMzU5NTlaMEIxCzAJBgNVBAYTAlVTMREwDwYDVQQKEwhEaWdp
# Q2VydDEgMB4GA1UEAxMXRGlnaUNlcnQgVGltZXN0YW1wIDIwMjQwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQC+anOf9pUhq5Ywultt5lmjtej9kR8YxIg7
# apnjpcH9CjAgQxK+CMR0Rne/i+utMeV5bUlYYSuuM4vQngvQepVHVzNLO9RDnEXv
# PghCaft0djvKKO+hDu6ObS7rJcXa/UKvNminKQPTv/1+kBPgHGlP28mgmoCw/xi6
# FG9+Un1h4eN6zh926SxMe6We2r1Z6VFZj75MU/HNmtsgtFjKfITLutLWUdAoWle+
# jYZ49+wxGE1/UXjWfISDmHuI5e/6+NfQrxGFSKx+rDdNMsePW6FLrphfYtk/FLih
# p/feun0eV+pIF496OVh4R1TvjQYpAztJpVIfdNsEvxHofBf1BWkadc+Up0Th8Eif
# kEEWdX4rA/FE1Q0rqViTbLVZIqi6viEk3RIySho1XyHLIAOJfXG5PEppc3XYeBH7
# xa6VTZ3rOHNeiYnY+V4j1XbJ+Z9dI8ZhqcaDHOoj5KGg4YuiYx3eYm33aebsyF6e
# D9MF5IDbPgjvwmnAalNEeJPvIeoGJXaeBQjIK13SlnzODdLtuThALhGtyconcVuP
# I8AaiCaiJnfdzUcb3dWnqUnjXkRFwLtsVAxFvGqsxUA2Jq/WTjbnNjIUzIs3ITVC
# 6VBKAOlb2u29Vwgfta8b2ypi6n2PzP0nVepsFk8nlcuWfyZLzBaZ0MucEdeBiXL+
# nUOGhCjl+QIDAQABo4IBizCCAYcwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC
# MAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwIAYDVR0gBBkwFzAIBgZngQwBBAIw
# CwYJYIZIAYb9bAcBMB8GA1UdIwQYMBaAFLoW2W1NhS9zKXaaL3WMaiCPnshvMB0G
# A1UdDgQWBBSfVywDdw4oFZBmpWNe7k+SH3agWzBaBgNVHR8EUzBRME+gTaBLhklo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRSU0E0MDk2
# U0hBMjU2VGltZVN0YW1waW5nQ0EuY3JsMIGQBggrBgEFBQcBAQSBgzCBgDAkBggr
# BgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFgGCCsGAQUFBzAChkxo
# dHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRSU0E0
# MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQA9
# rR4fdplb4ziEEkfZQ5H2EdubTggd0ShPz9Pce4FLJl6reNKLkZd5Y/vEIqFWKt4o
# KcKz7wZmXa5VgW9B76k9NJxUl4JlKwyjUkKhk3aYx7D8vi2mpU1tKlY71AYXB8wT
# LrQeh83pXnWwwsxc1Mt+FWqz57yFq6laICtKjPICYYf/qgxACHTvypGHrC8k1TqC
# eHk6u4I/VBQC9VK7iSpU5wlWjNlHlFFv/M93748YTeoXU/fFa9hWJQkuzG2+B7+b
# MDvmgF8VlJt1qQcl7YFUMYgZU1WM6nyw23vT6QSgwX5Pq2m0xQ2V6FJHu8z4LXe/
# 371k5QrN9FQBhLLISZi2yemW0P8ZZfx4zvSWzVXpAb9k4Hpvpi6bUe8iK6WonUSV
# 6yPlMwerwJZP/Gtbu3CKldMnn+LmmRTkTXpFIEB06nXZrDwhCGED+8RsWQSIXZpu
# G4WLFQOhtloDRWGoCwwc6ZpPddOFkM2LlTbMcqFSzm4cd0boGhBq7vkqI1uHRz6F
# q1IX7TaRQuR+0BGOzISkcqwXu7nMpFu3mgrlgbAW+BzikRVQ3K2YHcGkiKjA4gi4
# OA/kz1YCsdhIBHXqBzR0/Zd2QwQ/l4Gxftt/8wY3grcc/nS//TVkej9nmUYu83BD
# tccHHXKibMs/yXHhDXNkoPIdynhVAku7aRZOwqw6pDCCBq4wggSWoAMCAQICEAc2
# N7ckVHzYR6z9KGYqXlswDQYJKoZIhvcNAQELBQAwYjELMAkGA1UEBhMCVVMxFTAT
# BgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEh
# MB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4XDTIyMDMyMzAwMDAw
# MFoXDTM3MDMyMjIzNTk1OVowYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lD
# ZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYg
# U0hBMjU2IFRpbWVTdGFtcGluZyBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAMaGNQZJs8E9cklRVcclA8TykTepl1Gh1tKD0Z5Mom2gsMyD+Vr2EaFE
# FUJfpIjzaPp985yJC3+dH54PMx9QEwsmc5Zt+FeoAn39Q7SE2hHxc7Gz7iuAhIoi
# GN/r2j3EF3+rGSs+QtxnjupRPfDWVtTnKC3r07G1decfBmWNlCnT2exp39mQh0YA
# e9tEQYncfGpXevA3eZ9drMvohGS0UvJ2R/dhgxndX7RUCyFobjchu0CsX7LeSn3O
# 9TkSZ+8OpWNs5KbFHc02DVzV5huowWR0QKfAcsW6Th+xtVhNef7Xj3OTrCw54qVI
# 1vCwMROpVymWJy71h6aPTnYVVSZwmCZ/oBpHIEPjQ2OAe3VuJyWQmDo4EbP29p7m
# O1vsgd4iFNmCKseSv6De4z6ic/rnH1pslPJSlRErWHRAKKtzQ87fSqEcazjFKfPK
# qpZzQmiftkaznTqj1QPgv/CiPMpC3BhIfxQ0z9JMq++bPf4OuGQq+nUoJEHtQr8F
# nGZJUlD0UfM2SU2LINIsVzV5K6jzRWC8I41Y99xh3pP+OcD5sjClTNfpmEpYPtMD
# iP6zj9NeS3YSUZPJjAw7W4oiqMEmCPkUEBIDfV8ju2TjY+Cm4T72wnSyPx4Jduyr
# XUZ14mCjWAkBKAAOhFTuzuldyF4wEr1GnrXTdrnSDmuZDNIztM2xAgMBAAGjggFd
# MIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS6FtltTYUvcyl2mi91
# jGogj57IbzAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qYrhwPTzAOBgNVHQ8B
# Af8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYBBQUHAQEEazBpMCQG
# CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQQYIKwYBBQUHMAKG
# NWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290
# RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQC
# MAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAfVmOwJO2b5ipRCIBfmbW
# 2CFC4bAYLhBNE88wU86/GPvHUF3iSyn7cIoNqilp/GnBzx0H6T5gyNgL5Vxb122H
# +oQgJTQxZ822EpZvxFBMYh0MCIKoFr2pVs8Vc40BIiXOlWk/R3f7cnQU1/+rT4os
# equFzUNf7WC2qk+RZp4snuCKrOX9jLxkJodskr2dfNBwCnzvqLx1T7pa96kQsl3p
# /yhUifDVinF2ZdrM8HKjI/rAJ4JErpknG6skHibBt94q6/aesXmZgaNWhqsKRcnf
# xI2g55j7+6adcq/Ex8HBanHZxhOACcS2n82HhyS7T6NJuXdmkfFynOlLAlKnN36T
# U6w7HQhJD5TNOXrd/yVjmScsPT9rp/Fmw0HNT7ZAmyEhQNC3EyTN3B14OuSereU0
# cZLXJmvkOHOrpgFPvT87eK1MrfvElXvtCl8zOYdBeHo46Zzh3SP9HSjTx/no8Zhf
# +yvYfvJGnXUsHicsJttvFXseGYs2uJPU5vIXmVnKcPA3v5gA3yAWTyf7YGcWoWa6
# 3VXAOimGsJigK+2VQbc61RWYMbRiCQ8KvYHZE/6/pNHzV9m8BPqC3jLfBInwAM1d
# wvnQI38AC+R2AibZ8GV2QqYphwlHK+Z/GqSFD/yYlvZVVCsfgPrA8g4r5db7qS9E
# FUrnEw4d2zc4GqEr9u3WfPwwggWNMIIEdaADAgECAhAOmxiO+dAt5+/bUOIIQBha
# MA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNVBAMTG0RpZ2lD
# ZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBaFw0zMTExMDky
# MzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAX
# BgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0
# ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/mkHNo
# 3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/zG6Q4FutW
# xpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZanMylNEQ
# RBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7sWxq868nP
# zaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL2pNe3I6P
# gNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfbBHMqbpEB
# fCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3JFxGj2T3
# wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3cAORFJYm2
# mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqxYxhElRp2
# Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0viastkF1
# 3nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aLT8LWRV+d
# IPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIBNjAPBgNVHRMB
# Af8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwPTzAfBgNVHSME
# GDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMCAYYweQYIKwYB
# BQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w
# QwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy
# dEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0aHR0cDovL2Ny
# bDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENBLmNybDARBgNV
# HSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0NcVec4X6CjdBs9
# thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnovLbc47/T/gLn4
# offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65ZyoUi0mcudT6cG
# AxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFWjuyk1T3osdz9
# HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPFmCLBsln1VWvP
# J6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9ztwGpn1eqXiji
# uZQxggN2MIIDcgIBATB3MGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy
# dCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNI
# QTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAuuZrxaun+Vh8b56QTjMwQwDQYJYIZIAWUD
# BAIBBQCggdEwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJ
# BTEPFw0yNTA3MDQwODE5MzBaMCsGCyqGSIb3DQEJEAIMMRwwGjAYMBYEFNvThe5i
# 29I+e+T2cUhQhyTVhltFMC8GCSqGSIb3DQEJBDEiBCBTi5/N1ErxSPMruB864Q2Z
# IkL6AsHV1kNCrqMGI+AInTA3BgsqhkiG9w0BCRACLzEoMCYwJDAiBCB2dp+o8mMv
# H0MLOiMwrtZWdf7Xc9sF1mW5BZOYQ4+a2zANBgkqhkiG9w0BAQEFAASCAgCyowV+
# O/jC87neul9O9zAehtz34Z2tX/+uiRdY2TOFjBSsZ5kyg/JqOTIR0f3lTqDru5Cj
# Ag4FLcely3HXYUwPC7RhTwnigb2Msy8+VwaKUuXyPgVVn0/AA+HXqt0gnriiLKwR
# eFrUwQK5IeW3xNla7OXTLkVso6P40BtVIW3uJlfbaddP28IVRthZSLcRBsKsvafr
# GJ6i/nOGhKsPY8mlK0+hwNagooiOcZ/4vBbymj5SrgFennuATy8ExbpjgpfeoXI+
# 4mG6nsjweqx6u9JqY4Pf6qNNUZ86AxmNIKnASwQCQcYm1ubhjNZWhLgAuU535ifx
# ihV3klKT7eVCH8J9AGN8s77bthCDEOv3XfStQARSCAA9KDVJlTsAsDE1FDfWTBTV
# 0DcZocZlu2LXLFFliA/jkPgibq6jcZs0GfD60VV2goVo5PalzP8ziSuP8hvuyPD5
# RyS7jr5OhhBf/Ir+ntdQgqYo5zEa0Si9gFx1bL+/lkx9Uny+IyHVR0nRNn0cFSsj
# 6GVSrE3/xbSyksYwTQDfGsQ7D7Vt85TCJHjOE/GxYI/HFr94vJvn1swMFURiwQZo
# v+PkgqKmx0TWoD4Tawa6uBHUPFgnwaCevHVA30/wA1wXpeZaHR2PMDCap96S2oQ6
# ARhFAdV/y/TGM+9S10WnVhezN2OydTHUJTOkeA==
# SIG # End signature block