Nexthink-Omnissa-Connector.psm1

<#PSScriptInfo
.VERSION 0.1.1
.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.1" -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
    )

    $exitCode = $EXIT_CODE_OK
    try {
        Initialize-EnrichmentProcess -OmnissaEnvironment $OmnissaEnvironment -ScriptRootPath $ScriptRootPath
        Write-CustomLog -Message "Starting Omnissa connector $SCRIPT_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 $SCRIPT_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
                PoolType       = $pool.type
                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/v2/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/v6/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(
        [string]$UserAssignment,

        [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
# MIIpiQYJKoZIhvcNAQcCoIIpejCCKXYCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDKIrQ5yrOgB/ar
# C+udqO0+OQhvQzgeJXYjAnH++6A1MaCCDnswggawMIIEmKADAgECAhAIrUCyYNKc
# 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/KE7xwib8KS594kK7Q89nFgH3Uo1reXKkejGCGmQwghpgAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQByZSWDDMQQLaVXpt5nYVBDANBglghkgBZQMEAgEFAKB8
# MBAGCisGAQQBgjcCAQwxAjAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwG
# CisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAZfoMC
# N7RS+YvP9sWR1j1VP4L/ZZtghNMRRgWMQJzgNzANBgkqhkiG9w0BAQEFAASCAgAL
# PXGln9WC5VOgbYwGA//2xHtuurP7ZcZi5mrmi/YDJMwsIml/1JU5BV39aZ4iAyNd
# 2BBelZEUu5Rn6ckUf7cWjHEDh0fbyCZbqBJVzw3ISO+4EopObydY7pIGXzVuPLGe
# PfcxLD9u3/+ZkigAZBVW8pMZXzByv45QJk/lw5mru4P5dhBhoxVv5SEokpv+AIcL
# GckU2eT3FINTsm2YH9Zc7FNiNy+wb/mQ6M+BpQhwgpdIW0ka0fmT3QzBpOJ5fM9S
# tnu4fPeuT8cWjS1OvC/NwylVrJnp2YulJEpICyrzrHxChIOBqXyb3HriMETKmalX
# CdyPIOvnYR+XTqpLLFnouoc2/62oh5FMgWMQKGxBiCFmyZSDNQq91SqLzO8nTXNV
# y6TjvswhQSZhDR9rOSYlRraWKHikpz3s032sQLSi2Waifcc514Is2xc/FhV23LmT
# pu0bBnRTbrhgIwXxIeU2khhXt2GCw9fvL3rfuWK/oGfsKBDahygbTOjJM+MqR1FZ
# Bsrl4Y65wMRU7LyS/Mpyt8qesW8CYe3EU7dRKzvhf4HiXSKOVnkLWeWRtNfre7Y0
# ULJaRMiCxzco3OaTVcf99LdiI2c7Z0IkFXbir+7YtHfT0f7u8RE3mojfm0PXvgpX
# VAz9AecuwaFmzsPl4LvIjR6NhJ5Qk8L5D2fBNQT1o6GCFzowghc2BgorBgEEAYI3
# AwMBMYIXJjCCFyIGCSqGSIb3DQEHAqCCFxMwghcPAgEDMQ8wDQYJYIZIAWUDBAIB
# BQAweAYLKoZIhvcNAQkQAQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFl
# AwQCAQUABCDJV8LZ8scJ9PwE8/YzwvHX5OoxKc9WH8YWSaomcLnOMwIRAPNc+gPT
# bkjGgjoKqj1sJbMYDzIwMjUwNzA5MDgwNTU5WqCCEwMwgga8MIIEpKADAgECAhAL
# rma8Wrp/lYfG+ekE4zMEMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcw
# FQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3Rl
# ZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjQwOTI2MDAw
# MDAwWhcNMzUxMTI1MjM1OTU5WjBCMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGln
# aUNlcnQxIDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDI0MIICIjANBgkq
# hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvmpzn/aVIauWMLpbbeZZo7Xo/ZEfGMSI
# O2qZ46XB/QowIEMSvgjEdEZ3v4vrrTHleW1JWGErrjOL0J4L0HqVR1czSzvUQ5xF
# 7z4IQmn7dHY7yijvoQ7ujm0u6yXF2v1CrzZopykD07/9fpAT4BxpT9vJoJqAsP8Y
# uhRvflJ9YeHjes4fduksTHulntq9WelRWY++TFPxzZrbILRYynyEy7rS1lHQKFpX
# vo2GePfsMRhNf1F41nyEg5h7iOXv+vjX0K8RhUisfqw3TTLHj1uhS66YX2LZPxS4
# oaf33rp9HlfqSBePejlYeEdU740GKQM7SaVSH3TbBL8R6HwX9QVpGnXPlKdE4fBI
# n5BBFnV+KwPxRNUNK6lYk2y1WSKour4hJN0SMkoaNV8hyyADiX1xuTxKaXN12HgR
# +8WulU2d6zhzXomJ2PleI9V2yfmfXSPGYanGgxzqI+ShoOGLomMd3mJt92nm7Mhe
# ng/TBeSA2z4I78JpwGpTRHiT7yHqBiV2ngUIyCtd0pZ8zg3S7bk4QC4RrcnKJ3Fb
# jyPAGogmoiZ33c1HG93Vp6lJ415ERcC7bFQMRbxqrMVANiav1k425zYyFMyLNyE1
# QulQSgDpW9rtvVcIH7WvG9sqYup9j8z9J1XqbBZPJ5XLln8mS8wWmdDLnBHXgYly
# /p1DhoQo5fkCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8E
# AjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQC
# MAsGCWCGSAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAd
# BgNVHQ4EFgQUn1csA3cOKBWQZqVjXu5Pkh92oFswWgYDVR0fBFMwUTBPoE2gS4ZJ
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5
# NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYI
# KwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZM
# aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNB
# NDA5NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEA
# Pa0eH3aZW+M4hBJH2UOR9hHbm04IHdEoT8/T3HuBSyZeq3jSi5GXeWP7xCKhVire
# KCnCs+8GZl2uVYFvQe+pPTScVJeCZSsMo1JCoZN2mMew/L4tpqVNbSpWO9QGFwfM
# Ey60HofN6V51sMLMXNTLfhVqs+e8haupWiArSozyAmGH/6oMQAh078qRh6wvJNU6
# gnh5OruCP1QUAvVSu4kqVOcJVozZR5RRb/zPd++PGE3qF1P3xWvYViUJLsxtvge/
# mzA75oBfFZSbdakHJe2BVDGIGVNVjOp8sNt70+kEoMF+T6tptMUNlehSR7vM+C13
# v9+9ZOUKzfRUAYSyyEmYtsnpltD/GWX8eM70ls1V6QG/ZOB6b6Yum1HvIiulqJ1E
# lesj5TMHq8CWT/xrW7twipXTJ5/i5pkU5E16RSBAdOp12aw8IQhhA/vEbFkEiF2a
# bhuFixUDobZaA0VhqAsMHOmaT3XThZDNi5U2zHKhUs5uHHdG6BoQau75KiNbh0c+
# hatSF+02kULkftARjsyEpHKsF7u5zKRbt5oK5YGwFvgc4pEVUNytmB3BpIiowOII
# uDgP5M9WArHYSAR16gc0dP2XdkMEP5eBsX7bf/MGN4K3HP50v/01ZHo/Z5lGLvNw
# Q7XHBx1yomzLP8lx4Q1zZKDyHcp4VQJLu2kWTsKsOqQwggauMIIElqADAgECAhAH
# Nje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUw
# EwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20x
# ITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAw
# MDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdp
# Q2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2
# IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
# ggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGh
# RBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISK
# Ihjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdG
# AHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9
# zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKl
# SNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae
# 5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnz
# yqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/
# BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7T
# A4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbs
# q11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IB
# XTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3Mpdpov
# dYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0P
# AQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAk
# BggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAC
# hjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9v
# dEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE
# AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m
# 1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dt
# h/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+K
# LHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd
# 6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ
# 38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+
# k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3l
# NHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGY
# X/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFm
# ut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADN
# XcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kv
# RBVK5xMOHds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAY
# WjANBgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl
# cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdp
# Q2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5
# MjM1OTU5WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkw
# FwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVz
# dGVkIFJvb3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBz
# aN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbr
# VsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTR
# EEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJ
# z82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyO
# j4DatpGYQJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6R
# AXwhTNS8rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k
# 98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJ
# tppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUa
# dmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZB
# dd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVf
# nSD8oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0T
# AQH/BAUwAwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0j
# BBgwFoAUReuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsG
# AQUFBwEBBG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t
# MEMGCCsGAQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl
# cnRBc3N1cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9j
# cmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYD
# VR0gBAowCDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3Qb
# PbYW1/e/Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5
# +KH38nLeJLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+n
# BgMTdydE1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc
# /RzY9HdaXFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVr
# zyerbHbObyMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o
# 4rmUMYIDdjCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNl
# cnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBT
# SEEyNTYgVGltZVN0YW1waW5nIENBAhALrma8Wrp/lYfG+ekE4zMEMA0GCWCGSAFl
# AwQCAQUAoIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0B
# CQUxDxcNMjUwNzA5MDgwNTU5WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBTb04Xu
# YtvSPnvk9nFIUIck1YZbRTAvBgkqhkiG9w0BCQQxIgQgmSsNZdWXGxPVbGpnD6EA
# f2N3szuUVhUVbAo279pWO2YwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQgdnafqPJj
# Lx9DCzojMK7WVnX+13PbBdZluQWTmEOPmtswDQYJKoZIhvcNAQEBBQAEggIAq3z2
# iqGgtnUTx2cYbFYT0dBpj7JJ744yHiq584lZHSaGiHDPhD4iXMamVwxE2TyxMQHQ
# CLY8wcM2Vl3anFod4Aw1469rSPqX64mD2/FY8/E8hx4Ml8uguKSIfWltggA6Iz9Q
# JT62gh5n0FNp/NMHVb2xrixS9kPG85XkRBpoyuzgSdFwnnpDB3thvPr/dbtD88fG
# QIJrFyE0oPy/ygGgYBRYz2RS8waist6pM73P1bjoFPFIYg5OQ3GG1Qq5v8j/kJZN
# 3xs4S6rUTTs8V5epiGTu9A2H8caW6DOp4hs/kn+3DaRLX2I+4txNSQV+YmLZToJf
# /f689aTo9wPBbPjHOVZHAvoGnXZpemhC0yf1qnyY5F2i1qzTdeQgTPKrz9eXBtZC
# uXToFL3QItI+9fuRrmSPkrmJxCb0igGL+8fNUcuHWvI/rV2CDoDYQUomJGGofyqJ
# SB38PuEaqJta9BOyT+h0ykvuX5y2OqrV1Rk1INlon7KbBpwUlQZmJOFg4ikAslXs
# 3snZvqUSgttJ4g9AhgTWfU+hpi9H17dcqsV47VZ6Apf9tM+ogxBMeVN6TSi++EgB
# 1XKqFQkC00k3GV7kpa3YXg8zadQgzEm9Hy6IqHJsSgvbOmrSMsL8zjIxtmSLHrYg
# 5a0U6x9metWVrPP7J4ivQHyNUsB709GzkdSYfLg=
# SIG # End signature block