modules/AzStack.Insights/AzStack.Insights.Helper.psm1
|
<################################################################
# # # Copyright (C) Microsoft Corporation. All rights reserved. # # # ################################################################> # Analyzers and rules import this module standalone (Import-Module ..\AzStack.Insights.Helper.psm1), # so it cannot rely on a parent module having already brought Write-AzsSupportLog into scope. Import-Module -Name $PSScriptRoot\..\AzStack.Observability\AzStack.Observability.psm1 function Get-InsightModulePaths { <# .SYNOPSIS Returns the resolved paths for the module root and packages directory. .DESCRIPTION Derives paths from the module file's own $PSScriptRoot, which PowerShell binds at module load time and cannot be overwritten by script code. Prefer this over reading from a global variable, which can be overwritten at runtime. .OUTPUTS PSCustomObject with ModuleRoot and PackagesPath properties. #> [CmdletBinding()] [OutputType([PSCustomObject])] param() # $PSScriptRoot here is always the AzStack.Insights module directory # (modules\AzStack.Insights\), so two levels up reaches the version root. $moduleRoot = (Get-Item -Path (Join-Path -Path $PSScriptRoot -ChildPath '..\..') -ErrorAction Stop).FullName $packagesPath = Join-Path -Path $moduleRoot -ChildPath 'packages' return [PSCustomObject]@{ ModuleRoot = $moduleRoot PackagesPath = $packagesPath } } function Get-ParsedFileVersion { <# .SYNOPSIS Returns a parsed System.Version value from a file's version metadata. .DESCRIPTION Reads FileVersion first, then ProductVersion, and extracts a numeric version token (for example, 1.2.3.4). Returns $null when no version can be parsed. .PARAMETER FilePath Path to the file whose version should be parsed. .OUTPUTS System.Version or $null. #> [CmdletBinding()] [OutputType([System.Version])] param( [Parameter(Mandatory = $true)] [string]$FilePath ) $versionInfo = (Get-Item -Path $FilePath -ErrorAction Stop).VersionInfo $rawVersion = $versionInfo.FileVersion if ([string]::IsNullOrWhiteSpace($rawVersion)) { $rawVersion = $versionInfo.ProductVersion } if ([string]::IsNullOrWhiteSpace($rawVersion)) { return $null } $versionMatch = [System.Text.RegularExpressions.Regex]::Match($rawVersion, '\d+(\.\d+){1,3}') if (-not $versionMatch.Success) { return $null } try { return [System.Version]$versionMatch.Value } catch { return $null } } function Test-isFailoverCluster { [CmdletBinding()] param() # Detecting Failover Cluster based on the presence of the Get-Cluster cmdlet return ((Get-Command "Get-Cluster" -ErrorAction Ignore) -and (Get-Cluster -ErrorAction Ignore)) } function Get-NodeFqdn { <# .SYNOPSIS Resolves the local node FQDN with resilient fallback behavior. .DESCRIPTION Uses Win32_ComputerSystem identity first, then DNS host lookup, then USERDNSDOMAIN, and finally falls back to computer name. #> [CmdletBinding()] param() $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue if ($null -ne $computerSystem -and $computerSystem.PartOfDomain -and -not [string]::IsNullOrWhiteSpace($computerSystem.DNSHostName) -and -not [string]::IsNullOrWhiteSpace($computerSystem.Domain) -and $computerSystem.Domain -ne 'WORKGROUP') { return "$($computerSystem.DNSHostName).$($computerSystem.Domain)" } try { $hostName = [System.Net.Dns]::GetHostEntry($Env:ComputerName).HostName if (-not [string]::IsNullOrWhiteSpace($hostName) -and $hostName.Contains('.')) { return $hostName } } catch { } if (-not [string]::IsNullOrWhiteSpace($Env:USERDNSDOMAIN)) { return "$($Env:ComputerName).$($Env:USERDNSDOMAIN)" } return $Env:ComputerName } function Get-FirewallEndpoints { <# .SYNOPSIS Retrieves the required firewall endpoints for a specified Azure region. .DESCRIPTION This function returns a collection of firewall endpoints that need to be opened for Azure Local operations in the specified region. This comes from https://learn.microsoft.com/en-us/azure/azure-local/concepts/system-requirements-23h2?view=azloc-2601&tabs=azure-public .OUTPUTS Collection of PSObjects representing firewall endpoints. .EXAMPLE $endpoints = Get-FirewallEndpoints -Region 'eastus' Retrieves all firewall endpoints for the 'eastus' region and stores them in the $endpoints variable. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$Region ) $endpointRootDir = Get-Item -Path "$PSScriptRoot\config\firewall_endpoints" try { switch ($Region) { 'australiaeast' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "AustraliaEastEndpoints.psd1") } 'canadacentral' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "CanadaCentralEndpoints.psd1") } 'eastus' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "EastUSEndpoints.psd1") } 'centralindia' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "IndiaCentralEndpoints.psd1") } 'japaneast' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "JapanEastEndpoints.psd1") } 'southcentralus' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "SouthCentralUSEndpoints.psd1") } 'southeastasia' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "SoutheastAsiaEndpoints.psd1") } 'westeurope' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "WestEuropeEndpoints.psd1") } 'usgovvirginia' { $configData = Import-PowerShellDataFile -Path (Join-Path -Path $endpointRootDir.FullName -ChildPath "USGovVirginiaEndpoints.psd1") } default { return $null } } return $configData.Endpoints } catch { $message = "Failed to load firewall endpoints for region '$Region'. $($_.Exception.Message)" if (Get-Command -Name 'Write-AzsSupportLog' -ErrorAction SilentlyContinue) { try { Write-AzsSupportLog -Level 'Error' -Message $message } catch { } } else { Write-Verbose -Message $message } throw } } function Invoke-AzsInsightJobWithTimeout { <# .SYNOPSIS Runs a script block in a process-isolated job with a wall-clock timeout. .DESCRIPTION Uses a background process so a blocking filesystem or RPC operation cannot stall the Insights runspace. Job output is deserialized when it crosses the process boundary; callers should consume scalar properties rather than methods. .PARAMETER ScriptBlock Operation to run in the isolated process. .PARAMETER ArgumentList Arguments passed to the isolated script block. .PARAMETER TimeoutSeconds Maximum number of seconds to wait for the operation. .PARAMETER Activity Operation label used in timeout and failure messages. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [Parameter(Mandatory = $false)] [object[]]$ArgumentList = @(), [Parameter(Mandatory = $true)] [ValidateRange(1, 3600)] [int]$TimeoutSeconds, [Parameter(Mandatory = $false)] [string]$Activity = 'Insight operation' ) $job = $null $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() try { $job = Start-Job -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop $remainingSeconds = [int][Math]::Floor($TimeoutSeconds - $stopwatch.Elapsed.TotalSeconds) if ($remainingSeconds -lt 1) { throw (New-Object -TypeName System.TimeoutException -ArgumentList "$Activity exceeded the $TimeoutSeconds second timeout while starting the isolated process.") } $completedJob = Wait-Job -Job $job -Timeout $remainingSeconds -ErrorAction Stop if ($null -eq $completedJob) { throw (New-Object -TypeName System.TimeoutException -ArgumentList "$Activity exceeded the $TimeoutSeconds second timeout.") } if ($job.State -ne 'Completed') { $reason = $job.JobStateInfo.Reason if ($null -eq $reason) { $reason = $job.ChildJobs | ForEach-Object { $_.JobStateInfo.Reason } | Where-Object { $null -ne $_ } | Select-Object -First 1 } $reasonMessage = if ($null -ne $reason) { $reason.Message } else { "Job entered state '$($job.State)'." } throw "$Activity failed. $reasonMessage" } return Receive-Job -Job $job -ErrorAction Stop } finally { $stopwatch.Stop() if ($null -ne $job) { try { Remove-Job -Job $job -Force -ErrorAction Stop } catch { Write-Warning -Message "Failed to remove isolated job '$($job.Name)': $($_.Exception.Message)" } } } } function Test-AzsInsightPath { <# .SYNOPSIS Tests a path in a process-isolated job with a timeout. .PARAMETER Path Path to test. .PARAMETER TimeoutSeconds Maximum number of seconds to wait for Test-Path. .PARAMETER CallerName Label used in timeout and failure messages. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [int]$TimeoutSeconds = 15, [Parameter(Mandatory = $false)] [string]$CallerName = 'MocArb' ) $pathExists = Invoke-AzsInsightJobWithTimeout -ScriptBlock { param($PathToTest) Test-Path -LiteralPath $PathToTest -ErrorAction Stop } -ArgumentList @($Path) -TimeoutSeconds $TimeoutSeconds -Activity "[$env:COMPUTERNAME][$CallerName] Test-Path '$Path'" return [bool]$pathExists } function Get-MocConfigCached { <# .SYNOPSIS Returns MOC configuration, using the global cache when available. .DESCRIPTION Reads from $Global:MocArbCachedMocConfig when the variable exists (set by the MocArb analyzer). A cached $null is a negative cache entry that prevents each rule from repeating a failed operation. On cache miss, calls Get-MocConfig in a process-isolated job with a total retry budget. .PARAMETER CallerName Label used in diagnostic messages. .PARAMETER TimeoutSeconds Total wall-clock budget shared by all attempts and backoff delays. .PARAMETER MaximumAttempts Maximum attempts for quick transient failures. A blocking call consumes the remaining timeout budget and is not retried. .OUTPUTS The MOC configuration object, or $null if all attempts fail. #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string]$CallerName = 'MocArb', [Parameter(Mandatory = $false)] [ValidateRange(1, 600)] [int]$TimeoutSeconds = 120, [Parameter(Mandatory = $false)] [ValidateRange(1, 10)] [int]$MaximumAttempts = 5 ) $cachedConfig = Get-Variable -Name 'MocArbCachedMocConfig' -Scope Global -ErrorAction Ignore if ($null -ne $cachedConfig) { return $cachedConfig.Value } Write-Verbose -Message "[$env:COMPUTERNAME][$CallerName] Cache miss, calling Get-MocConfig in an isolated process" try { $getMocConfigCommand = Get-Command -Name 'Get-MocConfig' -ErrorAction Stop } catch { Write-AzsSupportLog -Level 'Warning' -Message "[$env:COMPUTERNAME][$CallerName] Get-MocConfig is unavailable: $($_.Exception.Message)" return $null } $modulePath = if ($null -ne $getMocConfigCommand.Module) { $getMocConfigCommand.Module.Path } else { $null } $moduleName = $getMocConfigCommand.ModuleName $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $mocConfig = $null $lastFailure = $null $attemptsCompleted = 0 for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { $remainingSeconds = [int][Math]::Floor($TimeoutSeconds - $stopwatch.Elapsed.TotalSeconds) if ($remainingSeconds -lt 1) { break } $attemptsCompleted = $attempt try { $mocConfig = Invoke-AzsInsightJobWithTimeout -ScriptBlock { param($ModulePath, $ModuleName) if (-not [string]::IsNullOrWhiteSpace($ModulePath)) { Import-Module -Name $ModulePath -ErrorAction Stop } elseif (-not [string]::IsNullOrWhiteSpace($ModuleName)) { Import-Module -Name $ModuleName -ErrorAction Stop } Get-MocConfig -ErrorAction Stop } -ArgumentList @($modulePath, $moduleName) -TimeoutSeconds $remainingSeconds -Activity "[$env:COMPUTERNAME][$CallerName] Get-MocConfig attempt $attempt" } catch [System.TimeoutException] { $lastFailure = $_.Exception.Message Write-AzsSupportLog -Level 'Warning' -Message $lastFailure break } catch { $lastFailure = $_.Exception.Message Write-AzsSupportLog -Level 'Warning' -Message "[$env:COMPUTERNAME][$CallerName] Get-MocConfig attempt $attempt failed: $lastFailure" } if ($null -ne $mocConfig) { $stopwatch.Stop() Write-Verbose -Message "[$env:COMPUTERNAME][$CallerName] Get-MocConfig succeeded on attempt $attempt after $([Math]::Round($stopwatch.Elapsed.TotalSeconds, 1)) seconds" return $mocConfig } if ([string]::IsNullOrWhiteSpace($lastFailure)) { $lastFailure = 'Get-MocConfig returned no configuration.' } if ($attempt -lt $MaximumAttempts) { $backoff = 2 * $attempt $remainingAfterAttempt = [int][Math]::Floor($TimeoutSeconds - $stopwatch.Elapsed.TotalSeconds) $sleepSeconds = [Math]::Min($backoff, $remainingAfterAttempt) if ($sleepSeconds -lt 1) { break } Write-AzsSupportLog -Level 'Warning' -Message "[$env:COMPUTERNAME][$CallerName] Get-MocConfig attempt $attempt returned no configuration, retrying in ${sleepSeconds}s" Start-Sleep -Seconds $sleepSeconds } } $stopwatch.Stop() if ([string]::IsNullOrWhiteSpace($lastFailure)) { $lastFailure = "The $TimeoutSeconds second retry budget was exhausted." } Write-AzsSupportLog -Level 'Warning' -Message "[$env:COMPUTERNAME][$CallerName] Get-MocConfig failed after $attemptsCompleted attempt(s) and $([Math]::Round($stopwatch.Elapsed.TotalSeconds, 1)) seconds. Last failure: $lastFailure" return $null } function Get-ArbControlPlaneVMs { <# .SYNOPSIS Returns local Hyper-V VM objects for the ARB control-plane VM. .DESCRIPTION Calls Get-VM -Name '*control-plan*' on the local node, then disambiguates when multiple VMs match (e.g., AKS workloads also create control-plane VMs). Uses three methods to identify the real ARB VM: 1. Cluster group: ARB VM belongs to the '<clusterName>-arcbridge' cluster group. The cluster group name reliably contains 'arcbridge' even though the VM name does not. 2. MOC kubeconfig IP: Matches the API server IP from the ARB kubeconfig to VM network adapters. Kubeconfigs under paths containing 'arcbridge' are preferred over AKS kubeconfigs. 3. ARB hex-ID name pattern: matches '<hex>-control-plan[hex/random]-<hex>' VM names. Two real-world variants are seen in the field — '<hex>-control-plane-0-<hex>' (older clusters) and '<hex>-control-plan<5-char-suffix>-<hex>' (newer clusters, e.g. LH-21). Both are covered. AKS control-plane VMs use non-hex prefixes so this still excludes them. Falls back to returning all matches if disambiguation fails. .OUTPUTS Array of Hyper-V VM objects, or empty array if none found on this node. #> [CmdletBinding()] param () $vms = @(Get-VM -Name '*control-plan*' -ErrorAction Ignore) if ($vms.Count -le 1) { return $vms } # Multiple VMs matched — AKS workloads can create additional control-plane VMs. Write-Verbose "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Found $($vms.Count) VMs matching '*control-plan*', disambiguating ARB VM..." # Method 1: Cross-reference with the ARB failover cluster group (<clusterName>-arcbridge) # The cluster group name reliably contains 'arcbridge'; the VM name itself does not. try { $clusterName = Get-AzsSupportEceManagementClusterName -ErrorAction Stop $arbGroupName = "$clusterName-arcbridge" $arbGroup = Get-ClusterGroup -Name $arbGroupName -ErrorAction Ignore if ($arbGroup) { $arbGroupResources = @(Get-ClusterResource -InputObject $arbGroup -ErrorAction Ignore | Where-Object { $_.ResourceType -eq 'Virtual Machine' }) if ($arbGroupResources.Count -gt 0) { $arbResourceNames = @($arbGroupResources | ForEach-Object { $_.Name }) # Cluster resource names for VMs are typically 'Virtual Machine <VMName>' $matched = @($vms | Where-Object { $vmName = $_.Name $arbResourceNames -contains $vmName -or $arbResourceNames -contains "Virtual Machine $vmName" }) if ($matched.Count -gt 0) { Write-Verbose "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Narrowed to $($matched.Count) VM(s) by cluster group '$arbGroupName'" return $matched } } } } catch { Write-Warning "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Cluster group cross-reference skipped: $_" } # Method 2: Match VM network adapter IPs against the ARB kubeconfig API server IP # Prefer kubeconfigs whose path contains 'arcbridge' to avoid picking up AKS kubeconfigs. $arbIp = $null # The kubeconfig IP is only a disambiguator here, so a discovery timeout must not fail the # VM lookup -- fall through to the name-pattern match in Method 3 instead. $kubeconfigs = @() try { $kubeconfigs = @(Get-ArbKubeconfigs -CallerName 'Get-ArbControlPlaneVMs') } catch [System.TimeoutException] { Write-Warning "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Kubeconfig disambiguation skipped: $($_.Exception.Message)" } if ($kubeconfigs.Count -gt 0) { # Prefer kubeconfigs under arcbridge paths $arcbridgeKubeconfigs = @($kubeconfigs | Where-Object { $_.IsArcBridge }) $orderedKubeconfigs = if ($arcbridgeKubeconfigs.Count -gt 0) { $arcbridgeKubeconfigs } else { $kubeconfigs } foreach ($kc in $orderedKubeconfigs) { if ($kc.ServerIp) { $arbIp = $kc.ServerIp break } } } if ($arbIp) { $matched = @($vms | Where-Object { $vmIps = @( $_ | Get-VMNetworkAdapter -ErrorAction Ignore | ForEach-Object { $_.IPAddresses } | Where-Object { $_ -match '^\d+\.' } ) $vmIps -contains $arbIp }) if ($matched.Count -gt 0) { Write-Verbose "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Narrowed to $($matched.Count) VM(s) by MOC kubeconfig IP ($arbIp)" return $matched } } # ------------------------------------------------------------------------------------------ # Method 3: ARB-vs-AKS disambiguation by VM name regex — READ BEFORE MODIFYING # ------------------------------------------------------------------------------------------ # The regex below matches ONLY the Arc Resource Bridge (ARB) control-plane VM. # It DELIBERATELY EXCLUDES AKS workload-cluster control-plane VMs (which can co-exist). # # Discriminator: the leading prefix before '-control-plan' must be ALL hex chars [0-9a-f]. # ARB VM prefix is a derived Azure resource identifier — all hex, typically ~45 chars: # caa5017c65b71e53681da3c40ae39f0807ad6c3f216b0-control-planzzq2l-92b1f66d (newer) # 013bc87fb2959d61230552d6d19dc6c9e7483f83cd66-control-plane-0-3e9345d2 (older) # AKS workload-cluster control-plane VM prefix contains non-hex letters (g–z), e.g. # 0002akscls001-control-plane-... ('k','s','c','l' are not hex → no match) # # If you are looking for AKS control-plane VMs you need a DIFFERENT regex/lookup — # this one will silently skip them. # ------------------------------------------------------------------------------------------ $matched = @($vms | Where-Object { $_.Name -imatch '^[0-9a-f]+-control-plan[a-z0-9-]+-[0-9a-f]+$' }) if ($matched.Count -gt 0) { Write-Verbose "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Narrowed to $($matched.Count) VM(s) by ARB hex-ID name pattern" return $matched } Write-Warning "[$env:COMPUTERNAME][Get-ArbControlPlaneVMs] Could not disambiguate $($vms.Count) control-plane VMs. Returning all matches." return $vms } function Get-ArbControlPlaneTargets { <# .SYNOPSIS Discovers ARB control-plane VM IPv4 addresses. .DESCRIPTION Uses two methods to find ARB control-plane VM IPs: 1. Local Hyper-V (Get-VM) for VMs matching '*control-plan*' on this node. 2. Parses kubeconfig files in the MOC working directory and ClusterStorage for the Kubernetes API server IP (port 6443). Method 2 uses Get-MocConfigCached to locate the MOC working directory. .PARAMETER CallerName Label used in diagnostic messages. .OUTPUTS Array of unique IPv4 address strings, or empty array if none found. .NOTES Throws System.TimeoutException when Method 2 kubeconfig discovery does not complete. Callers must let that surface as UNKNOWN rather than treating it as an empty target list, because "discovery timed out" and "no ARB targets exist" require different operator action. #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string]$CallerName = 'MocArb' ) $arbTargets = @() # Method 1: Local Get-VM for ARB control-plane VMs on this node $arbVMs = Get-ArbControlPlaneVMs if ($arbVMs) { $arbTargets = @( $arbVMs | Get-VMNetworkAdapter -ErrorAction Ignore | ForEach-Object { $_.IPAddresses } | Where-Object { $_ } | Where-Object { $_ -match '^\d+\.' } ) } # Method 2: Parse kubeconfig files for API server IP if ($arbTargets.Count -eq 0) { $kubeconfigs = @(Get-ArbKubeconfigs -CallerName $CallerName) # On clusters with AKS workloads, multiple kubeconfigs exist (ARB + AKS target clusters). # Prefer kubeconfigs whose path contains 'arcbridge' to avoid testing AKS endpoints. $arcbridgeKubeconfigs = @($kubeconfigs | Where-Object { $_.IsArcBridge }) $orderedKubeconfigs = if ($arcbridgeKubeconfigs.Count -gt 0) { $arcbridgeKubeconfigs } else { $kubeconfigs } foreach ($kc in $orderedKubeconfigs) { if ($kc.ServerIp) { $arbTargets += $kc.ServerIp } } $arbTargets = @($arbTargets | Select-Object -Unique) } return $arbTargets } function Get-ArbKubeconfigs { <# .SYNOPSIS Finds ARB kubeconfig files on the local node. .DESCRIPTION Searches the MOC working directory and ClusterStorage for kubeconfig files that contain a Kubernetes API server endpoint (port 6443). Filesystem enumeration and content reads run inside a process-isolated timeout. When the MocArb analyzer initializes its global cache envelope, success or an empty result is cached for the remainder of that analyzer run. A discovery timeout that yields no endpoints is NOT reported as an empty result: an empty array means "no ARB kubeconfig exists on this node", whereas a timeout means "discovery did not finish, so targets are unknown". Conflating the two hides the degraded-ClusterStorage failure mode this timeout exists to surface. The timeout is recorded in the cache envelope and re-thrown on subsequent calls, so later rules inherit the distinction without repeating the scan. .PARAMETER CallerName Label used in diagnostic messages. .PARAMETER TimeoutSeconds Total wall-clock budget shared by all search paths. .OUTPUTS Objects with FullName, ServerIp, and IsArcBridge properties. #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string]$CallerName = 'MocArb', [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [int]$TimeoutSeconds = 30 ) $timeoutMessage = "[$env:COMPUTERNAME][$CallerName] ARB kubeconfig discovery did not complete within the allowed time; control-plane targets could not be determined." $cacheVariable = Get-Variable -Name 'MocArbCachedKubeconfigEndpoints' -Scope Global -ErrorAction Ignore if ($null -ne $cacheVariable -and $null -ne $cacheVariable.Value -and $cacheVariable.Value.IsInitialized) { # Replay a cached timeout as a timeout rather than as an empty result, so every rule in # the analyzer run reports "targets unknown" instead of "no ARB found". if ($cacheVariable.Value.TimedOut -and @($cacheVariable.Value.Items).Count -eq 0) { throw (New-Object -TypeName System.TimeoutException -ArgumentList $timeoutMessage) } return @($cacheVariable.Value.Items) } $searchPaths = @() $mocCfg = Get-MocConfigCached -CallerName $CallerName if ($mocCfg -and $mocCfg.WorkingDir) { $searchPaths += $mocCfg.WorkingDir } $searchPaths += 'C:\ClusterStorage' $searchPaths = @($searchPaths | Where-Object { $_ } | Select-Object -Unique) $found = @() $discoveryTimedOut = $false $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() foreach ($searchPath in $searchPaths) { $remainingSeconds = [int][Math]::Floor($TimeoutSeconds - $stopwatch.Elapsed.TotalSeconds) if ($remainingSeconds -lt 1) { # The budget was consumed by an earlier search path that found nothing; the # remaining paths are unsearched, so this is a timeout and not an empty result. $discoveryTimedOut = $true break } try { $found += @(Invoke-AzsInsightJobWithTimeout -ScriptBlock { param($SearchPath) if (-not (Test-Path -LiteralPath $SearchPath -ErrorAction Stop)) { return } $enumerationErrors = @() $kubeconfigs = @(Get-ChildItem -LiteralPath $SearchPath -Filter 'kubeconfig' -Recurse -Depth 10 -ErrorAction SilentlyContinue -ErrorVariable enumerationErrors) foreach ($enumerationError in $enumerationErrors) { Write-Warning -Message "Kubeconfig enumeration under '$SearchPath' was partially unavailable: $($enumerationError.Exception.Message)" } foreach ($kubeconfig in $kubeconfigs) { try { $content = Get-Content -LiteralPath $kubeconfig.FullName -Raw -ErrorAction Stop } catch { Write-Warning -Message "Unable to read kubeconfig '$($kubeconfig.FullName)': $($_.Exception.Message)" continue } if ($content -match 'server:\s*https?://([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+):6443') { [PSCustomObject]@{ FullName = $kubeconfig.FullName ServerIp = $matches[1] IsArcBridge = $kubeconfig.FullName -like '*arcbridge*' } } } } -ArgumentList @($searchPath) -TimeoutSeconds $remainingSeconds -Activity "[$env:COMPUTERNAME][$CallerName] kubeconfig search in '$searchPath'") } catch [System.TimeoutException] { Write-AzsSupportLog -Level 'Warning' -Message $_.Exception.Message $discoveryTimedOut = $true break } catch { Write-AzsSupportLog -Level 'Warning' -Message "[$env:COMPUTERNAME][$CallerName] Kubeconfig search failed in '$searchPath': $($_.Exception.Message)" continue } if ($found.Count -gt 0) { break } } $stopwatch.Stop() $found = @($found | Where-Object { $null -ne $_ }) if ($null -ne $cacheVariable) { # Cache before throwing so the remaining rules inherit the timeout without re-scanning. $Global:MocArbCachedKubeconfigEndpoints = [PSCustomObject]@{ IsInitialized = $true Items = $found TimedOut = $discoveryTimedOut } } # A partial result is still usable, so only a timeout that produced nothing is fatal. if ($discoveryTimedOut -and $found.Count -eq 0) { throw (New-Object -TypeName System.TimeoutException -ArgumentList $timeoutMessage) } return $found } function Get-EffectiveVlanId { <# .SYNOPSIS Resolves the effective VLAN ID applied to a Hyper-V virtual network adapter. .DESCRIPTION A Hyper-V vNIC can carry its VLAN in one of two places depending on how the port is programmed: - Access mode: the VLAN is in 'AccessVlanId' (Get-VMNetworkAdapterVlan) and the isolation 'DefaultIsolationID' reads 0. - VLAN isolation mode: the VLAN is in 'DefaultIsolationID' (Get-VMNetworkAdapterIsolation) and 'AccessVlanId' reads 0. SDN / ARC-SDN managed ports (including the ARB control-plane VM NIC) run in this mode. Reading only one source produces a false mismatch when the port uses the other. This helper applies the same precedence the virtual switch applies, so an access-mode port and an isolation-mode port carrying the same VLAN resolve to the same effective value. .PARAMETER VlanInfo The object returned by Get-VMNetworkAdapterVlan for the adapter. Optional. .PARAMETER IsolationInfo The object returned by Get-VMNetworkAdapterIsolation for the adapter. Optional. .OUTPUTS PSCustomObject with EffectiveVlanId, Source, and the raw diagnostic fields (OperationMode, AccessVlanId, IsolationMode, DefaultIsolationID). EffectiveVlanId is an int when the VLAN can be resolved, or $null with Source 'Unknown' when the adapter VLAN could not be determined (no usable isolation data and AccessVlanId 0). #> [CmdletBinding()] param ( [Parameter()] [object] $VlanInfo, [Parameter()] [object] $IsolationInfo ) $operationMode = $null $accessVlanId = $null $isolationMode = $null $defaultIsolationId = $null if ($null -ne $VlanInfo) { $operationMode = $VlanInfo.OperationMode $accessVlanId = $VlanInfo.AccessVlanId } if ($null -ne $IsolationInfo) { $isolationMode = $IsolationInfo.IsolationMode $defaultIsolationId = $IsolationInfo.DefaultIsolationID } # Precedence: a port in VLAN isolation mode carries its VLAN in DefaultIsolationID # (AccessVlanId reads 0 there). Otherwise an access-mode port carries it in # AccessVlanId. If neither is set and isolation data was readable the port is # genuinely untagged (0). If isolation data could NOT be read, an AccessVlanId of 0 # is ambiguous (it cannot distinguish an untagged port from a VLAN-isolation port # whose tag lives in DefaultIsolationID), so the effective VLAN is left unresolved # (null) and the caller keeps this adapter out of the drift comparison. if (($null -ne $isolationMode) -and ("$isolationMode" -eq 'Vlan') -and ($null -ne $defaultIsolationId) -and ([int]$defaultIsolationId -gt 0)) { $effectiveVlanId = [int]$defaultIsolationId $source = 'DefaultIsolationID' } elseif (($null -ne $accessVlanId) -and ([int]$accessVlanId -gt 0)) { $effectiveVlanId = [int]$accessVlanId $source = 'AccessVlanId' } elseif ($null -ne $IsolationInfo) { $effectiveVlanId = 0 $source = 'Untagged' } else { $effectiveVlanId = $null $source = 'Unknown' } return [PSCustomObject]@{ EffectiveVlanId = $effectiveVlanId Source = $source OperationMode = $operationMode AccessVlanId = $accessVlanId IsolationMode = $isolationMode DefaultIsolationID = $defaultIsolationId } } function Get-CimReferenceKeyValue { <# .SYNOPSIS Extracts a key property value from a CIM reference-typed property. .DESCRIPTION Reference-typed properties on a CimInstance (for example the 'Parent' and 'HostResource' properties of Msvm_EthernetPortAllocationSettingData) may be surfaced either as a Microsoft.Management.Infrastructure.CimInstance whose key properties are populated, or as a WMI object-path string of the form '...InstanceID="Microsoft:GUID\\GUID"...'. This helper returns the requested key value regardless of which representation is used, so callers do not have to branch on the shape of the reference. .PARAMETER Reference The reference-typed property value (CimInstance or object-path string). .PARAMETER KeyName The name of the key property to extract (for example 'InstanceID' or 'Name'). .OUTPUTS The string value of the requested key, or $null when it cannot be resolved. #> [CmdletBinding()] [OutputType([string])] param ( [Parameter()] [object] $Reference, [Parameter(Mandatory = $true)] [string] $KeyName ) if ($null -eq $Reference) { return $null } if ($Reference -is [Microsoft.Management.Infrastructure.CimInstance]) { $property = $Reference.CimInstanceProperties[$KeyName] if ($null -ne $property) { return [string]$property.Value } return $null } # Fall back to parsing an object-path string such as ...KeyName="value"... # Anchor the key name on a word boundary so it cannot match as a substring of a # different key. For example, Msvm_VirtualEthernetSwitch is a two-key class and its # real HostResource reference is: # Msvm_VirtualEthernetSwitch.CreationClassName="Msvm_VirtualEthernetSwitch",Name="<switch-guid>" # An unanchored 'Name="([^"]*)"' would match inside CreationClassName and return the # class name instead of the GUID; the '\b' anchor forces a match on the standalone key. $match = [regex]::Match([string]$Reference, '\b' + [regex]::Escape($KeyName) + '="([^"]*)"') if ($match.Success) { # Backslashes inside a WMI object-path key value are escaped (doubled). The real # Parent reference emitted by Hyper-V is, for example, # Msvm_SyntheticEthernetPortSettingData.InstanceID="Microsoft:<guid>\\<portGuid>" # Unescape the doubled backslash so the returned value matches the single-backslash # form the referenced instance reports through its own InstanceID key property # (otherwise the correlating hashtable lookup in Get-VMNetworkAdapterFromCim misses # and every adapter is silently skipped). return $match.Groups[1].Value -replace '\\\\', '\' } return $null } function Get-VMNetworkAdapterFromCim { <# .SYNOPSIS Collects Hyper-V VM network adapters using CIM cmdlets instead of Get-VMNetworkAdapter. .DESCRIPTION Get-VMNetworkAdapter is expensive on hosts with many VMs because it performs a large number of per-adapter WMI association traversals. This helper issues a small, fixed number of bulk Get-CimInstance queries against the root\virtualization\v2 namespace and correlates the results in memory, which scales far better on dense hosts. Only adapters that are connected to a virtual switch are returned (the connection is represented by Msvm_EthernetPortAllocationSettingData). Disconnected adapters have no switch association and are therefore not relevant to switch-scoped checks. The returned objects mirror the subset of Get-VMNetworkAdapter properties that the switch insight rules consume, so those rules do not need to change. .OUTPUTS PSCustomObject collection, each with: Name, VMName, IsManagementOs, SwitchName, SwitchId, MacAddress, and MacAddressSpoofing ('On' or 'Off'). #> [CmdletBinding()] [OutputType([System.Management.Automation.PSObject[]])] param () $namespace = 'root\virtualization\v2' $cimParams = @{ Namespace = $namespace; ErrorAction = 'Stop' } try { # Virtual switches: correlate by Name (the switch GUID, which matches Get-VMSwitch.Id). $switchByGuid = @{} foreach ($vSwitch in @(Get-CimInstance @cimParams -ClassName 'Msvm_VirtualEthernetSwitch')) { if (-not [string]::IsNullOrEmpty($vSwitch.Name)) { $switchByGuid[$vSwitch.Name] = $vSwitch } } # The host ("Hosting Computer System") owns the management OS (host) vNICs; any adapter # whose owning system GUID equals the host GUID is a management OS adapter. $hostSystemGuid = $null $vmNameByGuid = @{} foreach ($system in @(Get-CimInstance @cimParams -ClassName 'Msvm_ComputerSystem')) { if (-not [string]::IsNullOrEmpty($system.Name)) { $vmNameByGuid[$system.Name] = $system.ElementName } if ($system.Caption -ieq 'Hosting Computer System') { $hostSystemGuid = $system.Name } } # Port setting data describes each adapter (name, MAC). Both synthetic and emulated NICs. $portByInstanceId = @{} foreach ($portClass in @('Msvm_SyntheticEthernetPortSettingData', 'Msvm_EmulatedEthernetPortSettingData')) { foreach ($port in @(Get-CimInstance @cimParams -ClassName $portClass)) { if (-not [string]::IsNullOrEmpty($port.InstanceID)) { $portByInstanceId[$port.InstanceID] = $port } } } # Security setting data carries AllowMacSpoofing. Its InstanceID is the owning connection's # InstanceID plus a feature suffix, so index by the connection prefix for correlation. $securityByConnectionId = @{} foreach ($security in @(Get-CimInstance @cimParams -ClassName 'Msvm_EthernetSwitchPortSecuritySettingData')) { if ([string]::IsNullOrEmpty($security.InstanceID)) { continue } # InstanceID form: '<connection InstanceID>\<featureGuid>'. Strip the last segment. $connectionId = $security.InstanceID -replace '\\[^\\]*$', '' if (-not [string]::IsNullOrEmpty($connectionId)) { $securityByConnectionId[$connectionId] = $security } } $adapters = [System.Collections.Generic.List[object]]::new() # Each connection (allocation) ties an adapter port to a virtual switch. foreach ($connection in @(Get-CimInstance @cimParams -ClassName 'Msvm_EthernetPortAllocationSettingData')) { # Resolve the switch this connection targets from its HostResource references. $vSwitch = $null foreach ($hostResource in @($connection.HostResource)) { $switchGuid = Get-CimReferenceKeyValue -Reference $hostResource -KeyName 'Name' if (-not [string]::IsNullOrEmpty($switchGuid) -and $switchByGuid.ContainsKey($switchGuid)) { $vSwitch = $switchByGuid[$switchGuid] break } } # Skip connections that are not attached to a known virtual switch. if ($null -eq $vSwitch) { continue } # Resolve the adapter port backing this connection. $portInstanceId = Get-CimReferenceKeyValue -Reference $connection.Parent -KeyName 'InstanceID' if ([string]::IsNullOrEmpty($portInstanceId) -or -not $portByInstanceId.ContainsKey($portInstanceId)) { continue } $port = $portByInstanceId[$portInstanceId] # MAC spoofing state comes from the security feature setting on this connection. $macAddressSpoofing = 'Off' if ($securityByConnectionId.ContainsKey($connection.InstanceID)) { $security = $securityByConnectionId[$connection.InstanceID] if ($security.AllowMacSpoofing) { $macAddressSpoofing = 'On' } } # The owning system GUID is the first path segment of the port InstanceID # ('Microsoft:<systemGuid>\<portGuid>'). $systemGuid = $null $instanceCore = $portInstanceId -replace '^Microsoft:', '' if ($instanceCore.Contains('\')) { $systemGuid = $instanceCore.Substring(0, $instanceCore.IndexOf('\')) } $isManagementOs = (-not [string]::IsNullOrEmpty($hostSystemGuid)) -and ($systemGuid -eq $hostSystemGuid) $vmName = if ($isManagementOs) { $null } elseif ($null -ne $systemGuid -and $vmNameByGuid.ContainsKey($systemGuid)) { $vmNameByGuid[$systemGuid] } else { $null } $adapters.Add([PSCustomObject]@{ Name = $port.ElementName VMName = $vmName IsManagementOs = $isManagementOs SwitchName = $vSwitch.ElementName SwitchId = $vSwitch.Name MacAddress = $port.Address MacAddressSpoofing = $macAddressSpoofing }) } return $adapters.ToArray() } catch { # Surface CIM failures as a non-terminating error so callers using # -ErrorAction SilentlyContinue gracefully skip (returning no adapters) # instead of the whole analyzer going UNKNOWN on the terminating error. Write-Error -ErrorRecord $_ return @() } } # SIG # Begin signature block # MIInRAYJKoZIhvcNAQcCoIInNTCCJzECAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBDnyFfIUCsUay4 # iCWDAMqjxXf4CdLl8CTp5+piu6ElSKCCDLowggX1MIID3aADAgECAhMzAAACHU0Z # yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD # VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD # b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1 # OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE # BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD # VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB # DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8 # o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg # 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4 # Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R # X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk # ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B # Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O # BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw # HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg # UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0 # JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh # MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv # Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy # dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9 # s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H # VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3 # w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n # 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs # A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo # Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb # SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6 # 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z # V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v # 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs # /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA # AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX # YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg # Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl # IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow # VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo # MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh # emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h # KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd # M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp # yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t # Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5 # REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs # 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK # Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5 # pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW # eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ # 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC # NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB # gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU # ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny # bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0 # dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx # MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI # MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4 # NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh # ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q # hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU # nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb # H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z # uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u # vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW # 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV # DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10 # 1cY2L4A7GTQG1h32HHAvfQESWP0xghngMIIZ3AIBATBuMFcxCzAJBgNVBAYTAlVT # MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv # c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w # DQYJYIZIAWUDBAIBBQCggZAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwLwYJ # KoZIhvcNAQkEMSIEIBZeplhe/pn3EOzgVE1UOnIdqJrKvj9OwjRkxLd0dGErMEIG # CisGAQQBgjcCAQwxNDAyoBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8v # d3d3Lm1pY3Jvc29mdC5jb20wDQYJKoZIhvcNAQEBBQAEggEAlmavH+MSMVuwIqdG # CTXFzxJQnvaZTqdVShlZVFEeljmjxB0/wliVPGvJUSFhpmt4JX8Ne1ZdqKbzg5sj # 4JpzGVvpRS9U1tJ3lED2u1oa/IJHtSTWmmSix2lRuhJiJwZkLg9EdepB2g9v6D+Q # LPJULAO47iG03u31Ai9NXEQKakVu8XWsB/eDZKmz54ifJVcIZsm12nycciGSn4iP # 3jtO6cvtPoCeS5OF8xggsoF89/mEcDE5HZ25kMuy13Qylvvd+nwprPGTxyApFlUq # L7bEwDhaTb7tTLuWkvSGRIe+UANHkWoLqQbsfvacpSgXfWJWK3E03zuO98bMwj78 # G7/aCKGCF7AwghesBgorBgEEAYI3AwMBMYIXnDCCF5gGCSqGSIb3DQEHAqCCF4kw # gheFAgEDMQ8wDQYJYIZIAWUDBAIBBQAwggFaBgsqhkiG9w0BCRABBKCCAUkEggFF # MIIBQQIBAQYKKwYBBAGEWQoDATAxMA0GCWCGSAFlAwQCAQUABCDp8NtTSH9eiJ6B # eIzlj3hN3ORQnSfqxt6KyZRkPpsFmQIGaolQ9UupGBMyMDI2MDkwMjE1MjYwOS41 # NDJaMASAAgH0oIHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExp # bWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo1MjFBLTA1RTAtRDk0NzEl # MCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaCCEf4wggcoMIIF # EKADAgECAhMzAAACF3H7LqWvAR3qAAEAAAIXMA0GCSqGSIb3DQEBCwUAMHwxCzAJ # BgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25k # MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jv # c29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMB4XDTI1MDgxNDE4NDgyM1oXDTI2MTEx # MzE4NDgyM1owgdMxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw # DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x # LTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJhdGlvbnMgTGltaXRlZDEn # MCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjUyMUEtMDVFMC1EOTQ3MSUwIwYDVQQD # ExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEAwM82sEw+39vYR7iGCIFDnYNhRM+BzF2AYiq5dUpZpJFP # RjCcipQ6RUbI+RAYNRApExx5ygrXbaWtuwvqsqAVSWbU/W6fecujjILkPqn9pngt # WRkfQgbYgvaXALl6PY2yOH9f72MD+6AyxQenSpAMdUzY/Qk/jtjsHdFXVBe+tshl # IkSJ3GZw8VVKqTg3GZElztwbJWNtrhBEvhf6anxMegQMJP7tO8/BJ7ITs4/AV3D2 # bv8eHk81Y+fOmQ8mQ61WLq2wItvlzIT5bzelK9LvEycf5x1lXxAwEw5a7dpS+CKT # anhtv+Q2mwebAybjf9io4k48stTaq1rtcrOiDwddqVm1S9e8h1TszXFzjLLvE9Em # jnNfIewsY+RChUaHnY4FFwwJEnEv/JS76oHT0oGdy7+J60fGOl7A1UoUyAkhpb2B # ja+SwSIiHbQ4FDyJiLlZ6drZZ84MoJ852JSxM0hBjGO6FZlPO8iuNyk680Di8Vnb # SNpIdJN+DhlepeTUMBDHqCmd0mVWRWZPm1pvgty93asNt/Ng6o4m2dnooWOdM3yK # sJaWjyHqic9gfTrZBM+PCXqeTaO1oEiaQ+h4w0nHVdV+XSvI2m1yN4iibqjm5HPa # AO3OJ+OmNLftNVmr4Z6U2T6pIcLBysoKcDUvCqycXj4C/+n1KFBpDGdDMw9gmu8C # AwEAAaOCAUkwggFFMB0GA1UdDgQWBBRQrN9jlwNOoeE5ZQqnF5x8S1bJQzAfBgNV # HSMEGDAWgBSfpxVdAF5iXYP05dJlpxtTNRnpcjBfBgNVHR8EWDBWMFSgUqBQhk5o # dHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNyb3NvZnQlMjBU # aW1lLVN0YW1wJTIwUENBJTIwMjAxMCgxKS5jcmwwbAYIKwYBBQUHAQEEYDBeMFwG # CCsGAQUFBzAChlBodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRz # L01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNydDAMBgNV # HRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMA4GA1UdDwEB/wQEAwIH # gDANBgkqhkiG9w0BAQsFAAOCAgEARmgFdhB7xIAIHEEg5I/5S+gx67aR6RiW8ZAw # tE3mz8o0dyn+pIP+lidNR1IKQQ0r+RjYgI9cZ6mbvAyvh3e2q/BV8rjHE3ud9PyY # yq32euFgdZ3vX4b5QXePWlpBAYrdziR27rHz6WwpH5dZsSypbXDBbQkWkNl6g82y # Ty3AbBbKDXBdzxZsEauaOplatK7Er4dhglKBex8JQ2dMSkSZweCNDXqd9r/9W2Vd # RZsDJKP/Xc4UyQlVsboBotKtYESXFkjwR1HVsH+Q0C69/N5CP/Tq3YgI1ub4b9+3 # MJFKWhJXCcJGFZkcLwUmYwoFg1XLo7DLJdGjrIH1jsI2NFXJFQHef6AdRe1ERvYQ # eqtyrBvxIvR+P/83FNYyzx04inUT9TF2AwTOuqCC6Z67oNwR4pEEJyAIEREvkdhj # jfWcgsk/nGTlfahvNY/SOHrNRKo49KDlccNzRCJQyQ+D59r7/qebNSyQPTfwI9++ # jEY0Q/UWKVNLhio55GYBseJ99s7NzkdxOr9Uftp597HEovbA69qGlZ3OpUE3H1RB # GDVp/FvM2uXTum8LrMkPXx5Ap/kbPASsC9ju9oMCe2IEXO2SeD1aD3IqvAOdHFKH # g1vpbPUQSWb6g2xfBV30wFcqaPYgzcbxPWPyZqK+S8l7zw64aO5hmJ7eQwoMfTu0 # Vay6r48wggdxMIIFWaADAgECAhMzAAAAFcXna54Cm0mZAAAAAAAVMA0GCSqGSIb3 # DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4G # A1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMTIw # MAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAx # MDAeFw0yMTA5MzAxODIyMjVaFw0zMDA5MzAxODMyMjVaMHwxCzAJBgNVBAYTAlVT # MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK # ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1l # LVN0YW1wIFBDQSAyMDEwMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA # 5OGmTOe0ciELeaLL1yR5vQ7VgtP97pwHB9KpbE51yMo1V/YBf2xK4OK9uT4XYDP/ # XE/HZveVU3Fa4n5KWv64NmeFRiMMtY0Tz3cywBAY6GB9alKDRLemjkZrBxTzxXb1 # hlDcwUTIcVxRMTegCjhuje3XD9gmU3w5YQJ6xKr9cmmvHaus9ja+NSZk2pg7uhp7 # M62AW36MEBydUv626GIl3GoPz130/o5Tz9bshVZN7928jaTjkY+yOSxRnOlwaQ3K # Ni1wjjHINSi947SHJMPgyY9+tVSP3PoFVZhtaDuaRr3tpK56KTesy+uDRedGbsoy # 1cCGMFxPLOJiss254o2I5JasAUq7vnGpF1tnYN74kpEeHT39IM9zfUGaRnXNxF80 # 3RKJ1v2lIH1+/NmeRd+2ci/bfV+AutuqfjbsNkz2K26oElHovwUDo9Fzpk03dJQc # NIIP8BDyt0cY7afomXw/TNuvXsLz1dhzPUNOwTM5TI4CvEJoLhDqhFFG4tG9ahha # YQFzymeiXtcodgLiMxhy16cg8ML6EgrXY28MyTZki1ugpoMhXV8wdJGUlNi5UPkL # iWHzNgY1GIRH29wb0f2y1BzFa/ZcUlFdEtsluq9QBXpsxREdcu+N+VLEhReTwDwV # 2xo3xwgVGD94q0W29R6HXtqPnhZyacaue7e3PmriLq0CAwEAAaOCAd0wggHZMBIG # CSsGAQQBgjcVAQQFAgMBAAEwIwYJKwYBBAGCNxUCBBYEFCqnUv5kxJq+gpE8RjUp # zxD/LwTuMB0GA1UdDgQWBBSfpxVdAF5iXYP05dJlpxtTNRnpcjBcBgNVHSAEVTBT # MFEGDCsGAQQBgjdMg30BATBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jv # c29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wEwYDVR0lBAwwCgYI # KwYBBQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/oolxiaNE9lJBb186a # GMQwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3Br # aS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3JsMFoGCCsG # AQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29t # L3BraS9jZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcnQwDQYJKoZIhvcN # AQELBQADggIBAJ1VffwqreEsH2cBMSRb4Z5yS/ypb+pcFLY+TkdkeLEGk5c9MTO1 # OdfCcTY/2mRsfNB1OW27DzHkwo/7bNGhlBgi7ulmZzpTTd2YurYeeNg2LpypglYA # A7AFvonoaeC6Ce5732pvvinLbtg/SHUB2RjebYIM9W0jVOR4U3UkV7ndn/OOPcbz # aN9l9qRWqveVtihVJ9AkvUCgvxm2EhIRXT0n4ECWOKz3+SmJw7wXsFSFQrP8DJ6L # GYnn8AtqgcKBGUIZUnWKNsIdw2FzLixre24/LAl4FOmRsqlb30mjdAy87JGA0j3m # Sj5mO0+7hvoyGtmW9I/2kQH2zsZ0/fZMcm8Qq3UwxTSwethQ/gpY3UA8x1RtnWN0 # SCyxTkctwRQEcb9k+SS+c23Kjgm9swFXSVRk2XPXfx5bRAGOWhmRaw2fpCjcZxko # JLo4S5pu+yFUa2pFEUep8beuyOiJXk+d0tBMdrVXVAmxaQFEfnyhYWxz/gq77EFm # PWn9y8FBSX5+k77L+DvktxW/tM4+pTFRhLy/AsGConsXHRWJjXD+57XQKBqJC482 # 2rpM+Zv/Cuk0+CQ1ZyvgDbjmjJnW4SLq8CdCPSWU5nR0W2rRnj7tfqAxM328y+l7 # vzhwRNGQ8cirOoo6CGJ/2XBjU02N7oJtpQUQwXEGahC0HVUzWLOhcGbyoYIDWTCC # AkECAQEwggEBoYHZpIHWMIHTMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu # Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv # cmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQgSXJlbGFuZCBPcGVyYXRpb25zIExp # bWl0ZWQxJzAlBgNVBAsTHm5TaGllbGQgVFNTIEVTTjo1MjFBLTA1RTAtRDk0NzEl # MCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcGBSsO # AwIaAxUAabKAFaKt2haUdqkHfFYzAzfgSMuggYMwgYCkfjB8MQswCQYDVQQGEwJV # UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UE # ChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGlt # ZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQsFAAIFAO5CTsswIhgPMjAyNjA5 # MDIwNzI5MTVaGA8yMDI2MDkwMzA3MjkxNVowdzA9BgorBgEEAYRZCgQBMS8wLTAK # AgUA7kJOywIBADAKAgEAAgIcIwIB/zAHAgEAAgITJTAKAgUA7kOgSwIBADA2Bgor # BgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIBAAID # AYagMA0GCSqGSIb3DQEBCwUAA4IBAQBLqnQ0V+8Vt+f0zPH/LnPXwpafW3K5JZ1P # 5NyzU6SRAEn49LMOWAN6QL3cqY7kWlRUb3VNtzmd+smlWIZNkLN6sRlIIrzCPFvG # 4jFjqnrScow3Xak0GHKNbK/+r7OiftLIOV4HpSa4FF4zynbawOte3/dxJ/EHSvUg # VH8cTIZ91U07Wo+GN980d7h9rBm1qYuP/bbhGD7BtRbJg/Qs92Mwz8W7m0Udl9El # DabAYuRbifRyI6cyvpxudG0DeMuFlwG13VsTkrbnthNwYaPxNthfLPPMj+tcF83w # +IKL7Cl9AmFm5HbsgbMegLxYvOCEe9d9m6ngccbPLvBaizKpeaoGMYIEDTCCBAkC # AQEwgZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNV # BAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQG # A1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTACEzMAAAIXcfsupa8B # HeoAAQAAAhcwDQYJYIZIAWUDBAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG # 9w0BCRABBDAvBgkqhkiG9w0BCQQxIgQgV35tx97bHzars5VdZhPkw/6HmNPRadn6 # Z+GP1bhqtygwgfoGCyqGSIb3DQEJEAIvMYHqMIHnMIHkMIG9BCDQ8lBgPl23yZ0S # zUSt5phOIegHPywrkNwevxe2k+RaWzCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMw # EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVN # aWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0 # YW1wIFBDQSAyMDEwAhMzAAACF3H7LqWvAR3qAAEAAAIXMCIEIKj6hTyZodonOU1g # CWasxwxGKzcwRjJ44RpBNsnK6/C8MA0GCSqGSIb3DQEBCwUABIICAIkxahDrsX7y # DIUElu2S6Cwd11XbkwHN1j46jJtJU3W2op2fkETMvgSfu+PVf+kVE28HGeE5cffv # w/6PbKA51RUswVMd3Zh6bGnIvv2S3qwMfB7EKS26Ybk2Z21dSTSU9ba7N5OJIpID # 73f8fOHoptysAmddRBRhVUZ/ujhCWDgslTrdVID8w3au+CWmOhvuFSEzV00UaSRN # G43LBnK0Ytj1yVGzEhrsCXFZxCg+lmH9jhrHZ1QoOcddCSqY0XnPmCPQ5N9bA2z/ # nze4arUm+SOHkNzNJVAc9Kk5wRbYYDjNIldGIW9baVwNXhEzMcHsgwuaCbL05Xs1 # Vg6c5Pj2UfhGGNPzY7KqoXNWGpNN4M2MJYzFpF7N2xQYRmmUUPusT8U9LDTBW1lr # ZXDRBZsW3AtpSaYxu7UWga7QN2Snii3vGm7FOiWAjuXmhpN47v1SdBn/fi1wlO2A # x4Pmi2Gj+Mqj4lHZDH51ZRXG4E7iisGm5P+ALaC1mmDy+0wVzXSfd6mDbvowRV74 # lDqDEKbV4zHpuSeQW6cQUc1vdkE/NSU344nsyUd09c72FOi6Ceiq2H7dBg4pgLjs # eOo5Df6oNTzPh33R6bpUGMaNa3p9DK6+5AnRwOeB9ywYmhqOL/HRuZL+AgeRT+EX # sM4Jru/d5sQ90DYFXyB3qX5VG8uFo4Om # SIG # End signature block |