helpers/discovery/Setup-MSCluster-Discovery.ps1
|
#requires -Version 5.1 <# .SYNOPSIS Discovers a Microsoft Windows Server Failover Cluster (WSFC/MSCS) and pushes a cluster-aware monitor plan to WhatsUp Gold. .DESCRIPTION Point this at any single node of a failover cluster. It probes root\MSCluster over WMI and returns the whole cluster: nodes with their OS build and drain status, clustered roles with virtual network names, virtual IPs, failover policy, preferred owners and anti-affinity, cluster resources with their restart and health-check policy, cluster networks with roles and metrics, cluster shared volumes including redirected and maintenance state, cluster disk capacity, installed resource types, unassigned available disks, heartbeat tuning, and quorum and witness configuration with per-node vote weights. SQL Failover Cluster Instances and Always On AG listeners are identified along the way. Anything unhealthy is called out as it is found: nodes not Up or being drained, mismatched node builds, roles not Online, resources not Online, CSVs in redirected or maintenance mode, a single or absent cluster-communication network, cluster disks below 10% free, and an even vote count with no witness configured. It then builds a monitor plan that respects how a cluster actually behaves: NODE devices (addressed by the node IP) get the node-local resources: - Cluster Service (ClusSvc) active monitor - Every cluster performance counter validated to return data on that node (cluster database, cluster API, network reconnections, resource control manager, CSV file system, Storage Spaces Direct, and so on) ROLE devices (addressed by the role's virtual IP) get the shared resource: - Role availability. A clustered role floats between nodes, so its virtual IP is the only honest place to answer "is this role up". That split is the point of the script. Putting shared-resource monitors on the physical nodes is what produces false alarms on whichever node does not currently own the role. Counter discovery is validated, not assumed: each candidate class is queried and only properties that return a numeric value are turned into monitors. Classes differ per node depending on installed roles (S2D, CSV, SQL), so every node is probed independently. Prerequisites: 1. WMI/DCOM access to at least one cluster node 2. Credentials with local administrator rights on the nodes 3. WhatsUpGoldPS module (only for -Action PushToWUG / DashboardAndPush) .PARAMETER Target One or more cluster node names or IP addresses. Any single reachable node returns the entire cluster topology; extra targets only add resilience. .PARAMETER Credential PSCredential for WMI access. When omitted, resolved from the DPAPI discovery vault. .PARAMETER CounterClass Wildcard filter applied to the discovered cluster performance counter classes. Example: '*ClusterCSV*','*ClusterDatabase*'. Used with -IncludeClusterPerformanceCounters. .PARAMETER MaxPerfMonitorsPerNode Cap on performance monitors assigned to each node device. A cluster running S2D can expose hundreds of counters; this keeps the push sane. Default: 60. Use 0 for no cap. .PARAMETER SkipRoleDevices Do not create or monitor the clustered role virtual IP devices. Node monitoring only. .PARAMETER IncludeClusterCore Also create a device for the cluster core network name and its virtual IP. By default, the cluster core is inventory-only; clustered application roles get their own devices. .PARAMETER SkipNodePerfMonitors Discover and report cluster counters but do not push them. Leaves the Cluster Service monitor on the nodes and role availability on the virtual IPs. .PARAMETER IncludeClusterPerformanceCounters Include validated cluster performance counters. By default, MSCluster creates only service monitors and clustered-role availability. Use this switch for extra cluster counter monitoring; combine it with -CounterClass to select specific classes. .PARAMETER PrimaryRole Primary device role applied to every cluster device before the rescan. Default: 'Windows Server'. .PARAMETER SubRole Sub-roles applied to every cluster device before the rescan. The 'Windows' sub-role is what supplies the built-in CPU, Disk and Memory utilization monitors, so the rescan adds them automatically. Default: 'Windows', 'Windows Server'. .PARAMETER SkipRescan Do not queue the post-push rescan. Roles are still applied. By default a device is only rescanned when it has no performance monitors yet, so reruns against an already-scanned cluster cost one read per device and go straight to adding any missing cluster monitors. .PARAMETER RescanTimeoutMinutes How long to wait for the queued rescan to finish before setting display names. Default: 20. Ignored when nothing needed a rescan. .PARAMETER DeviceGroupName WUG device group that newly created cluster devices are placed into. .PARAMETER PollingIntervalMinutes Polling interval for performance monitor assignments. Omit to use the WUG default. .PARAMETER PollingIntervalSeconds Polling interval for active monitor assignments. Omit to use the WUG default. .PARAMETER Action Skips the interactive menu. Valid values: PushToWUG, ExportJSON, ExportCSV, ShowTable, Dashboard, DashboardAndPush, None. .PARAMETER WUGServer WhatsUp Gold server address. Only needed for PushToWUG. .PARAMETER WUGCredential PSCredential for WhatsUp Gold. When omitted, Connect-WUGServer -AutoConnect is used. .PARAMETER OutputPath Directory for exports and dashboards. Default: %LOCALAPPDATA%\WhatsUpGoldPS\DiscoveryHelpers\Output. .PARAMETER NonInteractive Suppress prompts. Uses cached vault credentials. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'SQLNODE1' -Action ShowTable Discover the cluster that SQLNODE1 belongs to and print the full plan. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'SQLNODE1' -Action Dashboard Generate the cluster inventory dashboard without touching WhatsUp Gold. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'SQLNODE1' -Action PushToWUG -DeviceGroupName 'Clusters' Create node devices and role virtual IP devices in the 'Clusters' group, then attach node-local counters to the nodes and role availability to the virtual IPs. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'HVNODE1' -CounterClass '*ClusterCSV*','*ClusterDatabase*' -Action PushToWUG Push only the CSV and cluster database counters. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'SQLNODE1' -SkipNodePerfMonitors -Action PushToWUG Availability only: Cluster Service on the nodes, role health on the virtual IPs. .EXAMPLE .\Setup-MSCluster-Discovery.ps1 -Target 'SQLNODE1' -Action DashboardAndPush -NonInteractive Unattended run suitable for a scheduled task. .NOTES Author: Jason Alberino (jason@wug.ninja) Requires: WhatsUpGoldPS module (for PushToWUG), PowerShell 5.1+ Encoding: UTF-8 with BOM #> [CmdletBinding()] param( [string[]]$Target, [PSCredential]$Credential, [string[]]$CounterClass, [ValidateRange(0, 5000)] [int]$MaxPerfMonitorsPerNode = 60, [switch]$SkipRoleDevices, [switch]$IncludeClusterCore, [switch]$SkipNodePerfMonitors, [Alias('IncludeNodePerfMonitors')] [switch]$IncludeClusterPerformanceCounters, [string]$PrimaryRole = 'Windows Server', [string[]]$SubRole = @('Windows', 'Windows Server'), [switch]$SkipRescan, [ValidateRange(1, 240)] [int]$RescanTimeoutMinutes = 20, [string]$DeviceGroupName, [ValidateRange(1, 1440)] [int]$PollingIntervalMinutes, [ValidateRange(60, 86400)] [int]$PollingIntervalSeconds, [ValidateSet('PushToWUG', 'ExportJSON', 'ExportCSV', 'ShowTable', 'Dashboard', 'DashboardAndPush', 'None')] [string]$Action, [string]$WUGServer, [PSCredential]$WUGCredential, [string]$WUGWindowsUser = 'wugninja\jason', [string]$OutputPath, [switch]$NonInteractive ) # ============================================================================== # Load the discovery framework and this provider # ============================================================================== $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent $discoveryHelpersPath = Join-Path $scriptDir 'DiscoveryHelpers.ps1' if (-not (Test-Path $discoveryHelpersPath)) { Write-Error "DiscoveryHelpers.ps1 not found at '$discoveryHelpersPath'. Cannot continue." return } . $discoveryHelpersPath $providerPath = Join-Path $scriptDir 'DiscoveryProvider-MSCluster.ps1' if (-not (Test-Path $providerPath)) { Write-Error "DiscoveryProvider-MSCluster.ps1 not found at '$providerPath'. Cannot continue." return } . $providerPath $dynDashPath = Join-Path (Split-Path $scriptDir -Parent) 'reports\Export-DynamicDashboardHtml.ps1' if (Test-Path $dynDashPath) { . $dynDashPath } Write-Host "" Write-Host "=== Microsoft Failover Cluster Discovery ===" -ForegroundColor Cyan Write-Host "" # ============================================================================== # STEP 1: Targets # ============================================================================== if ($Target) { $clusterTargets = @($Target) } elseif ($NonInteractive) { Write-Error 'No -Target supplied and running non-interactively. Exiting.' return } else { Write-Host "Enter any cluster node - IP address, hostname, or FQDN." -ForegroundColor Cyan Write-Host "One node is enough; the whole cluster is discovered from it." -ForegroundColor Gray $hostInput = Read-Host -Prompt "Cluster node(s)" $clusterTargets = @($hostInput -split '\s*,\s*' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } if ($clusterTargets.Count -eq 0) { Write-Error 'No valid cluster node provided. Exiting.' return } Write-Host "Targets: $($clusterTargets -join ', ')" -ForegroundColor Cyan Write-Host "" # ============================================================================== # STEP 2: Credentials (DPAPI vault) # ============================================================================== if ($Credential) { $wmiCred = $Credential } else { $vaultName = 'Windows.WMI.Credential.1' $credSplat = @{ Name = $vaultName; CredType = 'PSCredential'; ProviderLabel = 'Failover Cluster (WMI)' } if ($NonInteractive) { $credSplat.NonInteractive = $true } elseif ($Action) { $credSplat.AutoUse = $true } $wmiCred = Resolve-DiscoveryCredential @credSplat } if (-not $wmiCred) { Write-Error 'No credentials available. Provide -Credential or configure the vault. Exiting.' return } # ============================================================================== # STEP 3: Discover # ============================================================================== Write-Host "Discovering cluster from: $($clusterTargets -join ', ')..." -ForegroundColor Cyan $credHash = @{ Username = $wmiCred.UserName Password = $wmiCred.GetNetworkCredential().Password PSCredential = $wmiCred } # One node returns the whole cluster, so a target is only scanned when it belongs # to a cluster not already discovered. Extra targets therefore act as fallbacks # for an unreachable node, while nodes of a genuinely different cluster still get # discovered in the same run. $plan = @() $seenClusters = @{} $pending = [System.Collections.Generic.List[string]]::new() foreach ($t in $clusterTargets) { $pending.Add($t) } while ($pending.Count -gt 0) { $currentTarget = $pending[0] $pending.RemoveAt(0) $discoveryOptions = @{ SkipNodePerfMonitors = [bool]($SkipNodePerfMonitors -or -not $IncludeClusterPerformanceCounters) CounterClass = $CounterClass } $result = @(Invoke-Discovery -ProviderName 'MSCluster' -Target @($currentTarget) -Credential $credHash -Options $discoveryOptions) if ($result.Count -eq 0) { continue } $foundCluster = $result[0].Attributes['MSCluster.ClusterName'] if ($foundCluster -and $seenClusters.ContainsKey($foundCluster)) { Write-Host " $currentTarget belongs to '$foundCluster', already discovered. Skipping." -ForegroundColor DarkGray continue } if ($foundCluster) { $seenClusters[$foundCluster] = $true } $plan += $result # Remaining targets that are members of this cluster no longer need scanning $members = @{} foreach ($it in $result) { $mn = $it.Attributes['MSCluster.NodeName'] $mi = $it.Attributes['MSCluster.NodeIP'] if ($mn) { $members[$mn] = $true } if ($mi) { $members[$mi] = $true } } foreach ($nn in @($result[0].Attributes['MSCluster.Nodes'] -split ',')) { if ($nn) { $members[$nn.Trim()] = $true } } for ($p = $pending.Count - 1; $p -ge 0; $p--) { $cand = $pending[$p] $short = if ($cand -match '^([^.]+)\.') { $Matches[1] } else { $cand } if ($members.ContainsKey($cand) -or $members.ContainsKey($short)) { Write-Host " $cand is a node of '$foundCluster'. Already covered." -ForegroundColor DarkGray $pending.RemoveAt($p) } } } if ($plan.Count -eq 0) { Write-Warning "Nothing discovered. Check connectivity, credentials, and that the target is a cluster node." return } if ($seenClusters.Count -gt 1) { Write-Host " Discovered $($seenClusters.Count) clusters: $(@($seenClusters.Keys) -join ', ')" -ForegroundColor Cyan } # Safety net in case two seeds resolved to the same cluster under different names $uniquePlan = [ordered]@{} foreach ($it in $plan) { if (-not $uniquePlan.Contains($it.UniqueKey)) { $uniquePlan[$it.UniqueKey] = $it } } $plan = @($uniquePlan.Values) # ============================================================================== # STEP 4: Apply counter filters, then group the plan into devices # ============================================================================== if ($SkipNodePerfMonitors -or -not $IncludeClusterPerformanceCounters) { $plan = @($plan | Where-Object { $_.ItemType -ne 'PerformanceMonitor' }) } elseif ($CounterClass) { $plan = @($plan | Where-Object { if ($_.ItemType -ne 'PerformanceMonitor') { return $true } $cls = $_.MonitorParams['WmiFormattedRelativePath'] foreach ($pat in $CounterClass) { if ($cls -like $pat) { return $true } } return $false }) } if ($SkipRoleDevices) { $plan = @($plan | Where-Object { $_.Attributes['MSCluster.DeviceType'] -ne 'Role' }) } elseif (-not $IncludeClusterCore) { $plan = @($plan | Where-Object { $_.Attributes['MSCluster.DeviceType'] -ne 'Role' -or $_.Attributes['MSCluster.RoleKind'] -ne 'ClusterCore' }) } $devicePlan = [ordered]@{} foreach ($item in $plan) { $devType = $item.Attributes['MSCluster.DeviceType'] $itemCluster = $item.Attributes['MSCluster.ClusterName'] switch ($devType) { 'Node' { $name = $item.Attributes['MSCluster.NodeName'] $key = "$itemCluster|node:$name" $ip = $item.Attributes['MSCluster.NodeIP'] $kind = 'Node' } 'Role' { $name = $item.Attributes['MSCluster.VirtualName'] # Group names such as 'Cluster Group' repeat in every cluster, so the # key has to be cluster-qualified. $key = "$itemCluster|role:$($item.Attributes['MSCluster.ResourceGroup'])" $ip = $item.Attributes['MSCluster.PrimaryIP'] $kind = $item.Attributes['MSCluster.RoleKind'] } default { continue } } if (-not $devicePlan.Contains($key)) { $devicePlan[$key] = @{ Name = $name IP = $ip IPs = @($item.Attributes['MSCluster.VirtualIP'] -split ',' | Where-Object { $_ }) Type = $devType Kind = $kind Cluster = $itemCluster Attrs = $item.Attributes Items = [System.Collections.ArrayList]@() } } [void]$devicePlan[$key].Items.Add($item) } $clusterName = @($seenClusters.Keys) -join ', ' # Cap per-node performance monitors so a big S2D cluster does not flood WUG $cappedCount = 0 if ($MaxPerfMonitorsPerNode -gt 0) { foreach ($key in @($devicePlan.Keys)) { $dev = $devicePlan[$key] if ($dev.Type -ne 'Node') { continue } $perfItems = @($dev.Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' }) if ($perfItems.Count -le $MaxPerfMonitorsPerNode) { continue } $keepPerf = @($perfItems | Select-Object -First $MaxPerfMonitorsPerNode) $activeItems = @($dev.Items | Where-Object { $_.ItemType -ne 'PerformanceMonitor' }) $cappedCount += ($perfItems.Count - $keepPerf.Count) $newItems = [System.Collections.ArrayList]@() foreach ($i in $activeItems) { [void]$newItems.Add($i) } foreach ($i in $keepPerf) { [void]$newItems.Add($i) } $dev.Items = $newItems } if ($cappedCount -gt 0) { # Rebuild the plan so exports and the push agree with the capped device plan $rebuilt = [System.Collections.ArrayList]@() foreach ($key in $devicePlan.Keys) { foreach ($i in $devicePlan[$key].Items) { [void]$rebuilt.Add($i) } } $plan = @($rebuilt) } } $nodeDevices = @($devicePlan.Values | Where-Object { $_.Type -eq 'Node' }) $roleDevices = @($devicePlan.Values | Where-Object { $_.Type -eq 'Role' }) $nodesNoIP = @($nodeDevices | Where-Object { -not $_.IP }) function Sync-MSClusterDeviceInterfaces { [CmdletBinding()] param( [Parameter(Mandatory = $true)][int]$DeviceId, [Parameter(Mandatory = $true)][string]$DeviceName, [Parameter(Mandatory = $true)][string[]]$Addresses ) $desired = @($Addresses | Where-Object { $_ -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' } | Select-Object -Unique) if ($desired.Count -eq 0) { return } $pollByName = $desired.Count -gt 1 $existingAddresses = @{} $existingInterfaces = @{} try { foreach ($iface in @(Get-WUGDeviceInterface -DeviceId $DeviceId)) { $address = if ($iface.address) { [string]$iface.address } else { [string]$iface.networkAddress } if ($address) { $existingAddresses[$address] = $true $existingInterfaces[$address] = $iface } } } catch { Write-Verbose "Could not list interfaces for '$DeviceName': $_" return } foreach ($address in $desired) { if ($existingAddresses.ContainsKey($address)) { if ($pollByName -and $existingInterfaces[$address].pollUsingName -ne $true) { $interfaceId = if ($existingInterfaces[$address].interfaceId) { [string]$existingInterfaces[$address].interfaceId } else { [string]$existingInterfaces[$address].id } if ($interfaceId) { try { Set-WUGDeviceInterface -DeviceId $DeviceId -InterfaceId $interfaceId ` -NetworkName $DeviceName -PollUsingName $true | Out-Null Write-Host " Set interface $address to poll by hostname ($DeviceName)" -ForegroundColor Green } catch { Write-Warning " Failed to set interface $address to poll by hostname: $_" } } } continue } try { Add-WUGDeviceInterface -DeviceId $DeviceId -Address $address -HostName $DeviceName ` -PollUsingName $pollByName | Out-Null Write-Host " Added interface $address$(if ($pollByName) { ' (poll by hostname)' })" -ForegroundColor Green } catch { Write-Warning " Failed to add interface $address to '$DeviceName': $_" } } } function Sync-MSClusterDevicePolling { [CmdletBinding()] param( [Parameter(Mandatory = $true)][int]$DeviceId, [Parameter(Mandatory = $true)][string]$DeviceName, [Parameter(Mandatory = $true)][bool]$PollByHostName ) try { Set-WUGDevicePollingConfig -DeviceId $DeviceId -PollByHostName $PollByHostName | Out-Null Write-Host " Device polling: $(if ($PollByHostName) { 'hostname' } else { 'address' })" -ForegroundColor Green } catch { Write-Warning " Failed to set device polling for '$DeviceName': $_" } } # ============================================================================== # STEP 5: Show what was found # ============================================================================== Write-Host "" Write-Host "Discovery complete!" -ForegroundColor Green Write-Host " Cluster: $clusterName" -ForegroundColor White Write-Host " Nodes: $($nodeDevices.Count)" -ForegroundColor White Write-Host " Roles with a virtual IP: $($roleDevices.Count)" -ForegroundColor White Write-Host " Total plan items: $($plan.Count)" -ForegroundColor White Write-Host " Active monitors: $(@($plan | Where-Object { $_.ItemType -eq 'ActiveMonitor' }).Count)" -ForegroundColor White Write-Host " Performance monitors: $(@($plan | Where-Object { $_.ItemType -eq 'PerformanceMonitor' }).Count)" -ForegroundColor White if ($cappedCount -gt 0) { Write-Host " Counters trimmed by -MaxPerfMonitorsPerNode: $cappedCount" -ForegroundColor Yellow } Write-Host "" if ($nodesNoIP.Count -gt 0) { Write-Host "Nodes without a resolvable IPv4 address (cannot be created in WUG):" -ForegroundColor Yellow foreach ($n in $nodesNoIP) { Write-Host " $($n.Name)" -ForegroundColor DarkYellow } Write-Host "" } $sampleNodeAttrs = $null if ($nodeDevices.Count -gt 0) { $sampleNodeAttrs = $nodeDevices[0].Attrs } if ($sampleNodeAttrs) { foreach ($cn in @($seenClusters.Keys)) { $cNode = @($nodeDevices | Where-Object { $_.Cluster -eq $cn }) | Select-Object -First 1 if (-not $cNode) { continue } $ca = $cNode.Attrs Write-Host "Quorum - $cn" -ForegroundColor Cyan Write-Host " Type: $($ca['MSCluster.QuorumType'])" -ForegroundColor Gray Write-Host " Witness: $(if ($ca['MSCluster.WitnessType']) { "$($ca['MSCluster.WitnessType']) ($($ca['MSCluster.WitnessState']))" } else { 'none configured' })" -ForegroundColor Gray Write-Host " Total votes: $($ca['MSCluster.QuorumVotes'])" -ForegroundColor Gray if ($ca['MSCluster.ClusterDisks']) { Write-Host " Cluster disks: $($ca['MSCluster.ClusterDisks'])" -ForegroundColor Gray } Write-Host "" } } $badRoles = @($roleDevices | Where-Object { $_.Attrs['MSCluster.UnhealthyResources'] -or ($_.Attrs['MSCluster.RoleState'] -and $_.Attrs['MSCluster.RoleState'] -ne 'Online') }) $badNodes = @($nodeDevices | Where-Object { $_.Attrs['MSCluster.NodeState'] -ne 'Up' }) if ($badNodes.Count -gt 0 -or $badRoles.Count -gt 0) { Write-Host "Needs attention:" -ForegroundColor Yellow foreach ($n in $badNodes) { Write-Host " Node $($n.Name): $($n.Attrs['MSCluster.NodeState'])" -ForegroundColor DarkYellow } foreach ($r in $badRoles) { $detail = $r.Attrs['MSCluster.UnhealthyResources'] if (-not $detail) { $detail = $r.Attrs['MSCluster.RoleState'] } Write-Host " Role $($r.Name): $detail" -ForegroundColor DarkYellow } Write-Host "" } # Every clustered role, including those with no virtual IP. A role only becomes its # own WUG device when it owns one; the rest live on whichever node currently owns them. $roleRows = @() foreach ($cn in @($seenClusters.Keys)) { $srcNode = @($nodeDevices | Where-Object { $_.Cluster -eq $cn }) | Select-Object -First 1 if (-not $srcNode) { continue } $raw = $srcNode.Attrs['MSCluster.AllRoles'] if (-not $raw) { continue } foreach ($entry in @($raw -split ';')) { if (-not $entry) { continue } $f = $entry -split '\|' if ($f.Count -lt 5) { continue } $roleRows += [PSCustomObject]@{ Cluster = $cn Role = $f[0] Kind = $f[1] State = $f[2] OwnerNode = $f[3] VirtualIP = $f[4] Monitored = if ($f[4] -eq 'no-VIP') { 'on owner node' } else { 'own device' } } } } if ($roleRows.Count -gt 0) { Write-Host "Clustered roles:" -ForegroundColor Cyan $roleRows | Format-Table Cluster, Role, Kind, State, OwnerNode, VirtualIP, Monitored -AutoSize $noVip = @($roleRows | Where-Object { $_.VirtualIP -eq 'no-VIP' }) if ($noVip.Count -gt 0) { Write-Host " $($noVip.Count) role(s) have no virtual IP (VM and storage roles typically do not) so they get no device of their own." -ForegroundColor Gray Write-Host "" } } Write-Host "Cluster device layout:" -ForegroundColor Cyan $devicePlan.Values | Sort-Object @{E = { $_.Type }}, @{E = { $_.Name }} | ForEach-Object { [PSCustomObject]@{ Cluster = $_.Cluster Device = $_.Name Type = $_.Type Kind = $_.Kind IP = if ($_.IP) { $_.IP } else { '(none)' } Scope = if ($_.Type -eq 'Role') { 'Shared' } else { 'Node-local' } Monitors = $_.Items.Count } } | Format-Table -AutoSize # Flattened rows for dashboards and exports $inventory = @() foreach ($key in $devicePlan.Keys) { $dev = $devicePlan[$key] $a = $dev.Attrs $inventory += [PSCustomObject]@{ Cluster = $dev.Cluster Device = $dev.Name Type = $dev.Type Kind = $dev.Kind IP = $dev.IP Scope = if ($dev.Type -eq 'Role') { 'Shared' } else { 'Node-local' } State = if ($dev.Type -eq 'Node') { $a['MSCluster.NodeState'] } else { $a['MSCluster.RoleState'] } OwnerNode = $a['MSCluster.OwnerNode'] ResourceGroup = $a['MSCluster.ResourceGroup'] SqlInstance = $a['MSCluster.SqlInstance'] AvailGroup = $a['MSCluster.AvailabilityGroup'] Resources = $a['MSCluster.Resources'] ResourceTypes = $a['MSCluster.ResourceTypesInUse'] ResourcePolicy = $a['MSCluster.ResourcePolicy'] Unhealthy = $a['MSCluster.UnhealthyResources'] FailoverPolicy = $a['MSCluster.FailoverPolicy'] PreferredOwner = $a['MSCluster.PreferredOwners'] AntiAffinity = $a['MSCluster.AntiAffinity'] NodeBuild = $a['MSCluster.NodeBuild'] DrainStatus = $a['MSCluster.NodeDrainStatus'] NodeVote = $a['MSCluster.NodeVote'] QuorumVotes = $a['MSCluster.QuorumVotes'] QuorumType = $a['MSCluster.QuorumType'] DynamicQuorum = $a['MSCluster.DynamicQuorum'] Witness = $a['MSCluster.WitnessType'] WitnessState = $a['MSCluster.WitnessState'] S2DEnabled = $a['MSCluster.S2DEnabled'] SharedVolumes = $a['MSCluster.SharedVolumes'] ClusterDisks = $a['MSCluster.ClusterDisks'] AvailableDisks = $a['MSCluster.AvailableDisks'] Networks = $a['MSCluster.Networks'] Heartbeat = $a['MSCluster.HeartbeatTuning'] ActiveCount = @($dev.Items | Where-Object { $_.ItemType -eq 'ActiveMonitor' }).Count PerfCount = @($dev.Items | Where-Object { $_.ItemType -eq 'PerformanceMonitor' }).Count } } # ============================================================================== # STEP 6: Action # ============================================================================== if ($OutputPath) { $OutputDir = $OutputPath } else { $OutputDir = Join-Path $env:LOCALAPPDATA 'WhatsUpGoldPS\DiscoveryHelpers\Output' } if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } $choice = $null if ($Action) { switch ($Action) { 'PushToWUG' { $choice = '1' } 'ExportJSON' { $choice = '2' } 'ExportCSV' { $choice = '3' } 'ShowTable' { $choice = '4' } 'Dashboard' { $choice = '5' } 'None' { $choice = '6' } 'DashboardAndPush' { $choice = '7' } } } if (-not $choice -and $NonInteractive) { $choice = '5' } if (-not $choice) { Write-Host "" Write-Host "What would you like to do?" -ForegroundColor Cyan Write-Host " [1] Push to WhatsUp Gold (creates node + role devices, attaches monitors)" Write-Host " [2] Export plan to JSON file" Write-Host " [3] Export inventory to CSV file" Write-Host " [4] Show full plan table" Write-Host " [5] Generate cluster HTML dashboard" Write-Host " [6] Exit (do nothing)" Write-Host " [7] Dashboard + Push to WUG" Write-Host "" $choice = Read-Host -Prompt "Choice [1-7]" } if ($choice -eq '7') { $actionsToRun = @('5', '1') } else { $actionsToRun = @($choice) } foreach ($currentChoice in $actionsToRun) { switch ($currentChoice) { '1' { # ---------------------------------------------------------------- Push Write-Host "" Write-Host "Loading WhatsUpGoldPS module..." -ForegroundColor Cyan try { $repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $repoPsd1 = Join-Path $repoRoot 'WhatsUpGoldPS.psd1' if (Test-Path $repoPsd1) { Import-Module $repoPsd1 -Force -ErrorAction Stop } else { Import-Module WhatsUpGoldPS -ErrorAction Stop } # The module loads a private copy of the discovery helpers. Reload # the current standalone helper after module import so this setup # script uses the same implementation that was just validated. . $discoveryHelpersPath } catch { Write-Error "Could not load WhatsUpGoldPS module. Is it installed? $_" return } $apiResponsePath = Join-Path $PSScriptRoot '..\..\functions\Get-WUGAPIResponse.ps1' if (Test-Path $apiResponsePath) { . $apiResponsePath } if ($WUGServer -or -not $global:WUGBearerHeaders -or -not $global:WhatsUpServerBaseURI) { try { if ($WUGCredential -and $WUGServer) { Connect-WUGServer -serverUri $WUGServer -Credential $WUGCredential -IgnoreSSLErrors } elseif ($WUGServer) { Connect-WUGServer -serverUri $WUGServer -IgnoreSSLErrors } else { Connect-WUGServer -AutoConnect -IgnoreSSLErrors } } catch { Write-Error "Failed to connect to WhatsUp Gold: $_" return } } if ($global:ignoreSSLErrors -and $PSVersionTable.PSEdition -eq 'Core') { if ($null -eq $script:PSDefaultParameterValues) { $script:PSDefaultParameterValues = @{} } $script:PSDefaultParameterValues['Invoke-RestMethod:SkipCertificateCheck'] = $true $script:PSDefaultParameterValues['Invoke-WebRequest:SkipCertificateCheck'] = $true } Write-Host "" Write-Host "Reconciling cluster devices in WUG..." -ForegroundColor Cyan $wugDeviceMap = @{} $devicesCreated = 0 $devicesFound = 0 $devicesSkipped = 0 foreach ($key in $devicePlan.Keys) { $dev = $devicePlan[$key] if (-not $dev.IP -or $dev.IP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { Write-Warning " '$($dev.Name)' has no IPv4 address. Skipping device creation." $devicesSkipped++ continue } # Pre-check before creating; the API rejects duplicates rather than merging. $existingDevice = $null try { $searchResults = @(Get-WUGDevice -SearchValue $dev.IP) if ($searchResults.Count -gt 0) { $existingDevice = $searchResults | Where-Object { $_.networkAddress -eq $dev.IP -or $_.hostName -eq $dev.Name -or $_.displayName -eq $dev.Name } | Select-Object -First 1 } if (-not $existingDevice) { $byName = @(Get-WUGDevice -SearchValue $dev.Name) $existingDevice = $byName | Where-Object { $_.displayName -eq $dev.Name -or $_.hostName -eq $dev.Name -or $_.networkAddress -eq $dev.IP } | Select-Object -First 1 } } catch { Write-Verbose "Device search for '$($dev.Name)' failed: $_" } if ($existingDevice) { $wugDeviceMap[$key] = $existingDevice.id $devicesFound++ $existingLabel = if ($existingDevice.displayName) { $existingDevice.displayName } else { $dev.Name } Write-Host " [EXISTS] $existingLabel (ID: $($existingDevice.id)) [$($dev.Type)]" -ForegroundColor Gray # A role can keep the same name while its VIP moves to a new # subnet. Reconcile the default WUG interface so reruns follow # the current VIP instead of leaving the device on an old IP. $currentAddress = [string]$existingDevice.networkAddress $defaultInterface = $null try { $defaultInterface = @(Get-WUGDeviceInterface -DeviceId $existingDevice.id -Default) | Select-Object -First 1 } catch { Write-Verbose "Could not retrieve the default interface for '$($dev.Name)': $_" } if (-not $defaultInterface -and $existingDevice.PSObject.Properties['interfaces']) { $defaultInterface = @($existingDevice.interfaces | Where-Object { $_.isDefault -eq $true }) | Select-Object -First 1 if (-not $defaultInterface) { $defaultInterface = @($existingDevice.interfaces) | Select-Object -First 1 } } if ($defaultInterface -and $defaultInterface.networkAddress) { $currentAddress = [string]$defaultInterface.networkAddress } if ($currentAddress -and $currentAddress -ne $dev.IP) { if ($defaultInterface -and $defaultInterface.interfaceId) { try { Set-WUGDeviceInterface ` -DeviceId $existingDevice.id ` -InterfaceId $defaultInterface.interfaceId ` -NetworkAddress $dev.IP ` -IsDefault $true Write-Host " Updated address: $currentAddress -> $($dev.IP)" -ForegroundColor Yellow } catch { Write-Warning " Failed to update address for '$($dev.Name)' ($currentAddress -> $($dev.IP)): $_" } } else { Write-Warning " Existing device '$($dev.Name)' has no resolvable default interface; address remains $currentAddress (discovered $($dev.IP))." } } $desiredAddresses = if (@($dev.IPs).Count -gt 0) { @($dev.IPs) } else { @($dev.IP) } Sync-MSClusterDeviceInterfaces -DeviceId ([int]$existingDevice.id) -DeviceName $dev.Name -Addresses $desiredAddresses continue } if ($dev.Type -eq 'Role') { $note = "Clustered role '$($dev.Attrs['MSCluster.ResourceGroup'])' in cluster '$($dev.Cluster)'. " + "This device owns SHARED cluster resources and follows the role across failover. " + "Node-local resources belong on the node devices." } else { $note = "Failover cluster node in cluster '$($dev.Cluster)'. " + "This device owns NODE-LOCAL resources. Shared role resources belong on the role virtual IP devices." } $splat = @{ displayName = $dev.Name DeviceAddress = $dev.IP Hostname = $dev.Name Brand = 'Microsoft' OS = 'Windows' Note = $note } if ($DeviceGroupName) { $splat['GroupName'] = $DeviceGroupName } Write-Host " [CREATE] $($dev.Name) ($($dev.IP)) [$($dev.Type)]" -ForegroundColor White try { Add-WUGDeviceTemplate @splat | Out-Null $newDevice = @(Get-WUGDevice -SearchValue $dev.IP) | Where-Object { $_.networkAddress -eq $dev.IP -or $_.displayName -eq $dev.Name } | Select-Object -First 1 if ($newDevice) { $wugDeviceMap[$key] = $newDevice.id $devicesCreated++ Write-Host " Created (ID: $($newDevice.id))" -ForegroundColor Green $desiredAddresses = if (@($dev.IPs).Count -gt 0) { @($dev.IPs) } else { @($dev.IP) } Sync-MSClusterDeviceInterfaces -DeviceId ([int]$newDevice.id) -DeviceName $dev.Name -Addresses $desiredAddresses } else { Write-Warning " Created '$($dev.Name)' but could not resolve its device ID." $devicesSkipped++ } } catch { Write-Warning " Failed to create device '$($dev.Name)' ($($dev.IP)): $_" $devicesSkipped++ } } # Bind every plan item to its resolved device foreach ($key in $devicePlan.Keys) { if (-not $wugDeviceMap.ContainsKey($key)) { continue } $wugId = $wugDeviceMap[$key] foreach ($item in $devicePlan[$key].Items) { $item.DeviceId = $wugId } } # Create or reuse the Windows credential used for WMI monitoring, then # assign it to every node and role device in this cluster plan. $wugWindowsCredentialId = $null $wugWindowsCredentialName = "MSCluster WMI - $clusterName" try { $existingWindowsCredential = @(Get-WUGCredential -SearchValue $wugWindowsCredentialName -Type windows) | Where-Object { $_.name -eq $wugWindowsCredentialName } | Select-Object -First 1 if ($existingWindowsCredential) { $wugWindowsCredentialId = if ($existingWindowsCredential.id) { $existingWindowsCredential.id } else { $existingWindowsCredential.resourceId } Write-Host "Windows credential exists: $wugWindowsCredentialName (ID: $wugWindowsCredentialId)" -ForegroundColor Gray } else { $wugCredentialResult = Add-WUGCredential ` -Name $wugWindowsCredentialName ` -Description "Windows WMI credential for cluster $clusterName" ` -Type windows ` -WindowsUser $WUGWindowsUser ` -WindowsPassword $wmiCred.GetNetworkCredential().Password ` -ErrorAction Stop $wugWindowsCredentialId = if ($wugCredentialResult.id) { $wugCredentialResult.id } else { $wugCredentialResult.resourceId } if (-not $wugWindowsCredentialId) { $wugWindowsCredentialId = @( Get-WUGCredential -SearchValue $wugWindowsCredentialName -Type windows ) | Where-Object { $_.name -eq $wugWindowsCredentialName } | Select-Object -ExpandProperty id -First 1 } if (-not $wugWindowsCredentialId) { throw "WUG did not return an ID for Windows credential '$wugWindowsCredentialName'." } Write-Host "Created Windows credential: $wugWindowsCredentialName (ID: $wugWindowsCredentialId)" -ForegroundColor Green } $credentialUpdateUri = "$($global:WhatsUpServerBaseURI)/api/v1/credentials/$wugWindowsCredentialId" $credentialUpdateBody = @{ name = $wugWindowsCredentialName description = "Windows WMI credential for cluster $clusterName" propertyBags = @( @{ name = 'CredWindows:DomainAndUserid'; value = $WUGWindowsUser } ) } | ConvertTo-Json -Depth 5 Get-WUGAPIResponse -Uri $credentialUpdateUri -Method PUT -Body $credentialUpdateBody -ErrorAction Stop | Out-Null Write-Host "Windows credential $wugWindowsCredentialId configured for $WUGWindowsUser" -ForegroundColor Gray foreach ($wugId in @($wugDeviceMap.Values | Select-Object -Unique)) { try { Set-WUGDeviceCredential -DeviceId ([string]$wugId) -CredentialId ([string]$wugWindowsCredentialId) -Assign -ErrorAction Stop | Out-Null Write-Verbose "Assigned Windows credential $wugWindowsCredentialId to device $wugId" } catch { if ($_.Exception.Message -match 'already|assigned|exists|duplicate') { Write-Verbose "Windows credential already assigned to device $wugId" } else { Write-Warning "Failed to assign Windows credential to device $wugId`: $_" } } } } catch { Write-Warning "Windows WUG credential provisioning failed: $_" } Write-Host "" Write-Host "Devices: $devicesCreated created, $devicesFound existing, $devicesSkipped skipped" -ForegroundColor Cyan # ------------------------------------------------------------------ # Scan first, then rename, then add the cluster monitors. # # A rescan is what pulls in the role-derived built-ins (CPU, Disk, # Memory, Ping, interfaces), and it rewrites the display name from # whatever the scan resolved - which for a role VIP is the node that # currently owns it. Running it before the cluster monitors are # attached means the scan can never disturb them and nothing has to # wait on a scan after the fact. # ------------------------------------------------------------------ $allDeviceIds = @($wugDeviceMap.Values | Select-Object -Unique | ForEach-Object { [int]$_ }) if ($allDeviceIds.Count -gt 0) { Write-Host "" Write-Host "Applying device roles..." -ForegroundColor Cyan $roleDeviceList = @($allDeviceIds | ForEach-Object { [string]$_ }) try { $primaryBody = @{ devices = $roleDeviceList; name = $PrimaryRole } | ConvertTo-Json -Depth 4 Set-WUGDeviceRole -BatchDeviceRole -RoleKind primary -Body $primaryBody -Confirm:$false -ErrorAction Stop | Out-Null Write-Host " Primary role '$PrimaryRole' applied to $($roleDeviceList.Count) device(s)." -ForegroundColor Green } catch { Write-Warning " Failed to apply primary role '$PrimaryRole': $_" } if ($SubRole.Count -gt 0) { try { $subRoleBody = @{ devices = $roleDeviceList operation = 'add' subroles = @($SubRole) } | ConvertTo-Json -Depth 4 Set-WUGDeviceRole -BatchDeviceRole -RoleKind sub-role -Body $subRoleBody -Confirm:$false -ErrorAction Stop | Out-Null Write-Host " Sub-roles applied: $($SubRole -join ', ')" -ForegroundColor Green } catch { Write-Warning " Failed to apply sub-roles: $_" } } # Only scan what is actually incomplete. A device that already # carries its role-derived performance monitors learned nothing # from another scan, so a rerun against a healthy cluster costs # one read per device instead of a full scan cycle. $scanDeviceIds = [System.Collections.Generic.List[int]]::new() if ($SkipRescan) { Write-Host " Rescan skipped (-SkipRescan)." -ForegroundColor DarkGray } else { foreach ($devId in $allDeviceIds) { $perfCount = 0 try { $perfUri = "$($global:WhatsUpServerBaseURI)/api/v1/devices/$devId/monitors/-?type=performance&view=basic" $perfCount = @((Get-WUGAPIResponse -Uri $perfUri -Method GET).data).Count } catch { Write-Verbose "Could not read performance monitors for device ${devId}: $_" } if ($perfCount -gt 0) { Write-Host " Device $devId already scanned ($perfCount performance monitor(s)); no rescan needed." -ForegroundColor DarkGray } else { $scanDeviceIds.Add($devId) } } } if ($scanDeviceIds.Count -gt 0) { Write-Host "" Write-Host "Queuing rescan for $($scanDeviceIds.Count) device(s)..." -ForegroundColor Cyan try { $scanIds = @(Invoke-WUGDeviceRefresh -DeviceId @($scanDeviceIds) ` -IncludeAssignedRoles 'true' ` -AddUseInRescanActiveMonitor 'true' ` -Confirm:$false -ErrorAction Stop) Write-Host " Rescan queued for: $(@($scanDeviceIds) -join ', ')" -ForegroundColor Green if ($scanIds.Count -gt 0) { Write-Host " Scan IDs: $($scanIds -join ', ')" -ForegroundColor Gray } } catch { Write-Warning " Failed to queue rescan: $_" } # A rescan renames a role VIP to whichever node currently owns # it, so names are corrected once the queue drains. Write-Host " Waiting for rescan to finish (up to $RescanTimeoutMinutes min)..." -ForegroundColor Gray $scanDeadline = (Get-Date).AddMinutes($RescanTimeoutMinutes) $scanUri = "$($global:WhatsUpServerBaseURI)/api/v1/device-scan/-/status" while ((Get-Date) -lt $scanDeadline) { Start-Sleep -Seconds 20 $active = @() try { $active = @((Get-WUGAPIResponse -Uri $scanUri -Method GET).data) } catch { } if ($active.Count -eq 0) { break } } if ((Get-Date) -ge $scanDeadline) { Write-Warning " Rescan did not finish within $RescanTimeoutMinutes minutes; display names may be overwritten later." } else { Write-Host " Rescan finished." -ForegroundColor Green } } elseif (-not $SkipRescan) { Write-Host "" Write-Host "All devices are already scanned; skipping rescan." -ForegroundColor DarkGray } Write-Host "" Write-Host "Setting display names..." -ForegroundColor Cyan foreach ($key in $wugDeviceMap.Keys) { $devId = $wugDeviceMap[$key] $devName = $devicePlan[$key].Name if (-not $devName) { continue } try { $nameSplat = @{ DeviceId = [int]$devId DisplayName = $devName } # A role VIP resolves to whichever node owns it, so stop WUG # renaming it from scan data on its own schedule. if ($devicePlan[$key].Type -eq 'Role') { $nameSplat['keepDetailsCurrent'] = $false } Set-WUGDeviceProperties @nameSplat -Confirm:$false -ErrorAction Stop | Out-Null } catch { Write-Warning " Failed to set display name on device ${devId}: $_" continue } $actualName = $null try { $actualName = (Get-WUGAPIResponse -Uri "$($global:WhatsUpServerBaseURI)/api/v1/devices/${devId}?view=card" -Method GET).data.name } catch { } if ($actualName -eq $devName) { Write-Host " $devId -> $devName" -ForegroundColor Gray } else { Write-Warning " Device $devId shows '$actualName' but should be '$devName'." } } } # The cluster monitors go on last, after the scan and the rename, so # nothing downstream can overwrite them. Write-Host "" Write-Host "Syncing cluster monitors..." -ForegroundColor Cyan $syncSplat = @{ Plan = $plan } if ($PSBoundParameters.ContainsKey('PollingIntervalSeconds')) { $syncSplat['PollingIntervalSeconds'] = $PollingIntervalSeconds } if ($PSBoundParameters.ContainsKey('PollingIntervalMinutes')) { $syncSplat['PerfPollingIntervalMinutes'] = $PollingIntervalMinutes } $result = Invoke-WUGDiscoverySync @syncSplat Write-Host "" Write-Host "=== Push Complete ===" -ForegroundColor Cyan Write-Host " Devices in WUG: $($wugDeviceMap.Count)" -ForegroundColor White Write-Host " Active monitors created: $($result.ActiveCreated)" -ForegroundColor White Write-Host " Performance monitors created: $($result.PerfCreated)" -ForegroundColor White Write-Host " Assigned to devices: $($result.Assigned)" -ForegroundColor White Write-Host " Skipped (already exist): $($result.Skipped)" -ForegroundColor White Write-Host " Attributes set: $($result.AttrsUpdated)" -ForegroundColor White if ($result.Failed -gt 0) { Write-Host " Failed: $($result.Failed)" -ForegroundColor Red } Write-Host "" Write-Host " Shared role resources are on the virtual IP devices; node-local resources are on the nodes." -ForegroundColor Gray Write-Host "" } '2' { $jsonPath = Join-Path $OutputDir "MSCluster-Discovery-$(Get-Date -Format 'yyyyMMdd-HHmmss').json" $plan | Export-DiscoveryPlan -Format JSON -Path $jsonPath -IncludeParams Write-Host "Exported to: $jsonPath" -ForegroundColor Green } '3' { $csvPath = Join-Path $OutputDir "MSCluster-Inventory-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv" $inventory | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 Write-Host "Exported to: $csvPath" -ForegroundColor Green $planCsvPath = Join-Path $OutputDir "MSCluster-Plan-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv" $plan | Export-DiscoveryPlan -Format CSV -Path $planCsvPath Write-Host "Plan exported to: $planCsvPath" -ForegroundColor Green } '4' { Write-Host "" Write-Host "--- Cluster inventory ---" -ForegroundColor Cyan $inventory | Format-Table -AutoSize Write-Host "--- Monitor plan ---" -ForegroundColor Cyan $plan | Export-DiscoveryPlan -Format Table } '5' { $dashPath = Join-Path $OutputDir 'MSCluster-Dashboard.html' if (-not (Get-Command -Name 'Export-DynamicDashboardHtml' -ErrorAction SilentlyContinue)) { Write-Warning "Dashboard generator not available. Exporting JSON instead." $jsonPath = Join-Path $OutputDir "MSCluster-Inventory-$(Get-Date -Format 'yyyyMMdd-HHmmss').json" $inventory | ConvertTo-Json -Depth 5 | Set-Content -Path $jsonPath -Encoding UTF8 Write-Host "JSON exported: $jsonPath" -ForegroundColor Green break } Export-MSClusterDashboardHtml -DashboardData $inventory ` -OutputPath $dashPath ` -ReportTitle "Failover Cluster: $clusterName" | Out-Null Write-Host "Dashboard generated: $dashPath" -ForegroundColor Green Write-Host " Nodes: $($nodeDevices.Count) | Roles: $($roleDevices.Count) | Monitors: $($plan.Count)" -ForegroundColor Gray $nmConsolePaths = @( "${env:ProgramFiles(x86)}\Ipswitch\WhatsUp\Html\NmConsole" "${env:ProgramFiles}\Ipswitch\WhatsUp\Html\NmConsole" ) $nmConsolePath = $nmConsolePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($nmConsolePath) { $wugDashDir = Join-Path $nmConsolePath 'dashboards' if (-not (Test-Path $wugDashDir)) { New-Item -ItemType Directory -Path $wugDashDir -Force | Out-Null } try { Copy-Item -Path $dashPath -Destination (Join-Path $wugDashDir 'MSCluster-Dashboard.html') -Force Write-Host "Copied to WUG: /NmConsole/dashboards/MSCluster-Dashboard.html" -ForegroundColor Green } catch { Write-Warning "Could not copy to NmConsole (run as admin?): $_" } if (Get-Command -Name 'Deploy-DashboardWebConfig' -ErrorAction SilentlyContinue) { Deploy-DashboardWebConfig -Path $wugDashDir } } } '6' { Write-Host "No action taken." -ForegroundColor Gray } default { Write-Warning "Invalid choice '$currentChoice'." } } } # end foreach actionsToRun Write-Host "" Write-Host "=== Failover Cluster Discovery Complete ===" -ForegroundColor Cyan Write-Host "" # SIG # Begin signature block # MIIr+wYJKoZIhvcNAQcCoIIr7DCCK+gCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBrbyVccAu0PCUl # t7i2EQaEJKbqGS6Njia++2mddNGkc6CCJQ0wggVvMIIEV6ADAgECAhBI/JO0YFWU # jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI # DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM # EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy # dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG # EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv # IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA # A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s # hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD # J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7 # P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme # me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz # T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q # RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz # mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc # QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T # OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/ # AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID # AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD # VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV # HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE # VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v # ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE # KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI # hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF # OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC # J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ # pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl # d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH # +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggWNMIIEdaADAgECAhAOmxiO # +dAt5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYD # VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAi # BgNVBAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAw # MDBaFw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdp # Q2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERp # Z2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsb # hA3EMB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iT # cMKyunWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGb # NOsFxl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclP # XuU15zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCr # VYJBMtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFP # ObURWBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTv # kpI6nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWM # cCxBYKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls # 5Q5SUUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBR # a2+xq4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6 # MIIBNjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8E # BAMCAYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDig # NoY0aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9v # dENBLmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCg # v0NcVec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQT # SnovLbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh # 65ZyoUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSw # uKFWjuyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAO # QGPFmCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjD # TZ9ztwGpn1eqXijiuZQwggYaMIIEAqADAgECAhBiHW0MUgGeO5B5FSCJIRwKMA0G # CSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExp # bWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBSb290 # IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5NTlaMFQxCzAJBgNVBAYT # AkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNlY3RpZ28g # UHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0GCSqGSIb3DQEBAQUAA4IB # jwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjIztNsfvxYB5UXeWUzCxEe # AEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NVDgFigOMYzB2OKhdqfWGV # oYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/36F09fy1tsB8je/RV0mIk # 8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05ZwmRmTnAO5/arnY83jeNzh # P06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm+qxp4VqpB3MV/h53yl41 # aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUedyz8rNyfQJy/aOs5b4s+ # ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz44MPZ1f9+YEQIQty/NQd/ # 2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBMdlyh2n5HirY4jKnFH/9g # Rvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQYMBaAFDLrkpr/NZZILyhA # QnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritUpimqF6TNDDAOBgNVHQ8B # Af8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNVHSUEDDAKBggrBgEFBQcD # AzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsGA1UdHwREMEIwQKA+oDyG # Omh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVTaWduaW5n # Um9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsGAQUFBzAChjpodHRwOi8v # Y3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2RlU2lnbmluZ1Jvb3RSNDYu # cDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG # 9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURhw1aVcdGRP4Wh60BAscjW # 4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0ZdOaWTsyNyBBsMLHqafvIh # rCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajjcw5+w/KeFvPYfLF/ldYp # mlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNcWbWDRF/3sBp6fWXhz7Dc # ML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalOhOfCipnx8CaLZeVme5yE # Lg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJszkyeiaerlphwoKx1uHRz # NyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z76mKnzAfZxCl/3dq3dUNw # 4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5JKdGvspbOrTfOXyXvmPL6 # E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHHj95Ejza63zdrEcxWLDX6 # xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2Bev6SivBBOHY+uqiirZt # g0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/L9Uo2bC5a4CH2RwwggY+ # MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqGSIb3DQEBDAUAMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwHhcNMjYwMjA5MDAwMDAw # WhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEUMBIGA1UECAwLQ29ubmVj # dGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcwFQYDVQQDDA5KYXNvbiBB # bGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPN6aN4B1yYW # kI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyEtRYPxEmNJL3A38Bkg7mw # zPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d67MlJLUAEufl18tOr3ST1 # DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2h4KdtRAnCqwM+/V7iBdb # w+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAADLdjZr5ip8vIWbf857Yw1 # Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZBBL56l4YAlIVRsrOiE1k # dHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDeyIq+Oztz1LvEdy8Rcd0J # BY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN79CJ90UTZb2+I+tamj0d # n+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+PhhamOxF2px9LObhBLLEMyRs # CHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi2ttn6lLOPThXMiQaooRU # q6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSMjDSRFDfHRCdGbZsL/keE # LJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJMIIBhTAfBgNVHSMEGDAW # gBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU6YF0o0D5AVhKHbVocr8G # aSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYI # KwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIBAwIwJTAjBggrBgEFBQcC # ARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQQBMEkGA1UdHwRCMEAw # PqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY0NvZGVT # aWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBEBggrBgEFBQcwAoY4aHR0 # cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGljQ29kZVNpZ25pbmdDQVIz # Ni5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqG # SIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvCwOA/RYQnFJD7R/bSQRu3 # wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03J5PrDTp1WPCWbXKX8Oz9 # vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9URrpiLPJ9rQjfHMb1BUd # vaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6sX93wphwJNBJAstH9Ozs6 # LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+zzieGEI50fKnN77FfNKFO # WKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcjGSHw4lTg1Wx+WMAMdl/7 # nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjsNrsk9ZlalBi5SHkIu+F6 # g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1nSX8OM++AXoyVGO+iTj7 # z+pl6XE9Gw/Td6WKKKswgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0G # CSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEy # NTYgMjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHT # CphBcr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPh # of6pvF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mA # xAHeHYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBv # MgEdgkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps # 0wjUjsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF # 83bRVFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXi # UOeSLsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOM # CZIVNSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydP # pOjL6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrU # G2ZdSoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+ # sdFUeEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwDQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WI # GjB/T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+ # IQhQE7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8M # yb9rEVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2 # th9y1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjaj # V/gxdEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2 # Lr3ty9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFze # GxcytL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG # 7uEBYTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+N # Jpud/v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckT # etiSuEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszW # kPZPubdcMIIG7TCCBNWgAwIBAgIQCE/cM09+RU7bww+P+ZIYNTANBgkqhkiG9w0B # AQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/ # BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg # U0hBMjU2IDIwMjUgQ0ExMB4XDTI2MDgwNTAwMDAwMFoXDTM3MTEwNDIzNTk1OVow # YzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQD # EzJEaWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIw # MjYgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALZ7pvLJ/s1K+NSb # TGWz/TjGMPh8CQ6RucZCLv5anHzWJjF/NWJrFIhy24fcpKXlgRiky4WAawDfU3YP # 0BMxt9l3Dm5oCG5Z69AqEN1kgHg2epx+l+lZBcmJCcN0ASURML5uFIS80sZsDwO3 # BSkUxDjLJhBI+qiZP3aixAC/qEGLjsBNlLol9VZ7pfGEXiMlneJIC5/YKuizVzNF # KZZEeoy/0B8Zm+nzKBgSWG52lCO1w+nCg6XpCtklTJXeIg283hw7TmmsZXR+SMbj # brEOvZ3fP2VxIgeR28Y90ZStd3F9VuA5RVynb/whITPAo9b75Zr4Ta6Mj3URm26Q # ZYMn/FnbuTegcoRcFEZ9FOqM5T6MTdtr/n74lIT/ug0eeOzmZ6QTFg33otX+bFRs # IolvykE1jive4PuESaT8zzVeFWDAMDtozNgLctkGD1ZjkEyZtJrLl5ya0m5doH/S # cpaZCZVl6pNUOCybMc/kxC6EAmSJY24L0yYKD1Nkddsnb/ItVKi/2nXpQNMu1PT5 # prW83vV8d67WowuUs0HdY4H8AMLGvdL/WHEj3ZnqMqAQQP9u3Ai9t+5eQ02GDwy0 # ODjdzi0xlp70W+ow63/0++YDEX1M0iwgUHwbrJvfpklkZQvw3+kv3vUPItdwrocz # k9icflf55W1zOEKAcJVAIXpcMCU9AgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAA # MB0GA1UdDgQWBBQUyWOKMC7USvtulPPm40B+9ezN4jAfBgNVHSMEGDAWgBTvb1NK # 6eQGfHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYI # KwYBBQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8v # b2NzcC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZT # SEEyNTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5k # aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2 # U0hBMjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9 # bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQCNxTphHp1SCt+ZrAmAfn0oQLFr0mLywSLa # DXQIENoyKqxrFbJblzCVP/pkXmwXOdrOpWygLzlT12os5ipDCy35RBCg2UMeApEt # rfGhz45F4Wt4WGdNdIbRWt3YTYJmpR+b7lr4d7Uwn+H600u4D7RnOGf8Wj4UNgAd # ZkfHhHv1mx9EVh71SJelcEN/oORSjXzdjfw1iZH9d8Nh/thn6hH23d+VsPAr6GAY # yzSA02nXD1nYLI7Ijmiv+xLCiYC41DSFYL3GhTiy0PxpawPtGRyaBVGzq+UiTfM8 # pD7KVyF5aQyWP4KhVGUUTnmm/RlYJoW3TiXA/+t0YcT2oRVBm3JETjajHug2AL+v # 5jhtKVnd3D0rbHXEu27o+Q8p4sEWPMqKDB+qbceb6T/6WcwTwXmQ9lOCLLYcsQeS # WmvKqzpAec9etE14jOQAzLKWdE3w/TCaKtLRaRT7LCkRYVnhA2D73FLje1O5b3HR # 5eHs0NzU/+xX7NbEdcofy0W3Wdwd1XOqtlpg/JgwtKfZM5dqO94lbUveOiJBI+xZ # EbGRsMNbXmMREUTgu+Oca7Y73MPWcslIx2VhkSKSXjDbD6rgg39H5Mh7QfieAIjW # agkJNt68Yfim6cjEzVSiLSeZfdkr5dtFPTW6jATlWJdYeeDRGCyatf8R1hSjzSvd # N8yWQPT9gzGCBkQwggZAAgEBMGgwVDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1Nl # Y3RpZ28gTGltaXRlZDErMCkGA1UEAxMiU2VjdGlnbyBQdWJsaWMgQ29kZSBTaWdu # aW5nIENBIFIzNgIQB5zg5NEUf4XNOXPPdi036zANBglghkgBZQMEAgEFAKCBhDAY # BgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3 # AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEi # BCDyQazw5T8mRIDlS4nm2bSkQmrkhy/Okm02BYXSgLd7nzANBgkqhkiG9w0BAQEF # AASCAgBC5MmKe/j+cz/88ctCjsqJjtG/4nBNS56J0bM0txsKC4nr1t+U4+w5u1pc # 7WxwKOexmeyNVDuLrvjIBhmKrPxgjl383ZZFL4wjSibQw0vyqYmlIkdx9LU98s8D # CIqD92/VAkMvB1cnfXWosO+BykJrre4jvQqKhJxcwnjeNitGLUDCZjWmrCFAyc7P # 9dglaxX+5dMqmyWL2jylyFVAGTZu5Mvfndn6yG5HpAP1TEXVB7p7eKcrjLXVBtYS # 2J5yI7L6o6SLzyOPR+Wb8BKSYYsELz4v0KCYua4LImEYY7ZH2q49BHmbup4RdWnd # AGLNTsSBZWn37NEPiM5wRdWXA62ye70GPGznHZuqi2am3JssbKTvW4+vkky+9ghZ # 72gf7+McZ+6tgw+ja/7L+hLLuBd/LdUD/drAxC4T3CrUuF2AdknOEIs8x3Of4Z04 # cRswp7A75ZGhQ1gIncKkgtuPFnL3XNE86DpIPlr1UfOB/TQPevoIXYyJZFRoLXzA # uUoVLQFQnecc98mSzXEJGWo565K8UDndQxEldQcXLN55OlHIDi6WFlztw//i66bb # mK61eaOn1IKj1ldm6BG2wZA9SaZYbeYwpATPp9nFJq1UDseUtSXa8tbuhMHXCINp # HOfDDXQL9/V/ocPkoyVhKloSLijh/3VOHAdRCWwZqVvNjj7nPKGCAyYwggMiBgkq # hkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E # aWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1l # U3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAhP3DNPfkVO28MPj/mS # GDUwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNjA5MjAxODA3MzRaMC8GCSqGSIb3DQEJBDEiBCBiYqq7 # 4/Aqa12Gas2x3PWlL5jFZpO2zWMVp/pQXZZzAjANBgkqhkiG9w0BAQEFAASCAgBR # TiBG1PSjB4W2Hvh87jY5OOo/iVFencwkO85MK5PoaAngHekQnFjebICN7SssAvSn # Ur/SigLmhH2MGlbOfArBPsYi9owoDYny+56CLUldQ1Dt7xxuxYQu6sgSfbCV2P5+ # Nc79urFwNt2qzOhrn3Cdzi1/Hc70JB+4oOH2PvJZ8PzcGqiv31pjLmxfXyEBWzvR # YWFjYHOf5puCWJCGUfm2UDYtNDEXPuiZfhdtHCQfhw6nxNIwGhFpuBpPCFH3TIG9 # UiVFCHTER5/J6/gYAs/ojvx+vCADPw0p3shqWnSRrgHPcTh8f279b5fz0OsjPQ32 # nqXLa9s/SmU6VguWLicmoYlWSb0/gxU+iz8fNgB1eRSsIMv3yxDUMylF5Iktc2FD # S0nQQlCmDeToXCBf7RroUefKSY+/JWgOi2JiGumrfI9/QeuJ69TeRO/aYXII4PYp # ALfgM9ofG99t03teGuwymGP4EAdksumVMxEja8GSsZ0Xa0RF+NVgmmhSRAu87yfd # sRxf3SyEzh74eoEavAJpBaa+1noUxZLcbVcWG1KlJwU1sSPC+8+vHtqVNbNgWBb6 # n5HhFJviw5/6WebIO2YFBWpFs5tqKpJdZT+3OCD1eE2ENlF9U3VnOzejv/1aBhB1 # DFOmvtEKdOMlKuj/XKi7uRYb73jurXeSDG0X8Z3y3Q== # SIG # End signature block |