Private/Diagnostics.ps1

<#
Building blocks for Test-NexthinkNutanixConnector, the installation and configuration helper.

Every check returns result records instead of throwing, so one broken prerequisite does not hide the
others, and every failure carries a hint written for the person installing the connector. Checks
never print, log or return a secret: credential checks report the user name only.
#>


function New-DiagnosticResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Category,

        [Parameter(Mandatory = $true)]
        [string]$Check,

        [Parameter(Mandatory = $true)]
        [ValidateSet('PASS', 'WARN', 'FAIL', 'SKIP', 'INFO')]
        [string]$Status,

        [Parameter(Mandatory = $false)]
        [string]$Detail = '',

        [Parameter(Mandatory = $false)]
        [string]$Hint = ''
    )

    return [pscustomobject][ordered]@{
        Category = $Category
        Check    = $Check
        Status   = $Status
        Detail   = $Detail
        Hint     = $Hint
    }
}

function Add-DiagnosticResult {
    <#
    .SYNOPSIS
        Appends one or more results, ignoring a check that returned nothing.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[object]]$Results,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [object[]]$Result
    )

    foreach ($item in @($Result)) {
        if ($null -ne $item) {
            $Results.Add($item)
        }
    }
}

function Get-DiagnosticCurrentIdentity {
    [CmdletBinding()]
    param()

    if ((Get-OperatingSystemType) -eq 'Windows') {
        return [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
    }

    return [Environment]::UserName
}

function Get-DiagnosticAccountSid {
    <#
    .SYNOPSIS
        The SID of a Windows account name, or $null when it cannot be resolved (or off Windows).
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Account
    )

    if ($Account -match '^S-1-\d+(-\d+)+$') {
        return $Account
    }

    try {
        return [System.Security.Principal.NTAccount]::new($Account).Translate([System.Security.Principal.SecurityIdentifier]).Value
    }
    catch {
        return $null
    }
}

function ConvertTo-DiagnosticAccountKey {
    <#
    .SYNOPSIS
        Normalizes a Windows account for comparison: its SID, with every LocalSystem spelling mapped to
        S-1-5-18. An account that cannot be resolved keeps its qualified name, so CORP\svc and SERVER\svc
        stay distinct: they have separate Credential Manager stores.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Account
    )

    $name = $Account.Trim().ToLowerInvariant()
    if ($name -in @('system', 'nt authority\system', 'localsystem', '.\localsystem', 's-1-5-18')) {
        return 's-1-5-18'
    }

    $sid = Get-DiagnosticAccountSid -Account $Account.Trim()
    if (-not [string]::IsNullOrWhiteSpace($sid)) {
        return $sid.ToLowerInvariant()
    }

    return $name
}

function Get-DiagnosticErrorMessage {
    <#
    .SYNOPSIS
        Flattens an error record and its inner exceptions into one line.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $messages = [System.Collections.Generic.List[string]]::new()
    $exception = $ErrorRecord.Exception
    while ($null -ne $exception) {
        if (-not [string]::IsNullOrWhiteSpace($exception.Message) -and -not $messages.Contains($exception.Message)) {
            $messages.Add($exception.Message)
        }
        $exception = $exception.InnerException
    }

    # Wrappers such as 'Exception calling "X" with "1" argument(s): "<inner>"' repeat their inner message.
    $distinct = @($messages | Where-Object {
            $message = $_
            @($messages | Where-Object { $_ -ne $message -and $message.Contains($_) }).Count -eq 0
        })

    return ($distinct -join ' | ')
}

function Get-DiagnosticHttpStatusCode {
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    if ($null -ne $ErrorRecord.Exception.Response) {
        return [int]$ErrorRecord.Exception.Response.StatusCode
    }

    # Errors rethrown as strings (Get-Jwt, Invoke-WebRequestWithLogging) only keep the status in the text.
    if ($ErrorRecord.Exception.Message -match '\b([45]\d{2})\b') {
        return [int]$Matches[1]
    }

    return $null
}

function Get-DiagnosticTlsFailureKind {
    <#
    .SYNOPSIS
        'Certificate' when the server certificate was rejected, 'Handshake' for any other TLS failure
        (reset, protocol mismatch, interception), otherwise $null.
    #>

    param([string]$Message)

    if ($Message -match 'certificate') {
        return 'Certificate'
    }
    if ($Message -match 'SSL|TLS') {
        return 'Handshake'
    }

    return $null
}

#
# Environment and prerequisites
#

function Test-DiagnosticPowerShellVersion {
    [CmdletBinding()]
    param()

    $version = $PSVersionTable.PSVersion
    if ($version.Major -ge 7) {
        return New-DiagnosticResult -Category 'Environment' -Check 'PowerShell version' -Status 'PASS' `
            -Detail "PowerShell $version ($($PSVersionTable.PSEdition))"
    }

    return New-DiagnosticResult -Category 'Environment' -Check 'PowerShell version' -Status 'FAIL' `
        -Detail "PowerShell $version" `
        -Hint 'Install PowerShell 7 and run the connector with pwsh.exe, not powershell.exe (Windows PowerShell 5.1).'
}

function Test-DiagnosticOperatingSystem {
    [CmdletBinding()]
    param()

    $osType = Get-OperatingSystemType
    if ($osType -eq 'Unknown') {
        return New-DiagnosticResult -Category 'Environment' -Check 'Operating system' -Status 'FAIL' `
            -Detail ([System.Runtime.InteropServices.RuntimeInformation]::OSDescription) `
            -Hint 'Only Windows (Credential Manager) and macOS (Keychain) are supported.'
    }

    return New-DiagnosticResult -Category 'Environment' -Check 'Operating system' -Status 'PASS' `
        -Detail "$osType - $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
}

function Get-DiagnosticExecutionPolicyList {
    # Wrapped so tests can replace it: Get-ExecutionPolicy -List returns typed scope objects.
    $byScope = @{}
    foreach ($entry in @(Get-ExecutionPolicy -List)) {
        $byScope[[string]$entry.Scope] = [string]$entry.ExecutionPolicy
    }
    return $byScope
}

function Get-DiagnosticWindowsDefaultExecutionPolicy {
    # With every scope Undefined, Windows clients get Restricted and Windows Server gets RemoteSigned.
    try {
        if ([int](Get-CimInstance -ClassName 'Win32_OperatingSystem' -ErrorAction Stop).ProductType -ne 1) {
            return 'RemoteSigned'
        }
    }
    catch {
        Write-Verbose "Could not read the Windows product type: $($_.Exception.Message)"
    }
    return 'Restricted'
}

function Test-DiagnosticFileFromInternet {
    # The Zone.Identifier stream ("mark of the web") is what makes PowerShell treat a file as downloaded.
    param([AllowEmptyString()][string]$Path)

    # Alternate data streams, and Get-Item -Stream, only exist on Windows.
    if ((Get-OperatingSystemType) -ne 'Windows' -or [string]::IsNullOrWhiteSpace($Path) -or
        -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        return $false
    }
    try {
        return $null -ne (Get-Item -LiteralPath $Path -Stream 'Zone.Identifier' -ErrorAction Stop)
    }
    catch {
        return $false
    }
}

function Test-DiagnosticTrustedPublisher {
    # True when the file's signing certificate is in a Trusted Publishers store, so PowerShell does not
    # ask whether to run software from that publisher.
    param([AllowEmptyString()][string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        return $false
    }
    try {
        $signature = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
    }
    catch {
        return $false
    }
    if ([string]$signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) {
        return $false
    }
    $thumbprint = $signature.SignerCertificate.Thumbprint
    foreach ($store in @('Cert:\LocalMachine\TrustedPublisher', 'Cert:\CurrentUser\TrustedPublisher')) {
        if (Test-Path -LiteralPath (Join-Path -Path $store -ChildPath $thumbprint)) {
            return $true
        }
    }
    return $false
}

function Get-DiagnosticExecutionPolicyProblem {
    <#
    .SYNOPSIS
        Says why a policy would stop the Runner from starting unattended, or returns $null.
    .NOTES
        A scheduled task cannot answer a prompt, so a policy that asks before running is as bad as one
        that blocks. Bypass never asks. See about_Execution_Policies.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Policy,

        [AllowEmptyString()]
        [string]$ScriptPath
    )

    switch ($Policy) {
        'Bypass' { return $null }
        'Restricted' { return 'Scripts cannot run under Restricted.' }
        'Undefined' { return $null }
    }

    $fromInternet = Test-DiagnosticFileFromInternet -Path $ScriptPath
    if ($Policy -eq 'AllSigned' -and -not (Test-DiagnosticTrustedPublisher -Path $ScriptPath)) {
        return 'Under AllSigned PowerShell asks whether to trust the Nexthink publisher before running the Runner, and a scheduled task cannot answer.'
    }
    if ($fromInternet -and ($Policy -eq 'Unrestricted' -or -not (Test-DiagnosticTrustedPublisher -Path $ScriptPath))) {
        return "The Runner is marked as downloaded from the internet, so under $Policy PowerShell asks for confirmation before running it, and a scheduled task cannot answer."
    }
    return $null
}

function Get-DiagnosticExecutionPolicyState {
    <#
    .SYNOPSIS
        The execution policy a new pwsh process gets when its command line sets none, and where it comes from.
    .NOTES
        This session's Process scope is ignored on purpose: it reflects how the diagnostic was started,
        not how the scheduled task starts the Runner. Group Policy scopes override -ExecutionPolicy.
    #>

    param([AllowEmptyString()][string]$ScriptPath)

    $byScope = Get-DiagnosticExecutionPolicyList
    $scope = @('MachinePolicy', 'UserPolicy', 'CurrentUser', 'LocalMachine') |
        Where-Object { $byScope.ContainsKey($_) -and $byScope[$_] -ne 'Undefined' } |
        Select-Object -First 1
    $policy = if ($null -ne $scope) { $byScope[$scope] } else { Get-DiagnosticWindowsDefaultExecutionPolicy }

    return [pscustomobject]@{
        Policy           = $policy
        Scope            = if ($null -ne $scope) { $scope } else { 'Windows default' }
        SetByGroupPolicy = $scope -in @('MachinePolicy', 'UserPolicy')
        Problem          = Get-DiagnosticExecutionPolicyProblem -Policy $policy -ScriptPath $ScriptPath
    }
}

function Get-DiagnosticGroupPolicyHint {
    param([string]$ScriptPath)

    $hint = 'Group Policy sets this policy, so -ExecutionPolicy Bypass on the command line does not override it. Ask the administrator to allow signed scripts (RemoteSigned)'
    if (Test-DiagnosticFileFromInternet -Path $ScriptPath) {
        $hint += ', and run Get-ChildItem <connector folder> -Recurse | Unblock-File'
    }
    return "$hint."
}

function Test-DiagnosticExecutionPolicy {
    [CmdletBinding()]
    param(
        # The Runner next to the configuration; its download mark decides whether some policies prompt.
        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string]$RunnerPath = ''
    )

    if ((Get-OperatingSystemType) -ne 'Windows') {
        return
    }

    $state = Get-DiagnosticExecutionPolicyState -ScriptPath $RunnerPath
    $detail = "$($state.Policy) (from $($state.Scope)) for a process started without -ExecutionPolicy"
    if ($null -eq $state.Problem) {
        return New-DiagnosticResult -Category 'Environment' -Check 'Execution policy' -Status 'PASS' -Detail $detail
    }

    if ($state.SetByGroupPolicy) {
        $status = if ($state.Policy -eq 'Restricted') { 'FAIL' } else { 'WARN' }
        return New-DiagnosticResult -Category 'Environment' -Check 'Execution policy' -Status $status `
            -Detail "$detail. $($state.Problem)" -Hint (Get-DiagnosticGroupPolicyHint -ScriptPath $RunnerPath)
    }

    # Bypass is meant for a script with its own security model: the Runner checks that the module carries
    # a valid Nexthink signature before loading it. It applies to that one process, not to the machine.
    return New-DiagnosticResult -Category 'Environment' -Check 'Execution policy' -Status 'WARN' `
        -Detail "$detail. $($state.Problem)" `
        -Hint "Start the Runner with pwsh.exe -NoProfile -ExecutionPolicy Bypass -File $($script:DIAGNOSTIC_RUNNER_SCRIPT_NAME): it changes the policy for that process only, and the Runner verifies the module's Nexthink signature itself. No machine-wide change is needed."
}

function Test-DiagnosticModuleInstalled {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name
    )

    $module = Get-Module -Name $Name -ListAvailable -ErrorAction SilentlyContinue |
        Sort-Object -Property Version -Descending |
        Select-Object -First 1

    if ($null -eq $module) {
        return New-DiagnosticResult -Category 'Prerequisites' -Check "Module $Name" -Status 'FAIL' `
            -Detail 'Not installed for this account.' `
            -Hint "Install it for every account, including the scheduled task's: Install-Module -Name $Name -Scope AllUsers"
    }

    return New-DiagnosticResult -Category 'Prerequisites' -Check "Module $Name" -Status 'PASS' `
        -Detail "Version $($module.Version) in $($module.ModuleBase)"
}

function Get-DiagnosticFileSignatureProblem {
    # Why one file would make the launchers refuse the module, or $null when it is signed by Nexthink.
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath
    )

    try {
        $signature = Get-AuthenticodeSignature -FilePath $FilePath -ErrorAction Stop
    }
    catch {
        return "Could not read the signature of $FilePath. $(Get-DiagnosticErrorMessage -ErrorRecord $_)"
    }
    if ([string]$signature.Status -ne 'Valid') {
        return "Signature status '$($signature.Status)' on $FilePath"
    }
    $commonName = ''
    if ([string]$signature.SignerCertificate.Subject -match 'CN=([^,]+)') {
        $commonName = $Matches[1]
    }
    if ($commonName -ne $script:DIAGNOSTIC_SIGNER_COMMON_NAME) {
        return "$FilePath is signed by '$commonName', expected '$($script:DIAGNOSTIC_SIGNER_COMMON_NAME)'."
    }
    return $null
}

function Test-DiagnosticModuleSignature {
    <#
    .SYNOPSIS
        Checks that every file of the module that can run code is signed by Nexthink, as CI signs them all.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ModuleRoot
    )

    $check = 'Connector module signature'
    if ((Get-OperatingSystemType) -ne 'Windows') {
        return New-DiagnosticResult -Category 'Prerequisites' -Check $check -Status 'SKIP' `
            -Detail 'Authenticode signatures can only be checked on Windows.'
    }

    $reinstallHint = 'An unsigned or modified module file means the installed module is not the one Nexthink published. Reinstall it from PowerShell Gallery with Nutanix-Connector-Auto-Updater.ps1.'
    $codeFiles = @(Get-ChildItem -LiteralPath $ModuleRoot -Recurse -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Extension -in @('.ps1', '.psm1', '.ps1xml') })
    if ($codeFiles.Count -eq 0) {
        return New-DiagnosticResult -Category 'Prerequisites' -Check $check -Status 'FAIL' `
            -Detail "No module files found under $ModuleRoot." -Hint $reinstallHint
    }

    $problems = @($codeFiles | ForEach-Object { Get-DiagnosticFileSignatureProblem -FilePath $_.FullName } | Where-Object { $_ })
    if ($problems.Count -gt 0) {
        return New-DiagnosticResult -Category 'Prerequisites' -Check $check -Status 'FAIL' `
            -Detail "$($problems.Count) of $($codeFiles.Count) module files fail. First: $($problems[0])" -Hint $reinstallHint
    }

    return New-DiagnosticResult -Category 'Prerequisites' -Check $check -Status 'PASS' `
        -Detail "All $($codeFiles.Count) module files signed by $($script:DIAGNOSTIC_SIGNER_COMMON_NAME)"
}

#
# Configuration
#

function Get-DiagnosticConfigurationPath {
    <#
    .SYNOPSIS
        Same lookup as Get-Configuration: Config/config.json, then the lowercase config/ folder.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$ScriptRootPath
    )

    foreach ($folder in @('Config', 'config')) {
        $path = Join-Path -Path $ScriptRootPath -ChildPath (Join-Path -Path $folder -ChildPath 'config.json')
        if (Test-Path -LiteralPath $path -PathType Leaf) {
            return $path
        }
    }

    return $null
}

function Get-DiagnosticEnvironmentNames {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigurationPath
    )

    try {
        $raw = Get-Content -LiteralPath $ConfigurationPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        return @($raw.nutanixEnvironments | ForEach-Object { [string]$_.Name } | Where-Object { $_ })
    }
    catch {
        return @()
    }
}

function Test-DiagnosticHostName {
    <#
    .SYNOPSIS
        The connector builds https://<value>[:port]/..., so a value must be a bare host name or IP.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$FieldName,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Value
    )

    if ($Value -match '^\s*<.*>\s*$') {
        return New-DiagnosticResult -Category 'Configuration' -Check $FieldName -Status 'FAIL' `
            -Detail "Still the template placeholder '$Value'." -Hint "Set $FieldName in Config/config.json."
    }

    if ($Value -match '://|/|:|\s') {
        return New-DiagnosticResult -Category 'Configuration' -Check $FieldName -Status 'FAIL' `
            -Detail "'$Value' is not a bare host name." `
            -Hint "Use the host name only, without https://, a port or a path (the Prism Central port goes in PrismCentralPort)."
    }

    return New-DiagnosticResult -Category 'Configuration' -Check $FieldName -Status 'PASS' -Detail $Value
}

function Test-DiagnosticConfigurationValues {
    <#
    .SYNOPSIS
        Checks Get-Configuration does not make: placeholders, URLs instead of host names, risky options.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config
    )

    Test-DiagnosticHostName -FieldName 'PrismCentralFQDN' -Value $Config.EnvironmentConfig.PrismCentralFQDN
    Test-DiagnosticHostName -FieldName 'NexthinkAPI.HostFQDN' -Value $Config.NexthinkAPI.HostFQDN
    Test-DiagnosticHostName -FieldName 'NexthinkAPI.LoginFQDN' -Value $Config.NexthinkAPI.LoginFQDN

    foreach ($entry in @(
            @{ Field = 'nutanixEnvironments[].WindowsCredentialEntry'; Value = $Config.EnvironmentConfig.WindowsCredentialEntry },
            @{ Field = 'NexthinkAPI.WindowsCredentialEntry'; Value = $Config.NexthinkAPI.WindowsCredentialEntry }
        )) {
        if ($entry.Value -match '^\s*<.*>\s*$') {
            New-DiagnosticResult -Category 'Configuration' -Check $entry.Field -Status 'FAIL' `
                -Detail "Still the template placeholder '$($entry.Value)'." -Hint "Set $($entry.Field) to the name of the credential entry."
        }
    }

    if ($Config.EnvironmentConfig.SkipCertificateCheck) {
        New-DiagnosticResult -Category 'Configuration' -Check 'SkipCertificateCheck' -Status 'WARN' `
            -Detail "TLS certificate validation of Prism Central is disabled for '$($Config.EnvironmentConfig.Name)'." `
            -Hint "Prefer trusting Prism Central's certificate on this machine and setting SkipCertificateCheck to false."
    }

    $logLevel = 'INFO'
    if ($null -ne $Config.Logging -and -not [string]::IsNullOrWhiteSpace($Config.Logging.LogLevel)) {
        $logLevel = $Config.Logging.LogLevel.ToUpperInvariant()
    }
    $logLevelHint = if ($logLevel -eq 'DEBUG') {
        'Set it back to INFO once troubleshooting is done: DEBUG logs every request.'
    }
    else {
        'For verbose scheduled runs, set Logging.LogLevel to DEBUG in Config/config.json.'
    }
    New-DiagnosticResult -Category 'Configuration' -Check 'Log level' -Status 'INFO' -Detail $logLevel -Hint $logLevelHint
}

#
# Folders and local logging
#

function Test-DiagnosticFolderAccess {
    <#
    .SYNOPSIS
        Probes write access without creating the folder: a missing folder is probed through its
        nearest existing parent, which is where the connector would create it.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

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

    $probeDirectory = $Path
    $note = ''
    while (-not [string]::IsNullOrEmpty($probeDirectory) -and -not (Test-Path -LiteralPath $probeDirectory -PathType Container)) {
        # A file in the way stops the connector from creating the folder, however writable its parent.
        if (Test-Path -LiteralPath $probeDirectory -PathType Leaf) {
            return New-DiagnosticResult -Category 'Logging' -Check $Check -Status 'FAIL' `
                -Detail "$probeDirectory is a file, so the connector cannot create $Path." `
                -Hint "Move or delete the file $probeDirectory."
        }
        $probeDirectory = Split-Path -Path $probeDirectory -Parent
    }
    if ($probeDirectory -ne $Path) {
        $note = ' (created on the first run)'
    }

    if ([string]::IsNullOrEmpty($probeDirectory)) {
        return New-DiagnosticResult -Category 'Logging' -Check $Check -Status 'FAIL' `
            -Detail "Neither $Path nor any of its parents exists." -Hint 'Check the connector folder path.'
    }

    $probeFile = Join-Path -Path $probeDirectory -ChildPath ".diagnose-write-check_$([Guid]::NewGuid().ToString('N')).tmp"
    try {
        [System.IO.File]::WriteAllText($probeFile, 'diagnose')
        Remove-Item -LiteralPath $probeFile -Force -ErrorAction Stop
    }
    catch {
        $identity = Get-DiagnosticCurrentIdentity
        return New-DiagnosticResult -Category 'Logging' -Check $Check -Status 'FAIL' `
            -Detail "Cannot write to $probeDirectory. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
            -Hint "Grant the account running the connector Modify rights, e.g. icacls `"$probeDirectory`" /grant `"${identity}:(OI)(CI)M`""
    }

    return New-DiagnosticResult -Category 'Logging' -Check $Check -Status 'PASS' -Detail "$Path is writable$note"
}

function Test-DiagnosticLocalLogging {
    <#
    .SYNOPSIS
        Writes one marker line through the connector's own logger and reads it back from the log file.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

        [Parameter(Mandatory = $true)]
        [string]$Message,

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

    $logsFolder = Join-Path -Path $Config.ScriptRootPath -ChildPath (Join-Path -Path 'Logs' -ChildPath $Config.EnvironmentConfig.Name)
    $logFile = Join-Path -Path $logsFolder -ChildPath "NutanixConnector-$((Get-Date).ToString('yyyyMMdd')).log"
    # Unique per call: a marker from an earlier run today must not pass for a write that failed now
    # (Write-CustomLog swallows logging errors).
    $probe = "$Message (probe $([Guid]::NewGuid().ToString('N')))"

    # Written at the configured level, so the file target does not filter the marker out.
    $severity = 'INFO'
    if ($null -ne $Config.Logging -and $Config.Logging.LogLevel -in @('WARNING', 'ERROR')) {
        $severity = $Config.Logging.LogLevel.ToUpperInvariant()
    }

    try {
        Initialize-Logger -Config $Config
        # Initialize-Logger applies the configured level to the console as well.
        Set-Variable -Name 'CONSOLE_LOG_LEVEL' -Value $ConsoleLogLevel -Scope Script -Force
        Write-CustomLog -Message $probe -Severity $severity -NoCache
        Wait-LoggingFlush
    }
    catch {
        return New-DiagnosticResult -Category 'Logging' -Check 'Local log file' -Status 'FAIL' `
            -Detail "The logger could not start. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
            -Hint 'Install the Logging module (Install-Module -Name Logging -Scope AllUsers) and check the Logs folder rights.'
    }

    # The Logging module's file target can still be writing after Wait-Logging returns.
    for ($attempt = 0; $attempt -lt 30; $attempt++) {
        if ((Test-Path -LiteralPath $logFile -PathType Leaf) -and
            $null -ne (Select-String -LiteralPath $logFile -SimpleMatch -Pattern $probe -ErrorAction SilentlyContinue)) {
            return New-DiagnosticResult -Category 'Logging' -Check 'Local log file' -Status 'PASS' -Detail "Marker written to $logFile"
        }
        Start-Sleep -Milliseconds 100
    }

    return New-DiagnosticResult -Category 'Logging' -Check 'Local log file' -Status 'FAIL' `
        -Detail "The marker line did not reach $logFile." `
        -Hint 'The Logging module is installed but writes nothing: check the Logs folder rights and free disk space.'
}

#
# Credentials and scheduling
#

function Test-DiagnosticCredential {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Check,

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

    $createHint = "Create it while logged on as the account that runs the connector: New-StoredCredential -Target '$Target' -UserName '<user>' -Password '<password>' -Persist LocalMachine"
    try {
        $credential = Get-SecureCredential -Target $Target
    }
    catch {
        return New-DiagnosticResult -Category 'Configuration' -Check $Check -Status 'FAIL' `
            -Detail (Get-DiagnosticErrorMessage -ErrorRecord $_) -Hint $createHint
    }

    if ([string]::IsNullOrEmpty($credential.GetNetworkCredential().Password)) {
        return New-DiagnosticResult -Category 'Configuration' -Check $Check -Status 'FAIL' `
            -Detail "Entry '$Target' has an empty password." -Hint $createHint
    }

    if ([string]::IsNullOrEmpty($credential.UserName)) {
        return New-DiagnosticResult -Category 'Configuration' -Check $Check -Status 'FAIL' `
            -Detail "Entry '$Target' has an empty user name." -Hint $createHint
    }

    return New-DiagnosticResult -Category 'Configuration' -Check $Check -Status 'PASS' `
        -Detail "Entry '$Target' found, user name '$($credential.UserName)'"
}

function Get-DiagnosticTaskExecutionPolicy {
    <#
    .SYNOPSIS
        Works out the execution policy a scheduled task's pwsh command line gives the Runner.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Arguments
    )

    $runnerPath = ''
    if ($Arguments -match '(?:^|\s)-(?:f|fi|fil|file)\s+(?:"([^"]+)"|''([^'']+)''|(\S+))') {
        $runnerPath = @($Matches[1], $Matches[2], $Matches[3]) | Where-Object { $_ } | Select-Object -First 1
    }

    # pwsh takes any unambiguous prefix of -ExecutionPolicy (-ex, -exec) and the alias -ep.
    $taskPolicy = $null
    foreach ($match in [regex]::Matches($Arguments, '(?:^|\s)-(\w+)\s+["'']?(\w+)')) {
        $name = $match.Groups[1].Value.ToLowerInvariant()
        if ($name -eq 'ep' -or ($name.Length -ge 2 -and 'executionpolicy'.StartsWith($name))) {
            $taskPolicy = $match.Groups[2].Value
            break
        }
    }

    $state = Get-DiagnosticExecutionPolicyState -ScriptPath $runnerPath
    if ($state.SetByGroupPolicy) {
        $status = if ($null -eq $state.Problem) { 'PASS' } elseif ($state.Policy -eq 'Restricted') { 'FAIL' } else { 'WARN' }
        $hint = if ($null -eq $state.Problem) { '' } else { "$($state.Problem) $(Get-DiagnosticGroupPolicyHint -ScriptPath $runnerPath)" }
        return [pscustomobject]@{ Status = $status; Detail = "execution policy $($state.Policy) (Group Policy)"; Hint = $hint }
    }

    if ($null -ne $taskPolicy) {
        $problem = Get-DiagnosticExecutionPolicyProblem -Policy $taskPolicy -ScriptPath $runnerPath
        $status = if ($null -eq $problem) { 'PASS' } elseif ($taskPolicy -eq 'Restricted') { 'FAIL' } else { 'WARN' }
        $hint = if ($null -eq $problem) { '' } else { "$problem Use -ExecutionPolicy Bypass in the task arguments." }
        return [pscustomobject]@{ Status = $status; Detail = "-ExecutionPolicy $taskPolicy"; Hint = $hint }
    }

    $detail = "no -ExecutionPolicy, so $($state.Policy) from $($state.Scope)"
    if ($null -eq $state.Problem) {
        return [pscustomobject]@{ Status = 'PASS'; Detail = $detail; Hint = '' }
    }
    $status = if ($state.Policy -eq 'Restricted') { 'FAIL' } else { 'WARN' }
    return [pscustomobject]@{
        Status = $status
        Detail = $detail
        Hint   = "$($state.Problem) Add -ExecutionPolicy Bypass before -File in the task arguments; it applies to that process only."
    }
}

function Test-DiagnosticScheduledTask {
    <#
    .SYNOPSIS
        Finds the scheduled tasks that start the Runner for this environment and checks how they run.
        Credential Manager entries belong to one Windows account, so a task running as another account
        than the current one cannot see the credentials checked by this run.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$NutanixEnvironment,

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

    if ((Get-OperatingSystemType) -ne 'Windows' -or -not (Get-Command -Name 'Get-ScheduledTask' -ErrorAction SilentlyContinue)) {
        return New-DiagnosticResult -Category 'Scheduling' -Check 'Scheduled task' -Status 'SKIP' `
            -Detail 'Task Scheduler checks are only available on Windows.'
    }

    try {
        $runnerTasks = @(Get-ScheduledTask -ErrorAction Stop | Where-Object {
                @($_.Actions | Where-Object { "$($_.Execute) $($_.Arguments)" -match [regex]::Escape($script:DIAGNOSTIC_RUNNER_SCRIPT_NAME) }).Count -gt 0
            })
    }
    catch {
        return New-DiagnosticResult -Category 'Scheduling' -Check 'Scheduled task' -Status 'SKIP' `
            -Detail "Cannot list scheduled tasks. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" -Hint 'Run this check from an elevated session.'
    }

    $scheduleHint = "Schedule the Runner every 5 minutes: pwsh.exe -NoProfile -ExecutionPolicy Bypass -File <connector folder>\$($script:DIAGNOSTIC_RUNNER_SCRIPT_NAME) -NutanixEnvironment $NutanixEnvironment"
    # Bare, or wholly inside matching quotes: 'lab' must not match "lab production".
    $environmentName = [regex]::Escape($NutanixEnvironment)
    $environmentPattern = "-NutanixEnvironment\s+(`"$environmentName`"|'$environmentName'|$environmentName)(\s|$)"
    $tasks = @($runnerTasks | Where-Object {
            @($_.Actions | Where-Object { [string]$_.Arguments -match $environmentPattern }).Count -gt 0
        })

    if ($tasks.Count -eq 0) {
        $others = if ($runnerTasks.Count -gt 0) { " Runner tasks found for other environments: $(($runnerTasks.TaskName) -join ', ')." } else { '' }
        return New-DiagnosticResult -Category 'Scheduling' -Check 'Scheduled task' -Status 'WARN' `
            -Detail "No scheduled task runs $($script:DIAGNOSTIC_RUNNER_SCRIPT_NAME) for '$NutanixEnvironment'.$others" -Hint $scheduleHint
    }

    foreach ($task in $tasks) {
        $check = "Task '$($task.TaskPath)$($task.TaskName)'"
        $action = @($task.Actions | Where-Object { [string]$_.Arguments -match $environmentPattern })[0]
        $status = 'PASS'
        $hints = [System.Collections.Generic.List[string]]::new()
        $details = [System.Collections.Generic.List[string]]::new()

        $runAs = [string]$task.Principal.UserId
        if ([string]::IsNullOrWhiteSpace($runAs)) { $runAs = [string]$task.Principal.GroupId }
        $details.Add("runs as '$runAs'")
        if ((ConvertTo-DiagnosticAccountKey -Account $runAs) -ne (ConvertTo-DiagnosticAccountKey -Account $CurrentIdentity)) {
            $status = 'WARN'
            $hints.Add("This diagnostic runs as '$CurrentIdentity': the credential checks above do not prove '$runAs' can read its entries. Run the diagnostic as '$runAs' too (for SYSTEM: psexec -s), or create the credential entries as that account.")
        }

        $details.Add("with '$($action.Execute)'")
        if ([string]$action.Execute -match '(^|\\)powershell(\.exe)?"?$') {
            $status = 'FAIL'
            $hints.Add('The task starts Windows PowerShell 5.1, which cannot load the module. Use pwsh.exe (PowerShell 7).')
        }

        $policyResult = Get-DiagnosticTaskExecutionPolicy -Arguments ([string]$action.Arguments)
        $details.Add($policyResult.Detail)
        if ($policyResult.Status -eq 'FAIL') {
            $status = 'FAIL'
        }
        elseif ($policyResult.Status -eq 'WARN' -and $status -eq 'PASS') {
            $status = 'WARN'
        }
        if (-not [string]::IsNullOrWhiteSpace($policyResult.Hint)) {
            $hints.Add($policyResult.Hint)
        }

        $details.Add("state $($task.State)")
        if ([string]$task.State -eq 'Disabled') {
            if ($status -eq 'PASS') { $status = 'WARN' }
            $hints.Add('Enable the task.')
        }

        try {
            $info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop
            $lastResult = [int64]$info.LastTaskResult
            switch ($lastResult) {
                0 { $details.Add("last run $($info.LastRunTime) succeeded") }
                267009 { $details.Add('currently running') }
                267011 {
                    $details.Add('has not run yet')
                    if ($status -eq 'PASS') { $status = 'WARN' }
                }
                default {
                    $details.Add(('last run {0} returned 0x{1:X}' -f $info.LastRunTime, $lastResult))
                    if ($status -eq 'PASS') { $status = 'WARN' }
                    $hints.Add('The last scheduled run failed: check the connector log of that run.')
                }
            }
        }
        catch {
            $details.Add('last run unknown')
        }

        New-DiagnosticResult -Category 'Scheduling' -Check $check -Status $status -Detail ($details -join ', ') -Hint ($hints -join ' ')
    }
}

#
# Connectivity
#

function Get-DiagnosticProxyUri {
    <#
    .SYNOPSIS
        The proxy Invoke-WebRequest goes through for https://HostName:Port, or $null for a direct
        connection. Invoke-WebRequest uses the system proxy (HTTPS_PROXY, or the Windows proxy settings)
        unless told otherwise, and the connector never tells it otherwise.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$HostName,

        [Parameter(Mandatory = $true)]
        [int]$Port,

        [Parameter(Mandatory = $false)]
        [AllowNull()]
        [System.Net.IWebProxy]$Proxy = [System.Net.Http.HttpClient]::DefaultProxy
    )

    # A malformed host (such as one with a port) is no URI: the direct probe then reports it.
    $target = $null
    if (-not [Uri]::TryCreate("https://${HostName}:$Port/", [UriKind]::Absolute, [ref]$target)) {
        return $null
    }
    if ($null -eq $Proxy -or $Proxy.IsBypassed($target)) {
        return $null
    }

    $proxyUri = $Proxy.GetProxy($target)
    if ($null -eq $proxyUri -or $proxyUri -eq $target) {
        return $null
    }

    return $proxyUri
}

function Test-DiagnosticEndpoint {
    <#
    .SYNOPSIS
        DNS lookup, TCP connection, then a TLS handshake. TCP alone proves little: a wrong port, or a
        firewall that accepts every port, still opens a connection. Only an endpoint that completes the
        handshake passes. The certificate is reported here, not enforced: the API checks validate it the
        same way the connector does.

        Behind a proxy the connector never connects directly, so the direct probe is skipped (INFO) and
        the API checks, which go through the same proxy as the connector, decide.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Check,

        [Parameter(Mandatory = $true)]
        [string]$HostName,

        [Parameter(Mandatory = $true)]
        [int]$Port,

        # What to check when the port opens but does not speak TLS.
        [Parameter(Mandatory = $false)]
        [string]$TlsHint = 'Check that no firewall or proxy intercepts the connection.',

        [Parameter(Mandatory = $false)]
        [int]$TimeoutMilliseconds = $script:DIAGNOSTIC_TCP_TIMEOUT_MILLISECONDS
    )

    $proxyUri = Get-DiagnosticProxyUri -HostName $HostName -Port $Port
    if ($null -ne $proxyUri) {
        return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'INFO' `
            -Detail "${HostName}:$Port is reached through the proxy $proxyUri. The direct DNS, TCP and TLS probe is skipped; the API checks test the path through the proxy."
    }

    try {
        $addresses = @([System.Net.Dns]::GetHostAddresses($HostName))
    }
    catch {
        return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'FAIL' `
            -Detail "DNS lookup of '$HostName' failed. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
            -Hint 'Check the host name in Config/config.json and the DNS settings of this machine.'
    }

    $client = [System.Net.Sockets.TcpClient]::new()
    $sslStream = $null
    try {
        try {
            $connect = $client.ConnectAsync($HostName, $Port)
            if (-not $connect.Wait($TimeoutMilliseconds)) {
                return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'FAIL' `
                    -Detail "No answer from ${HostName}:$Port within $($TimeoutMilliseconds / 1000) s." `
                    -Hint "A firewall blocks outbound TCP $Port. If this machine must go through a proxy, set it for this account (HTTPS_PROXY or the Windows proxy settings): the connector uses the system proxy."
            }
        }
        catch {
            return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'FAIL' `
                -Detail "TCP connection to ${HostName}:$Port failed. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
                -Hint "Check that ${HostName} listens on $Port and that no firewall rejects the connection."
        }

        # Synchronous handshake: the callback is a PowerShell script block and must run on this thread.
        $client.ReceiveTimeout = $TimeoutMilliseconds
        $client.SendTimeout = $TimeoutMilliseconds
        $certificateInfo = @{ Subject = $null; NotAfter = $null; Errors = $null }
        $callback = {
            param($source, $certificate, $chain, $policyErrors)
            $null = $source, $chain # Unused, required by the RemoteCertificateValidationCallback signature.
            if ($null -ne $certificate) {
                $certificateInfo.Subject = $certificate.Subject
                $certificateInfo.NotAfter = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certificate).NotAfter
            }
            $certificateInfo.Errors = $policyErrors
            return $true
        }.GetNewClosure()

        $sslStream = [System.Net.Security.SslStream]::new($client.GetStream(), $false,
            [System.Net.Security.RemoteCertificateValidationCallback]$callback)
        try {
            $sslStream.AuthenticateAsClient($HostName)
        }
        catch {
            return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'FAIL' `
                -Detail "TCP to ${HostName}:$Port opens, but the TLS handshake failed: this port does not answer as an HTTPS endpoint. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
                -Hint $TlsHint
        }
    }
    finally {
        if ($null -ne $sslStream) { $sslStream.Dispose() }
        $client.Dispose()
    }

    $trusted = ([string]$certificateInfo.Errors -eq 'None')
    $expires = if ($null -ne $certificateInfo.NotAfter) { $certificateInfo.NotAfter.ToString('yyyy-MM-dd') } else { 'unknown' }
    $trustText = if ($trusted) { 'trusted by this machine' } else { "not trusted by this machine ($($certificateInfo.Errors))" }

    return New-DiagnosticResult -Category 'Connectivity' -Check $Check -Status 'PASS' `
        -Detail "${HostName}:$Port answers HTTPS ($($addresses[0].IPAddressToString)). Certificate '$($certificateInfo.Subject)', expires $expires, $trustText."
}

function Get-DiagnosticPrismCentralHint {
    param(
        [AllowNull()]
        [Nullable[int]]$StatusCode,

        [string]$Message,

        [string]$EntryName
    )

    switch (Get-DiagnosticTlsFailureKind -Message $Message) {
        'Certificate' { return "This machine does not trust Prism Central's TLS certificate. Import its CA into the machine's Trusted Root store, or set SkipCertificateCheck to true for this environment (less secure)." }
        'Handshake' { return 'The TLS handshake failed. Check that PrismCentralPort is the Prism Central HTTPS port (9440 by default) and that no firewall or proxy intercepts the connection.' }
    }

    switch ($StatusCode) {
        401 { return "Prism Central rejected the user name or password stored in '$EntryName'. Update the credential entry." }
        403 { return 'The account is authenticated but may not read clusters. Give it a read-only (Viewer) role in Prism Central.' }
        404 { return 'The v4 API was not found. PrismCentralFQDN must point to Prism Central (not Prism Element), pc.2024.3 or later.' }
        { $_ -ge 500 } { return 'Prism Central returned a server error. Check its health and retry later.' }
    }

    return 'Check PrismCentralFQDN, PrismCentralPort and the network path to Prism Central.'
}

function Test-DiagnosticPrismCentralApi {
    <#
    .SYNOPSIS
        One authenticated, read-only call (the first page of the cluster list). No retry, so a failure
        is reported at once with its HTTP status.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixEnvironmentConfiguration]$EnvironmentConfig
    )

    $check = 'Prism Central API'
    try {
        $context = Get-NutanixApiContext -EnvironmentConfig $EnvironmentConfig
        $uri = "$($context.BaseUri)/clustermgmt/v4.0/config/clusters" +
            (Build-NutanixQueryString -Query @{ '$limit' = 1; '$select' = $script:NUTANIX_CLUSTER_SELECT })
        $response = Invoke-WebRequest -Uri $uri -Method 'GET' -Headers $context.Headers -UseBasicParsing `
            -SkipCertificateCheck:$context.SkipCertificateCheck -TimeoutSec $script:DIAGNOSTIC_HTTP_TIMEOUT_SECONDS -ErrorAction Stop
    }
    catch {
        $message = Get-DiagnosticErrorMessage -ErrorRecord $_
        $statusCode = Get-DiagnosticHttpStatusCode -ErrorRecord $_
        $statusText = if ($null -ne $statusCode) { "HTTP $statusCode. " } else { '' }
        return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'FAIL' `
            -Detail "$statusText$message" `
            -Hint (Get-DiagnosticPrismCentralHint -StatusCode $statusCode -Message $message -EntryName $EnvironmentConfig.WindowsCredentialEntry)
    }

    # A login page or a proxy error page can answer 200 too: only a JSON answer is the v4 API.
    $body = $null
    try {
        $body = $response.Content | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        $body = $null
    }
    if ($null -eq $body) {
        return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'FAIL' `
            -Detail "HTTP $($response.StatusCode), but the answer is not JSON, so it does not come from the Prism Central v4 API." `
            -Hint 'Check that PrismCentralFQDN and PrismCentralPort point to Prism Central itself, not to a login page, a load balancer or a proxy.'
    }

    $total = $body.metadata.totalAvailableResults
    $countText = if ($null -ne $total) { " Prism Central lists $total cluster entries (Prism Central itself included)." } else { '' }

    return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'PASS' `
        -Detail "Authenticated as the '$($EnvironmentConfig.WindowsCredentialEntry)' account.$countText"
}

function Get-DiagnosticNexthinkHint {
    param(
        [AllowNull()]
        [Nullable[int]]$StatusCode,

        [string]$Message,

        [string]$EntryName
    )

    if ($null -ne (Get-DiagnosticTlsFailureKind -Message $Message)) {
        return 'TLS to Nexthink failed. A TLS-inspecting proxy or firewall may be replacing the certificate; allow direct HTTPS to Nexthink.'
    }

    switch ($StatusCode) {
        { $_ -in @(400, 401) } { return "The client id or secret in '$EntryName' was rejected. Check the API credentials in Nexthink and NexthinkAPI.LoginFQDN." }
        403 { return 'The API credentials are valid but lack the permission for this API. Check the API credential scopes in Nexthink.' }
        404 { return 'The endpoint was not found. Check NexthinkAPI.HostFQDN and NexthinkAPI.LoginFQDN for the tenant.' }
    }

    return 'Check NexthinkAPI.HostFQDN, NexthinkAPI.LoginFQDN and the network path to Nexthink.'
}

function Test-DiagnosticNexthinkToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixNexthinkApiConfiguration]$NexthinkApi
    )

    $check = 'Nexthink access token'
    try {
        $jwt = Get-Jwt -LoginHost $NexthinkApi.LoginFQDN -Target $NexthinkApi.WindowsCredentialEntry
    }
    catch {
        $message = Get-DiagnosticErrorMessage -ErrorRecord $_
        return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'FAIL' -Detail $message `
            -Hint (Get-DiagnosticNexthinkHint -StatusCode (Get-DiagnosticHttpStatusCode -ErrorRecord $_) -Message $message -EntryName $NexthinkApi.WindowsCredentialEntry)
    }

    if ([string]::IsNullOrWhiteSpace($jwt)) {
        return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'FAIL' `
            -Detail "$($NexthinkApi.LoginFQDN) answered without an access token." -Hint 'Check NexthinkAPI.LoginFQDN for the tenant.'
    }

    return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'PASS' -Detail "Token issued by $($NexthinkApi.LoginFQDN)"
}

function Test-DiagnosticLogsApi {
    <#
    .SYNOPSIS
        Sends a single entry, prefixed with the diagnostic marker, to the Nexthink logs API.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

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

    $check = 'Nexthink logs API'
    $traceId = [Guid]::NewGuid().ToString()
    $entry = @{
        timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
        level     = 'INFO'
        message   = "$($script:DIAGNOSTIC_LOG_MARKER) Test entry from Test-NexthinkNutanixConnector $Version"
    }

    try {
        $jwt = Get-Jwt -LoginHost $Config.NexthinkAPI.LoginFQDN -Target $Config.NexthinkAPI.WindowsCredentialEntry
        $response = Invoke-LogsApi -LogsApiHost $Config.NexthinkAPI.HostFQDN -Jwt $jwt `
            -NutanixEnvironment $Config.EnvironmentConfig.Name -Version $Version -Logs @($entry) -TraceId $traceId
    }
    catch {
        $message = Get-DiagnosticErrorMessage -ErrorRecord $_
        return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'FAIL' -Detail "Trace id $traceId. $message" `
            -Hint (Get-DiagnosticNexthinkHint -StatusCode (Get-DiagnosticHttpStatusCode -ErrorRecord $_) -Message $message -EntryName $Config.NexthinkAPI.WindowsCredentialEntry)
    }

    return New-DiagnosticResult -Category 'Connectivity' -Check $check -Status 'PASS' `
        -Detail "Test entry accepted (HTTP $($response.StatusCode), trace id $traceId)."
}

#
# Collection test
#

function Invoke-DiagnosticCollection {
    <#
    .SYNOPSIS
        Runs one real collection cycle against Prism Central with every connector folder (Logs, State,
        Spool, Storage) redirected to a new collection-<id> folder under OutputPath, and writes the
        payloads to files instead of sending them. The connector's own state, spool and VM cache are
        never read or written.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [NutanixConnectorConfiguration]$Config,

        [Parameter(Mandatory = $true)]
        [string]$OutputPath,

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

    $category = 'Scheduling'

    # The collection runs in a folder created here, never in an existing one. Whatever OutputPath is
    # (the connector folder, a link to it, one of its data folders), the collection cannot reach the
    # live State, Spool or Storage files, and the scheduled Runner, which only reads its own
    # State/<env>, Spool/<env> and Storage/<env> folders, never sees what the collection writes.
    $workspace = Join-Path -Path $OutputPath -ChildPath "collection-$([Guid]::NewGuid().ToString('N').Substring(0, 8))"
    try {
        # Without -Force, New-Item fails on an existing folder instead of reusing it.
        New-Item -Path $workspace -ItemType Directory -ErrorAction Stop | Out-Null
    }
    catch {
        return New-DiagnosticResult -Category $category -Check 'Collection cycle' -Status 'FAIL' `
            -Detail "Could not create the collection folder $workspace. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
            -Hint 'Pass an -OutputPath this account can write to.'
    }

    $logging = [NutanixLoggingConfiguration]::new()
    $logging.LogLevel = 'DEBUG'
    $logging.LogRetentionDays = 1

    $collectionConfig = [NutanixConnectorConfiguration]::new()
    $collectionConfig.ScriptRootPath = $workspace
    $collectionConfig.EnvironmentConfig = $Config.EnvironmentConfig
    $collectionConfig.NexthinkAPI = $Config.NexthinkAPI
    $collectionConfig.Logging = $logging

    $logsFolder = Join-Path -Path $workspace -ChildPath (Join-Path -Path 'Logs' -ChildPath $Config.EnvironmentConfig.Name)

    try {
        Initialize-Logger -Config $collectionConfig
        Set-Variable -Name 'CONSOLE_LOG_LEVEL' -Value $ConsoleLogLevel -Scope Script -Force
        Write-CustomLog -Message "$($script:DIAGNOSTIC_LOG_MARKER) Collection test started. Nothing is sent to Nexthink." -Severity 'INFO' -NoCache

        $state = Read-State -Config $collectionConfig
        $collection = Invoke-NutanixPrismCentralCollection -Config $collectionConfig -State $state
    }
    catch {
        return New-DiagnosticResult -Category $category -Check 'Collection cycle' -Status 'FAIL' `
            -Detail "Collection failed. $(Get-DiagnosticErrorMessage -ErrorRecord $_)" `
            -Hint "The DEBUG log of the collection test is in $logsFolder."
    }
    finally {
        Wait-LoggingFlush
    }

    $summary = $collection.Summary
    $payload = $collection.Payload
    $eventCount = 0
    $vmCount = 0
    foreach ($dataItem in @($payload.data)) {
        $eventCount += @($dataItem.events).Count
        $vmCount += @($dataItem.virtual_machines).Count
    }

    $status = 'PASS'
    $hints = [System.Collections.Generic.List[string]]::new()
    if ([int]$summary.Hosts -eq 0) {
        $status = 'WARN'
        $hints.Add('Prism Central returned no AHV host: check that the account can see the clusters.')
    }
    if ([int]$summary.ClusterFailures -gt 0 -or [int]$summary.HostFailures -gt 0 -or [int]$summary.VmStatsFailures -gt 0) {
        $status = 'WARN'
        $hints.Add("Some requests failed (clusters $($summary.ClusterFailures), hosts $($summary.HostFailures), VM stats $($summary.VmStatsFailures)). See the DEBUG log in $logsFolder.")
    }
    if ([int]$summary.Hosts -gt 0 -and $eventCount -eq 0) {
        $status = 'WARN'
        $hints.Add('No complete minute was available yet. This is normal right after a host joins; otherwise check the host statistics in Prism Central.')
    }

    New-DiagnosticResult -Category $category -Check 'Collection cycle' -Status $status `
        -Detail "Clusters $($summary.Clusters), hosts $($summary.Hosts), VMs $($summary.Vms), host events $eventCount, VMs in payload $vmCount, $($summary.DurationSeconds) s" `
        -Hint ($hints -join ' ')

    $eventsFile = Join-Path -Path $OutputPath -ChildPath 'payload-hypervisor-events.json'
    $vmsFile = Join-Path -Path $OutputPath -ChildPath 'payload-virtual-machines.json'
    try {
        Set-Content -LiteralPath $eventsFile -Value (ConvertTo-HypervisorPayloadJson -Payload $payload -ExcludeVirtualMachines) -Encoding utf8 -ErrorAction Stop
        Set-Content -LiteralPath $vmsFile -Value (ConvertTo-HypervisorPayloadJson -Payload $payload -ExcludeHypervisorEvents) -Encoding utf8 -ErrorAction Stop
        New-DiagnosticResult -Category $category -Check 'Payloads' -Status 'INFO' `
            -Detail "Not sent. Written to $eventsFile and $vmsFile. The collection's DEBUG log, state and spool are in $workspace."
    }
    catch {
        New-DiagnosticResult -Category $category -Check 'Payloads' -Status 'WARN' `
            -Detail "Could not write the payload files. $(Get-DiagnosticErrorMessage -ErrorRecord $_)"
    }
}

#
# Report
#

function ConvertTo-DiagnosticReportLines {
    <#
    .SYNOPSIS
        Renders results as text lines (with a console colour each), grouped by category in check order.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [object[]]$Results,

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

    $colours = @{ PASS = 'Green'; WARN = 'Yellow'; FAIL = 'Red'; SKIP = 'DarkGray'; INFO = 'Cyan' }
    $lines = [System.Collections.Generic.List[object]]::new()
    $lines.Add([pscustomobject]@{ Text = $Title; Colour = 'White' })

    $categories = [System.Collections.Generic.List[string]]::new()
    foreach ($result in $Results) {
        if (-not $categories.Contains($result.Category)) { $categories.Add($result.Category) }
    }

    foreach ($category in $categories) {
        $lines.Add([pscustomobject]@{ Text = ''; Colour = 'White' })
        $lines.Add([pscustomobject]@{ Text = $category; Colour = 'White' })
        foreach ($result in @($Results | Where-Object { $_.Category -eq $category })) {
            $text = " [$($result.Status)] $($result.Check)"
            if (-not [string]::IsNullOrWhiteSpace($result.Detail)) { $text += ": $($result.Detail)" }
            $lines.Add([pscustomobject]@{ Text = $text; Colour = $colours[$result.Status] })
            if (-not [string]::IsNullOrWhiteSpace($result.Hint) -and $result.Status -ne 'PASS') {
                $lines.Add([pscustomobject]@{ Text = " -> $($result.Hint)"; Colour = 'Gray' })
            }
        }
    }

    $counts = foreach ($status in @('PASS', 'WARN', 'FAIL', 'SKIP')) {
        "$(@($Results | Where-Object { $_.Status -eq $status }).Count) $status"
    }
    $lines.Add([pscustomobject]@{ Text = ''; Colour = 'White' })
    $lines.Add([pscustomobject]@{ Text = "Summary: $($counts -join ', ')"; Colour = 'White' })

    return $lines
}

function Write-DiagnosticReport {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console report for the person installing the connector.')]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [object[]]$Lines
    )

    foreach ($line in $Lines) {
        Write-Host $line.Text -ForegroundColor $line.Colour
    }
}

# SIG # Begin signature block
# MIIohQYJKoZIhvcNAQcCoIIodjCCKHICAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAquVZIUNOFnyWW
# oFT+FS+qB1r/le8+XUZtw3SBSeu0bqCCIZ0wggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCE/cM09+RU7bww+P+ZIYNTANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI2MDgwNTAwMDAwMFoXDTM3MTEwNDIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjYgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBALZ7pvLJ/s1K+NSbTGWz/TjGMPh8CQ6RucZC
# Lv5anHzWJjF/NWJrFIhy24fcpKXlgRiky4WAawDfU3YP0BMxt9l3Dm5oCG5Z69Aq
# EN1kgHg2epx+l+lZBcmJCcN0ASURML5uFIS80sZsDwO3BSkUxDjLJhBI+qiZP3ai
# xAC/qEGLjsBNlLol9VZ7pfGEXiMlneJIC5/YKuizVzNFKZZEeoy/0B8Zm+nzKBgS
# WG52lCO1w+nCg6XpCtklTJXeIg283hw7TmmsZXR+SMbjbrEOvZ3fP2VxIgeR28Y9
# 0ZStd3F9VuA5RVynb/whITPAo9b75Zr4Ta6Mj3URm26QZYMn/FnbuTegcoRcFEZ9
# FOqM5T6MTdtr/n74lIT/ug0eeOzmZ6QTFg33otX+bFRsIolvykE1jive4PuESaT8
# zzVeFWDAMDtozNgLctkGD1ZjkEyZtJrLl5ya0m5doH/ScpaZCZVl6pNUOCybMc/k
# xC6EAmSJY24L0yYKD1Nkddsnb/ItVKi/2nXpQNMu1PT5prW83vV8d67WowuUs0Hd
# Y4H8AMLGvdL/WHEj3ZnqMqAQQP9u3Ai9t+5eQ02GDwy0ODjdzi0xlp70W+ow63/0
# ++YDEX1M0iwgUHwbrJvfpklkZQvw3+kv3vUPItdwroczk9icflf55W1zOEKAcJVA
# IXpcMCU9AgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQUyWOK
# MC7USvtulPPm40B+9ezN4jAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQCNxTphHp1SCt+ZrAmAfn0oQLFr0mLywSLaDXQIENoyKqxrFbJblzCV
# P/pkXmwXOdrOpWygLzlT12os5ipDCy35RBCg2UMeApEtrfGhz45F4Wt4WGdNdIbR
# Wt3YTYJmpR+b7lr4d7Uwn+H600u4D7RnOGf8Wj4UNgAdZkfHhHv1mx9EVh71SJel
# cEN/oORSjXzdjfw1iZH9d8Nh/thn6hH23d+VsPAr6GAYyzSA02nXD1nYLI7Ijmiv
# +xLCiYC41DSFYL3GhTiy0PxpawPtGRyaBVGzq+UiTfM8pD7KVyF5aQyWP4KhVGUU
# Tnmm/RlYJoW3TiXA/+t0YcT2oRVBm3JETjajHug2AL+v5jhtKVnd3D0rbHXEu27o
# +Q8p4sEWPMqKDB+qbceb6T/6WcwTwXmQ9lOCLLYcsQeSWmvKqzpAec9etE14jOQA
# zLKWdE3w/TCaKtLRaRT7LCkRYVnhA2D73FLje1O5b3HR5eHs0NzU/+xX7NbEdcof
# y0W3Wdwd1XOqtlpg/JgwtKfZM5dqO94lbUveOiJBI+xZEbGRsMNbXmMREUTgu+Oc
# a7Y73MPWcslIx2VhkSKSXjDbD6rgg39H5Mh7QfieAIjWagkJNt68Yfim6cjEzVSi
# LSeZfdkr5dtFPTW6jATlWJdYeeDRGCyatf8R1hSjzSvdN8yWQPT9gzCCB6swggWT
# oAMCAQICEA20PGu9jXp4I/Dy4yL8Si8wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNTExMTMwMDAwMDBaFw0yODExMTQyMzU5NTlaMIGzMRMwEQYLKwYBBAGC
# NzwCAQMTAkNIMRUwEwYLKwYBBAGCNzwCAQITBFZhdWQxHTAbBgNVBA8MFFByaXZh
# dGUgT3JnYW5pemF0aW9uMRgwFgYDVQQFEw9DSEUtMTEyLjAwMC41NzkxCzAJBgNV
# BAYTAkNIMQ8wDQYDVQQHEwZQcmlsbHkxFjAUBgNVBAoTDU5FWFRoaW5rIFMuQS4x
# FjAUBgNVBAMTDU5FWFRoaW5rIFMuQS4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
# ggIKAoICAQDIbNJpFq6aDbvQP7zFPymmt3WRPkvbmnRL4g0imzFNbaj+U/RIGPL0
# 3l9TtYxzraPbwDdidmT5P50mK1JqjF5FzcZEC4kZfMMojY4J/gOylo8jOdLjBscL
# SYwsY+85ZRs2bvxibPXIu4AcZttxBx5XdbVyt2Bd1vtuJGj+wfU1vJu7NB3GHjPy
# 4QE1BFrgidqQTL+1HASQaWwNMetW8B1U1POZjr6uWp6Ga3DF/MAKxpbvU3iVFI8b
# YFpKiVIQ9Namj3DQ7Xj26+fSEP959+bukG8LyObHN5DDkRdpnCSVVMT6qxZ2QGri
# DFkyjOVaGkjJTI6mbKY1JPUJhYVo+7Jqhu+2EFGRy6wSj9wZBubnbwfuvgr/XlH9
# /xQQuX9i/EJvxJRQQyz9IzLtB/DBSoEQvtyCh8Po87k9x9CGYn7k1HxUg1F6oBKa
# kemv3G474RWKZwSEIbwUlygFydEpTDkRTqBDZKOQf3VaUvfUgGmBmEfq1m+cljHj
# fLDBtkDIGaZ7SvyO72q9Uj0HrIWvJDHHIYK84gqhe58vKgaQ0uH9V5BCUT5cLvau
# +1w93iq5AMCTtG96SEyjI/T9Jzh+2sM+PoUwlLNLqYFUvMI+l19X+5UtQnjU5k1c
# ooxqCebatsS642gd3X1VoeNyfJ5FB67b6KGNfrDOXBt5FXgKZAUOHwIDAQABo4IC
# AjCCAf4wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE
# FICGOh06CZAfMG3M4l2zy9590p0OMD0GA1UdIAQ2MDQwMgYFZ4EMAQMwKTAnBggr
# BgEFBQcCARYbaHR0cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQE
# AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1o
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2ln
# bmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQu
# ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2
# U0hBMzg0MjAyMUNBMS5jcmwwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzAB
# hhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9j
# YWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5n
# UlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQEL
# BQADggIBALSLCU45H6xjr2Ce9mmr6WtqdnykOcc2s1iqd/UN88f4GlmzVmtA9Dts
# 9j8X77hxHYIAJwWM3NqKi8m55CB5HQBuOC3hOI8bWFdQLBsrAbBAApFh3cjRruGn
# r0mu7wlZo9HLgpM1PEy+hf90+cANYRBITrRkfVT4o2rTc8mmJyYzZM8WVwxGx+wm
# PAuvyjYKsjZNTeJ+miYqwJ/zHjc8LQP+/hYPNmP9ubYpMMkgC49vP9Uir0E4YI2C
# VTbUuhxao4Pmy2MnqhK20lDu50ZCNDNFurYhzXWBhK1cel6Ku4yuT6TlsjNye0AY
# v92FDyAaYH9VZuub7qZBNv/RwmcLUDqqh5rnEdeGMH7KFkUyN63RMEr7hLXV/Tav
# 7K0kWlXKPLIFenfqJnPYceni6PdJS/7WlxnBE0UwgoSoe4HawnEF0YvYBU3uyK+v
# TJWVau5Jptjp94uEQmW6PidWwUWNzVl4LQ7eEZe4ykZ0JLErbGnOeoUoZCeVzpx4
# uUEFiZv1KLeLEkB/8yFIrQp03sVMItqhNJ5mbDOoWW4y3Z8/jduhxVPwJMHhgglK
# 3z3sZr5xrpY3n1Qcqn1xFsI6U7rOrkzyQBw+d/yF82MW3LldZ7tPyU8LTEO89oOC
# EsGFwOfEb/wB7UJ1NulZ1R2nf5erYEnVsOsrGV0X1fYh2OoRQyxWMYIGPjCCBjoC
# AQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYg
# U0hBMzg0IDIwMjEgQ0ExAhANtDxrvY16eCPw8uMi/EovMA0GCWCGSAFlAwQCAQUA
# oGowGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwG
# CisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEICkwMJ6o0DPjz6cNq06t4KHk46vj
# Bz5QNfduv9PWPQlJMA0GCSqGSIb3DQEBAQUABIICAHw8FpRaBDC4Zp/6RCx7mUP6
# bYGs2kpOobX/MhJNra9a1hjMaQpSLXzwMigFOaCkq8NLXaJJ+BhXjyTBddzUuqYn
# km+u/xsX8v84ZbKsfPQAQmCGUET+PrPukMYEhDCK/s2rlymbgemAUK/gVzyM4R6F
# 4s0thtSZAPEtiy24B632lrMbrbopNLooHz+TWuElQnC2EFzK28wp6hQfBFobWLuf
# a4KqPCbRITHaaHTRJYQTDyrZEH00txIeAzCQqNyEtGGs1ogOo/TNJzyRZb4UcRmK
# T7vEj6peTJXlkipG16Z4IIXCdIKBRw+1yhzCye+pVKu8TWUcSEAdZFj2Ql7h5uZz
# gkdr94JpJntyqhU5G0fcYjAb0+KtqHEZi09uMw6DJmH26QNrZSaRi61NcwX7nIaL
# FVaqBIV31fFkJS99KtcQnaz6hNxDlBvaN46G1S+UOpUcAeqoC5TprQH7Rk1FVeVU
# 8/BjpTjs5hJ/UemrH6MTlVJfk84EYu8WRrQsd2Y0rLlPWOHj+Jad+OfKUwjYk7Ph
# th0NNidHs01GcBQC1PbclhaLQVeuSSv4X/NYzGBnnQMKJEi4ucUd8vlSR7Of0qhB
# djXjzWAQSkEqP92dJ/YtlVZnPKVd+fPpt9HKyAo9lN97h6qhodiaX5aZVQjnZDdk
# 4IvnztJLMf7HPhzbLDRwoYIDJjCCAyIGCSqGSIb3DQEJBjGCAxMwggMPAgEBMH0w
# aTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQD
# EzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2IFNIQTI1
# NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQMEAgEFAKBpMBgG
# CSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MTAyMTA4
# NDQ0MVowLwYJKoZIhvcNAQkEMSIEIP9sCZfonCqnNWqd/rRwV4X/CXBQcqhm7mB8
# bQRCAnhMMA0GCSqGSIb3DQEBAQUABIICAEMFzVIcYOb58iQPPTVpmT65o0VN7mw3
# Nn/EJywpJ5mAIsunzRm1e0ZlLlqHcFeGsUKg1lFhkLZykl+pfsHiMEUhbV9XDEko
# SE62CtCWdvtct/QY9ABQqQVVLcX+LhgRVwPf9+NAmUnvISzegB6Up4wQyg3IGOJv
# St31DX9kvywasnkTFpgsN8OgzNd/0rjlQJle67pIBuMd4qKV9qtQO6CcpakJhyPT
# eXrSct+ItrgJBG2hkSOZ85Do0ND6IgR1KBCzIx9GS1XNaCU2ptRG5z/eDN+o1c+K
# 2rV+Ylu/Ugrx3w0mjvkTSuRGKXJxQJuS7LJ40uJGUDWOdx3YC97COjpd2Ueij+aE
# OW35mp15fvpQN0UKNZYUrrUCihgd5hRbRTOJ1IBv8gj9b2e0PSDv2vBw10pIyPTt
# 2StQlBvCUqFOnir4S+p5oPeeW0QaStWkuwHOVINUj4XdKofr7BZJyS5wZ45se3n6
# kz3kdtnEDNpBl9CixStDFfmXwFzS1IAGOqLCWguFKsD3jBbjMawFVvH0m13h0M6g
# ZpjsTWC5bvlSh3cLtKYYCjfCiKRe1tbD/2RvXzZxDAkkVsPmk8QQBloluY6iON4n
# gbf7wFBMocnVhUDFh5xXoLbE/gcb+OZVIMsy44AHRn+FFde47CbZcgYv1D+M897j
# 0dR4L50Ilk6U
# SIG # End signature block