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 |