helpers/oci/OCIHelpers.ps1
|
# ============================================================================= # OCI (Oracle Cloud Infrastructure) Helpers for WhatsUpGoldPS # Requires the OCI PowerShell modules. Install them: # Install-Module -Name OCI.PSModules -Scope CurrentUser -Force # Or install individual modules: # Install-Module -Name OCI.PSModules.Identity, OCI.PSModules.Compute, # OCI.PSModules.Core, OCI.PSModules.Database, # OCI.PSModules.Loadbalancer -Scope CurrentUser -Force # For monitoring metrics, install the OCI CLI: # https://docs.oracle.com/en-us/iaas/Content/API/SDKDocs/cliinstall.htm # Configure authentication (API key config file): # https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdkconfig.htm # ============================================================================= function Connect-OCIProfile { <# .SYNOPSIS Validates OCI configuration and tests connectivity. .DESCRIPTION Checks that the OCI config file exists and validates connectivity by listing available OCI regions. Authentication is handled by the config file - this function only verifies it works. .PARAMETER ConfigFile Path to the OCI config file. Defaults to ~/.oci/config. .PARAMETER Profile The profile name in the config file. Defaults to DEFAULT. .EXAMPLE Connect-OCIProfile Validates the default OCI config file (~/.oci/config) with the DEFAULT profile. .EXAMPLE Connect-OCIProfile -ConfigFile "C:\oci\config" -Profile "production" Validates connectivity using a custom config file and the "production" profile. #> param( [string]$ConfigFile, [string]$Profile ) if (-not $ConfigFile) { $ConfigFile = Join-Path $env:USERPROFILE ".oci\config" } if (-not (Test-Path $ConfigFile)) { throw "OCI config file not found: $ConfigFile. See: https://docs.oracle.com/en-us/iaas/Content/API/Concepts/sdkconfig.htm" } $splat = @{ ErrorAction = "Stop" } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } try { $regions = Get-OCIIdentityRegionsList @splat $regionNames = ($regions.Items | ForEach-Object { $_.Name }) -join ", " Write-Verbose "Connected to OCI. Available regions: $regionNames" } catch { throw "Failed to connect to OCI: $($_.Exception.Message)" } } function Get-OCICompartments { <# .SYNOPSIS Returns all active compartments in a tenancy. .DESCRIPTION Lists child compartments under the specified tenancy OCID. Handles pagination automatically. .PARAMETER TenancyId The OCID of the tenancy (root compartment). .PARAMETER ConfigFile Path to the OCI config file. .PARAMETER Profile The profile name in the config file. .EXAMPLE Get-OCICompartments -TenancyId "ocid1.tenancy.oc1..aaaaaaaexample" Returns all active compartments in the tenancy using default config. .EXAMPLE Get-OCICompartments -TenancyId $tenancyId -ConfigFile "C:\oci\config" -Profile "dev" Returns compartments using a custom config file and profile. #> param( [Parameter(Mandatory)][string]$TenancyId, [string]$ConfigFile, [string]$Profile ) $splat = @{ CompartmentId = $TenancyId; ErrorAction = "Stop" } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } $allItems = @() $response = Get-OCIIdentityCompartmentsList @splat $allItems += $response.Items while ($response.OpcNextPage) { $splat["Page"] = $response.OpcNextPage $response = Get-OCIIdentityCompartmentsList @splat $allItems += $response.Items } foreach ($c in $allItems) { if ("$($c.LifecycleState)" -ne "ACTIVE") { continue } [PSCustomObject]@{ CompartmentId = "$($c.Id)" Name = "$($c.Name)" Description = if ($c.Description) { "$($c.Description)" } else { "" } LifecycleState = "$($c.LifecycleState)" TimeCreated = if ($c.TimeCreated) { "$($c.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" } else { "N/A" } } } } function Get-OCIComputeInstances { <# .SYNOPSIS Returns all compute instances in a compartment with IP addresses. .DESCRIPTION Lists compute instances and resolves their primary VNIC IP addresses via the VNIC attachment and VNIC detail APIs. .PARAMETER CompartmentId The OCID of the compartment. .PARAMETER Region Override the OCI region. .PARAMETER ConfigFile Path to the OCI config file. .PARAMETER Profile The profile name in the config file. .EXAMPLE Get-OCIComputeInstances -CompartmentId "ocid1.compartment.oc1..aaaaaaaexample" Returns all compute instances in the specified compartment. .EXAMPLE Get-OCIComputeInstances -CompartmentId $compartmentId -Region "us-ashburn-1" Returns compute instances in a specific region. .EXAMPLE Get-OCIComputeInstances -CompartmentId $compartmentId | Where-Object { $_.LifecycleState -eq "RUNNING" } Returns only running compute instances. #> param( [Parameter(Mandatory)][string]$CompartmentId, [string]$Region, [string]$ConfigFile, [string]$Profile ) $splat = @{ CompartmentId = $CompartmentId; ErrorAction = "Stop" } if ($Region) { $splat["Region"] = $Region } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } $allItems = @() $response = Get-OCIComputeInstancesList @splat $allItems += $response.Items while ($response.OpcNextPage) { $splat["Page"] = $response.OpcNextPage $response = Get-OCIComputeInstancesList @splat $allItems += $response.Items } foreach ($inst in $allItems) { $publicIP = "N/A" $privateIP = "N/A" $vnicName = "N/A" # Resolve IPs via VNIC attachments try { $vnicSplat = @{ CompartmentId = $CompartmentId InstanceId = $inst.Id ErrorAction = "Stop" } if ($Region) { $vnicSplat["Region"] = $Region } if ($ConfigFile) { $vnicSplat["ConfigFile"] = $ConfigFile } if ($Profile) { $vnicSplat["Profile"] = $Profile } $vnicAttachments = Get-OCIComputeVnicAttachmentsList @vnicSplat foreach ($att in $vnicAttachments.Items) { if ("$($att.LifecycleState)" -ne "ATTACHED") { continue } $vnicGetSplat = @{ VnicId = $att.VnicId; ErrorAction = "Stop" } if ($Region) { $vnicGetSplat["Region"] = $Region } if ($ConfigFile) { $vnicGetSplat["ConfigFile"] = $ConfigFile } if ($Profile) { $vnicGetSplat["Profile"] = $Profile } $vnic = Get-OCIVirtualNetworkVnic @vnicGetSplat if ($vnic.PublicIp) { $publicIP = "$($vnic.PublicIp)" } if ($vnic.PrivateIp) { $privateIP = "$($vnic.PrivateIp)" } $vnicName = if ($vnic.DisplayName) { "$($vnic.DisplayName)" } else { "N/A" } if ($vnic.IsPrimary) { break } } } catch { Write-Verbose "Could not resolve VNIC for $($inst.DisplayName): $($_.Exception.Message)" } # Freeform tags $tags = if ($inst.FreeformTags -and $inst.FreeformTags.Count -gt 0) { ($inst.FreeformTags.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join "; " } else { "" } [PSCustomObject]@{ Name = if ($inst.DisplayName) { "$($inst.DisplayName)" } else { "$($inst.Id)" } InstanceId = "$($inst.Id)" Shape = "$($inst.Shape)" LifecycleState = "$($inst.LifecycleState)" AvailabilityDomain = if ($inst.AvailabilityDomain) { "$($inst.AvailabilityDomain)" } else { "N/A" } FaultDomain = if ($inst.FaultDomain) { "$($inst.FaultDomain)" } else { "N/A" } Region = if ($Region) { $Region } else { "default" } CompartmentId = "$($inst.CompartmentId)" PublicIP = $publicIP PrivateIP = $privateIP VnicName = $vnicName ImageId = if ($inst.ImageId) { "$($inst.ImageId)" } else { "N/A" } TimeCreated = if ($inst.TimeCreated) { "$($inst.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" } else { "N/A" } Tags = $tags } } } function Get-OCIDBSystems { <# .SYNOPSIS Returns all Oracle DB System instances in a compartment. .DESCRIPTION Lists DB Systems and attempts DNS resolution for node IP addresses. .PARAMETER CompartmentId The OCID of the compartment. .PARAMETER Region Override the OCI region. .PARAMETER ConfigFile Path to the OCI config file. .PARAMETER Profile The profile name in the config file. .EXAMPLE Get-OCIDBSystems -CompartmentId "ocid1.compartment.oc1..aaaaaaaexample" Returns all Oracle DB System instances in the compartment. .EXAMPLE Get-OCIDBSystems -CompartmentId $compartmentId -Region "us-phoenix-1" Returns DB Systems in a specific region. #> param( [Parameter(Mandatory)][string]$CompartmentId, [string]$Region, [string]$ConfigFile, [string]$Profile ) $splat = @{ CompartmentId = $CompartmentId; ErrorAction = "Stop" } if ($Region) { $splat["Region"] = $Region } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } $allItems = @() $response = Get-OCIDatabaseDbSystemsList @splat $allItems += $response.Items while ($response.OpcNextPage) { $splat["Page"] = $response.OpcNextPage $response = Get-OCIDatabaseDbSystemsList @splat $allItems += $response.Items } foreach ($db in $allItems) { # Attempt DNS resolution for node IP $nodeIPs = "N/A" if ($db.Hostname -and $db.Domain) { $fqdn = "$($db.Hostname).$($db.Domain)" try { $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) if ($resolved) { $nodeIPs = ($resolved | ForEach-Object { $_.IPAddressToString }) -join ", " } } catch { } } # Scan IPs $scanIPs = if ($db.ScanIpIds -and $db.ScanIpIds.Count -gt 0) { ($db.ScanIpIds) -join ", " } else { "N/A" } [PSCustomObject]@{ Name = if ($db.DisplayName) { "$($db.DisplayName)" } else { "$($db.Id)" } DBSystemId = "$($db.Id)" Shape = if ($db.Shape) { "$($db.Shape)" } else { "N/A" } LifecycleState = "$($db.LifecycleState)" AvailabilityDomain = if ($db.AvailabilityDomain) { "$($db.AvailabilityDomain)" } else { "N/A" } Region = if ($Region) { $Region } else { "default" } CompartmentId = "$($db.CompartmentId)" Hostname = if ($db.Hostname) { "$($db.Hostname)" } else { "N/A" } Domain = if ($db.Domain) { "$($db.Domain)" } else { "N/A" } NodeIPs = $nodeIPs ScanIPs = $scanIPs CpuCoreCount = if ($db.CpuCoreCount) { "$($db.CpuCoreCount)" } else { "N/A" } DataStorageSizeInGBs = if ($db.DataStorageSizeInGBs) { "$($db.DataStorageSizeInGBs)" } else { "N/A" } NodeCount = if ($db.NodeCount) { "$($db.NodeCount)" } else { "N/A" } DatabaseEdition = if ($db.DatabaseEdition) { "$($db.DatabaseEdition)" } else { "N/A" } DiskRedundancy = if ($db.DiskRedundancy) { "$($db.DiskRedundancy)" } else { "N/A" } LicenseModel = if ($db.LicenseModel) { "$($db.LicenseModel)" } else { "N/A" } Version = if ($db.Version) { "$($db.Version)" } else { "N/A" } TimeCreated = if ($db.TimeCreated) { "$($db.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" } else { "N/A" } } } } function Get-OCIAutonomousDatabases { <# .SYNOPSIS Returns all Autonomous Database instances in a compartment. .PARAMETER CompartmentId The OCID of the compartment. .PARAMETER Region Override the OCI region. .PARAMETER ConfigFile Path to the OCI config file. .PARAMETER Profile The profile name in the config file. .EXAMPLE Get-OCIAutonomousDatabases -CompartmentId "ocid1.compartment.oc1..aaaaaaaexample" Returns all Autonomous Database instances in the compartment. .EXAMPLE Get-OCIAutonomousDatabases -CompartmentId $compartmentId | Where-Object { $_.DbWorkload -eq "OLTP" } Returns only OLTP Autonomous Database instances. #> param( [Parameter(Mandatory)][string]$CompartmentId, [string]$Region, [string]$ConfigFile, [string]$Profile ) $splat = @{ CompartmentId = $CompartmentId; ErrorAction = "Stop" } if ($Region) { $splat["Region"] = $Region } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } $allItems = @() $response = Get-OCIDatabaseAutonomousDatabasesList @splat $allItems += $response.Items while ($response.OpcNextPage) { $splat["Page"] = $response.OpcNextPage $response = Get-OCIDatabaseAutonomousDatabasesList @splat $allItems += $response.Items } foreach ($adb in $allItems) { $privateEndpointIp = if ($adb.PrivateEndpointIp) { "$($adb.PrivateEndpointIp)" } else { "N/A" } [PSCustomObject]@{ Name = if ($adb.DisplayName) { "$($adb.DisplayName)" } else { "$($adb.Id)" } AutonomousDbId = "$($adb.Id)" DbWorkload = if ($adb.DbWorkload) { "$($adb.DbWorkload)" } else { "N/A" } LifecycleState = "$($adb.LifecycleState)" Region = if ($Region) { $Region } else { "default" } CompartmentId = "$($adb.CompartmentId)" CpuCoreCount = if ($adb.CpuCoreCount) { "$($adb.CpuCoreCount)" } else { "N/A" } DataStorageSizeInTBs = if ($adb.DataStorageSizeInTBs) { "$($adb.DataStorageSizeInTBs)" } else { "N/A" } PrivateEndpointIp = $privateEndpointIp IsFreeTier = if ($null -ne $adb.IsFreeTier) { "$($adb.IsFreeTier)" } else { "N/A" } LicenseModel = if ($adb.LicenseModel) { "$($adb.LicenseModel)" } else { "N/A" } DbVersion = if ($adb.DbVersion) { "$($adb.DbVersion)" } else { "N/A" } IsAutoScalingEnabled = if ($null -ne $adb.IsAutoScalingEnabled) { "$($adb.IsAutoScalingEnabled)" } else { "N/A" } IsDedicated = if ($null -ne $adb.IsDedicated) { "$($adb.IsDedicated)" } else { "N/A" } TimeCreated = if ($adb.TimeCreated) { "$($adb.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" } else { "N/A" } } } } function Get-OCILoadBalancers { <# .SYNOPSIS Returns all load balancers in a compartment. .PARAMETER CompartmentId The OCID of the compartment. .PARAMETER Region Override the OCI region. .PARAMETER ConfigFile Path to the OCI config file. .PARAMETER Profile The profile name in the config file. .EXAMPLE Get-OCILoadBalancers -CompartmentId "ocid1.compartment.oc1..aaaaaaaexample" Returns all load balancers in the compartment. .EXAMPLE Get-OCILoadBalancers -CompartmentId $compartmentId -Region "us-ashburn-1" | Where-Object { $_.LifecycleState -eq "ACTIVE" } Returns only active load balancers in us-ashburn-1. #> param( [Parameter(Mandatory)][string]$CompartmentId, [string]$Region, [string]$ConfigFile, [string]$Profile ) $splat = @{ CompartmentId = $CompartmentId; ErrorAction = "Stop" } if ($Region) { $splat["Region"] = $Region } if ($ConfigFile) { $splat["ConfigFile"] = $ConfigFile } if ($Profile) { $splat["Profile"] = $Profile } $allItems = @() $response = Get-OCILoadbalancerLoadBalancersList @splat $allItems += $response.Items while ($response.OpcNextPage) { $splat["Page"] = $response.OpcNextPage $response = Get-OCILoadbalancerLoadBalancersList @splat $allItems += $response.Items } foreach ($lb in $allItems) { # IP addresses $ipAddresses = "N/A" $primaryIP = "N/A" if ($lb.IpAddresses -and $lb.IpAddresses.Count -gt 0) { $ipAddresses = ($lb.IpAddresses | ForEach-Object { "$($_.IpAddress)" }) -join ", " $primaryIP = "$($lb.IpAddresses[0].IpAddress)" } [PSCustomObject]@{ Name = if ($lb.DisplayName) { "$($lb.DisplayName)" } else { "$($lb.Id)" } LoadBalancerId = "$($lb.Id)" ShapeName = if ($lb.ShapeName) { "$($lb.ShapeName)" } else { "N/A" } LifecycleState = "$($lb.LifecycleState)" Region = if ($Region) { $Region } else { "default" } CompartmentId = "$($lb.CompartmentId)" PrimaryIP = $primaryIP AllIPs = $ipAddresses IsPrivate = if ($null -ne $lb.IsPrivate) { "$($lb.IsPrivate)" } else { "N/A" } SubnetIds = if ($lb.SubnetIds) { ($lb.SubnetIds) -join ", " } else { "N/A" } BackendSetCount = if ($lb.BackendSets) { "$($lb.BackendSets.Count)" } else { "0" } ListenerCount = if ($lb.Listeners) { "$($lb.Listeners.Count)" } else { "0" } TimeCreated = if ($lb.TimeCreated) { "$($lb.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))" } else { "N/A" } } } } function Get-OCIMonitoringMetrics { <# .SYNOPSIS Returns recent OCI Monitoring metric data for a resource. .DESCRIPTION Uses the OCI CLI to query the Monitoring service for metric data. Requires the OCI CLI to be installed and configured. .PARAMETER CompartmentId The OCID of the compartment. .PARAMETER Namespace The metric namespace (e.g. oci_computeagent, oci_database, oci_lbaas). .PARAMETER ResourceId The OCID of the resource. .PARAMETER MetricNames Array of metric names to query. If omitted, uses defaults per namespace. .PARAMETER Region Override the OCI region. .EXAMPLE Get-OCIMonitoringMetrics -CompartmentId $compartmentId -Namespace "oci_computeagent" -ResourceId "ocid1.instance.oc1..aaaaaaaexample" Returns default compute metrics (CPU, memory, disk, network) for the specified instance. .EXAMPLE Get-OCIMonitoringMetrics -CompartmentId $compartmentId -Namespace "oci_lbaas" -ResourceId $lbId -MetricNames @("ActiveConnections") Returns only the ActiveConnections metric for a load balancer. #> param( [Parameter(Mandatory)][string]$CompartmentId, [Parameter(Mandatory)][string]$Namespace, [Parameter(Mandatory)][string]$ResourceId, [string[]]$MetricNames, [string]$Region ) if (-not (Get-Command oci -ErrorAction SilentlyContinue)) { Write-Verbose "OCI CLI not available - skipping metrics collection" return @() } # Default metrics per namespace if (-not $MetricNames) { $MetricNames = switch ($Namespace) { "oci_computeagent" { @("CpuUtilization", "MemoryUtilization", "DiskBytesRead", "DiskBytesWritten", "NetworksBytesIn", "NetworksBytesOut") } "oci_database" { @("CpuUtilization", "StorageUtilization") } "oci_autonomous_database" { @("CpuUtilization", "StorageUtilized", "CurrentLogons") } "oci_lbaas" { @("ActiveConnections", "BytesReceived", "BytesSent", "HttpRequests", "ResponseTimeFirstByte") } default { @() } } } $startTime = (Get-Date).AddHours(-1).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.000Z") $endTime = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.000Z") $results = @() foreach ($metricName in $MetricNames) { try { $queryText = "${metricName}[1h]{resourceId = `"${ResourceId}`"}.mean()" $cliArgs = @( "monitoring", "metric-data", "summarize-metrics-data", "--compartment-id", $CompartmentId, "--namespace", $Namespace, "--query-text", $queryText, "--start-time", $startTime, "--end-time", $endTime, "--output", "json" ) if ($Region) { $cliArgs += @("--region", $Region) } $json = & oci @cliArgs 2>&1 if ($LASTEXITCODE -ne 0) { Write-Verbose "OCI CLI error for $metricName : $json" continue } $data = $json | ConvertFrom-Json $lastValue = "N/A" $unit = "N/A" if ($data.data -and $data.data.Count -gt 0) { $series = $data.data[0] $unit = if ($series.unit) { "$($series.unit)" } else { "N/A" } if ($series.'aggregated-datapoints' -and $series.'aggregated-datapoints'.Count -gt 0) { $latest = $series.'aggregated-datapoints' | Select-Object -Last 1 if ($null -ne $latest.value) { $lastValue = "$([math]::Round($latest.value, 4))" } } } $results += [PSCustomObject]@{ MetricName = $metricName Namespace = $Namespace LastValue = $lastValue Unit = $unit } } catch { Write-Verbose "Could not retrieve metric $metricName : $($_.Exception.Message)" } } return $results } function Resolve-OCIResourceIP { <# .SYNOPSIS Resolves an IP address for an OCI resource. .DESCRIPTION For Compute returns public IP (preferred) or private IP. For DB Systems returns the first resolved node IP. For Autonomous Databases returns the private endpoint IP. For Load Balancers returns the primary IP. .PARAMETER ResourceType The type of resource: Compute, DBSystem, AutonomousDB, or LoadBalancer. .PARAMETER Resource The resource object from the corresponding Get-OCI* function. .EXAMPLE $instances = Get-OCIComputeInstances -CompartmentId $compartmentId $ip = Resolve-OCIResourceIP -ResourceType "Compute" -Resource $instances[0] Resolves the IP for the first compute instance (prefers public IP). .EXAMPLE $lbs = Get-OCILoadBalancers -CompartmentId $compartmentId Resolve-OCIResourceIP -ResourceType "LoadBalancer" -Resource $lbs[0] Returns the primary IP of the first load balancer. .EXAMPLE $dbs = Get-OCIAutonomousDatabases -CompartmentId $compartmentId Resolve-OCIResourceIP -ResourceType "AutonomousDB" -Resource $dbs[0] Returns the private endpoint IP of the first Autonomous Database. #> param( [Parameter(Mandatory)][ValidateSet("Compute", "DBSystem", "AutonomousDB", "LoadBalancer")][string]$ResourceType, [Parameter(Mandatory)]$Resource ) $ip = $null switch ($ResourceType) { "Compute" { if ($Resource.PublicIP -and $Resource.PublicIP -ne "N/A") { $ip = $Resource.PublicIP } elseif ($Resource.PrivateIP -and $Resource.PrivateIP -ne "N/A") { $ip = $Resource.PrivateIP } } "DBSystem" { if ($Resource.NodeIPs -and $Resource.NodeIPs -ne "N/A") { $ip = ($Resource.NodeIPs -split ",")[0].Trim() } elseif ($Resource.Hostname -and $Resource.Hostname -ne "N/A" -and $Resource.Domain -and $Resource.Domain -ne "N/A") { $fqdn = "$($Resource.Hostname).$($Resource.Domain)" try { $resolved = [System.Net.Dns]::GetHostAddresses($fqdn) if ($resolved) { $ip = $resolved[0].IPAddressToString } } catch { } } } "AutonomousDB" { if ($Resource.PrivateEndpointIp -and $Resource.PrivateEndpointIp -ne "N/A") { $ip = $Resource.PrivateEndpointIp } } "LoadBalancer" { if ($Resource.PrimaryIP -and $Resource.PrimaryIP -ne "N/A") { $ip = $Resource.PrimaryIP } } } return $ip } function Get-OCIDashboard { <# .SYNOPSIS Builds a unified dashboard view of OCI Compute, DB Systems, Autonomous DBs, and Load Balancers. .DESCRIPTION Iterates compartments and collects compute instances, DB systems, autonomous databases, and load balancers into a flat collection suitable for Bootstrap Table display. Each row contains resource type, name, lifecycle state, resolved IP, region, availability domain, shape, creation time, and tags. .PARAMETER TenancyId The OCID of the tenancy (root compartment). .PARAMETER CompartmentIds Optional array of compartment OCIDs to limit scope. If omitted, discovers and scans all active compartments under the tenancy. .PARAMETER Region OCI region override (e.g. us-ashburn-1). Uses the configured default if omitted. .PARAMETER ConfigFile Path to the OCI config file. Defaults to ~/.oci/config. .PARAMETER Profile The OCI config profile name to use. Defaults to DEFAULT. .PARAMETER IncludeDBSystems Include Oracle DB Systems in the results. Defaults to $true. .PARAMETER IncludeAutonomousDBs Include Autonomous Databases in the results. Defaults to $true. .PARAMETER IncludeLoadBalancers Include Load Balancers in the results. Defaults to $true. .EXAMPLE Get-OCIDashboard -TenancyId "ocid1.tenancy.oc1..aaaaaaaexample" Returns all resources across all active compartments. .EXAMPLE Get-OCIDashboard -TenancyId $tenancyId -CompartmentIds $compId -IncludeDBSystems $false Returns compute, autonomous DBs, and load balancers from a single compartment. .EXAMPLE Connect-OCIProfile -ConfigFile "~/.oci/config" -Profile "PROD" $data = Get-OCIDashboard -TenancyId $tenancyId -Region "us-ashburn-1" Export-OCIDashboardHtml -DashboardData $data -OutputPath "C:\Reports\oci.html" Start-Process "C:\Reports\oci.html" End-to-end: configure profile, gather data, export HTML, and open in browser. .OUTPUTS PSCustomObject[] Each object contains: ResourceType, Name, LifecycleState, IPAddress, PrivateIP, Region, AvailabilityDomain, Shape, TimeCreated, Tags. .NOTES Author : jason@wug.ninja Version : 1.0.0 Date : 2025-07-15 Requires: PowerShell 5.1+, OCI.PSModules, OCI config file (~/.oci/config). .LINK https://github.com/jayyx2/WhatsUpGoldPS #> param( [Parameter(Mandatory)][string]$TenancyId, [string[]]$CompartmentIds, [string]$Region, [string]$ConfigFile, [string]$Profile, [bool]$IncludeDBSystems = $true, [bool]$IncludeAutonomousDBs = $true, [bool]$IncludeLoadBalancers = $true ) $commonSplat = @{} if ($Region) { $commonSplat["Region"] = $Region } if ($ConfigFile) { $commonSplat["ConfigFile"] = $ConfigFile } if ($Profile) { $commonSplat["Profile"] = $Profile } if (-not $CompartmentIds) { try { $compartments = Get-OCICompartments -TenancyId $TenancyId @commonSplat $CompartmentIds = @($TenancyId) + @($compartments | Select-Object -ExpandProperty CompartmentId) } catch { Write-Warning "Failed to list compartments: $($_.Exception.Message)" $CompartmentIds = @($TenancyId) } } $results = @() foreach ($compId in $CompartmentIds) { # Compute try { $instances = Get-OCIComputeInstances -CompartmentId $compId @commonSplat foreach ($inst in $instances) { $ip = Resolve-OCIResourceIP -ResourceType "Compute" -Resource $inst $results += [PSCustomObject]@{ ResourceType = "Compute" Name = $inst.Name LifecycleState = $inst.LifecycleState IPAddress = if ($ip) { $ip } else { "N/A" } PrivateIP = $inst.PrivateIP Region = $inst.Region AvailabilityDomain = $inst.AvailabilityDomain Shape = $inst.Shape TimeCreated = $inst.TimeCreated Tags = $inst.Tags } } } catch { Write-Warning "Compute query failed for compartment ${compId}: $($_.Exception.Message)" } # DB Systems if ($IncludeDBSystems) { try { $dbs = Get-OCIDBSystems -CompartmentId $compId @commonSplat foreach ($db in $dbs) { $ip = Resolve-OCIResourceIP -ResourceType "DBSystem" -Resource $db $results += [PSCustomObject]@{ ResourceType = "DBSystem" Name = $db.Name LifecycleState = $db.LifecycleState IPAddress = if ($ip) { $ip } else { "N/A" } PrivateIP = "N/A" Region = $db.Region AvailabilityDomain = $db.AvailabilityDomain Shape = $db.Shape TimeCreated = $db.TimeCreated Tags = "" } } } catch { Write-Warning "DB Systems query failed for compartment ${compId}: $($_.Exception.Message)" } } # Autonomous Databases if ($IncludeAutonomousDBs) { try { $adbs = Get-OCIAutonomousDatabases -CompartmentId $compId @commonSplat foreach ($adb in $adbs) { $ip = Resolve-OCIResourceIP -ResourceType "AutonomousDB" -Resource $adb $results += [PSCustomObject]@{ ResourceType = "AutonomousDB" Name = $adb.Name LifecycleState = $adb.LifecycleState IPAddress = if ($ip) { $ip } else { "N/A" } PrivateIP = $adb.PrivateEndpointIp Region = $adb.Region AvailabilityDomain = "N/A" Shape = "$($adb.CpuCoreCount) OCPUs" TimeCreated = $adb.TimeCreated Tags = "" } } } catch { Write-Warning "Autonomous DB query failed for compartment ${compId}: $($_.Exception.Message)" } } # Load Balancers if ($IncludeLoadBalancers) { try { $lbs = Get-OCILoadBalancers -CompartmentId $compId @commonSplat foreach ($lb in $lbs) { $ip = Resolve-OCIResourceIP -ResourceType "LoadBalancer" -Resource $lb $results += [PSCustomObject]@{ ResourceType = "LoadBalancer" Name = $lb.Name LifecycleState = $lb.LifecycleState IPAddress = if ($ip) { $ip } else { "N/A" } PrivateIP = "N/A" Region = $lb.Region AvailabilityDomain = "N/A" Shape = $lb.ShapeName TimeCreated = $lb.TimeCreated Tags = "" } } } catch { Write-Warning "Load Balancer query failed for compartment ${compId}: $($_.Exception.Message)" } } } return $results } function Export-OCIDashboardHtml { <# .SYNOPSIS Renders OCI dashboard data into a self-contained HTML file. .DESCRIPTION Takes the output of Get-OCIDashboard and generates a Bootstrap-based HTML report with sortable, searchable, and exportable tables. The report uses Bootstrap 5 and Bootstrap-Table for interactive filtering, sorting, column toggling, and CSV/JSON export. .PARAMETER DashboardData Array of PSCustomObject from Get-OCIDashboard containing Compute, DB System, Autonomous DB, and Load Balancer details. .PARAMETER OutputPath File path for the output HTML file. Parent directory must exist. .PARAMETER ReportTitle Title shown in the report header. Defaults to "OCI Dashboard". .PARAMETER TemplatePath Optional path to a custom HTML template. If omitted, uses the OCI-Dashboard-Template.html in the same directory as this script. .EXAMPLE $data = Get-OCIDashboard -TenancyId $tenancyId Export-OCIDashboardHtml -DashboardData $data -OutputPath "C:\Reports\oci.html" Exports the dashboard data to an HTML file using the default template. .EXAMPLE Export-OCIDashboardHtml -DashboardData $data -OutputPath "$env:TEMP\oci.html" -ReportTitle "Prod OCI" Exports with a custom report title. .EXAMPLE Connect-OCIProfile -Profile "PROD" $data = Get-OCIDashboard -TenancyId $tenancyId -Region "us-ashburn-1" Export-OCIDashboardHtml -DashboardData $data -OutputPath "C:\Reports\oci.html" Start-Process "C:\Reports\oci.html" Full pipeline: configure profile, gather, export, and open the report in a browser. .OUTPUTS System.Void Writes an HTML file to the path specified by OutputPath. .NOTES Author : jason@wug.ninja Version : 1.0.0 Date : 2025-07-15 Requires: PowerShell 5.1+, OCI-Dashboard-Template.html in the script directory. .LINK https://github.com/jayyx2/WhatsUpGoldPS #> [CmdletBinding()] param( [Parameter(Mandatory)]$DashboardData, [Parameter(Mandatory)][string]$OutputPath, [string]$ReportTitle = "OCI Dashboard", [string]$TemplatePath ) if (-not $TemplatePath) { $TemplatePath = Join-Path $PSScriptRoot "OCI-Dashboard-Template.html" } if (-not (Test-Path $TemplatePath)) { throw "HTML template not found at $TemplatePath" } $firstObj = $DashboardData | Select-Object -First 1 $columns = @() foreach ($prop in $firstObj.PSObject.Properties) { $col = @{ field = $prop.Name title = ($prop.Name -creplace '([A-Z])', ' $1').Trim() sortable = $true searchable = $true } if ($prop.Name -eq 'LifecycleState') { $col.formatter = 'formatState' } $columns += $col } $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress $dataJson = $DashboardData | ConvertTo-Json -Depth 5 -Compress $tableConfig = @" columns: $columnsJson, data: $dataJson "@ $html = Get-Content -Path $TemplatePath -Raw $html = $html -replace 'replaceThisHere', $tableConfig $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") Set-Content -Path $OutputPath -Value $html -Encoding UTF8 Write-Verbose "OCI Dashboard HTML written to $OutputPath" } # SIG # Begin signature block # MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAk7hyYsGC4GjGT # pV8UX+8M1GrGCbX3iZ3BzaNyGRqEtqCCEdMwggVvMIIEV6ADAgECAhBI/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/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBiHW0M # UgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYD # VQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENv # ZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5 # NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzAp # BgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0G # CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjI # ztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NV # DgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/3 # 6F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05Zw # mRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm # +qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUe # dyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz4 # 4MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBM # dlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQY # MBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritU # pimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNV # HSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsG # A1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1 # YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsG # AQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2Rl # U2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0 # aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURh # w1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0Zd # OaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajj # cw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNc # WbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalO # hOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJs # zkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z7 # 6mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5J # KdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHH # j95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2 # Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/ # L9Uo2bC5a4CH2RwwggY+MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqG # SIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0 # ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYw # HhcNMjYwMjA5MDAwMDAwWhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEU # MBIGA1UECAwLQ29ubmVjdGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcw # FQYDVQQDDA5KYXNvbiBBbGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC # AgoCggIBAPN6aN4B1yYWkI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyE # tRYPxEmNJL3A38Bkg7mwzPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d6 # 7MlJLUAEufl18tOr3ST1DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2 # h4KdtRAnCqwM+/V7iBdbw+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAAD # LdjZr5ip8vIWbf857Yw1Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZ # BBL56l4YAlIVRsrOiE1kdHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDe # yIq+Oztz1LvEdy8Rcd0JBY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN # 79CJ90UTZb2+I+tamj0dn+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+Phha # mOxF2px9LObhBLLEMyRsCHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi # 2ttn6lLOPThXMiQaooRUq6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSM # jDSRFDfHRCdGbZsL/keELJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJ # MIIBhTAfBgNVHSMEGDAWgBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU # 6YF0o0D5AVhKHbVocr8GaSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC # MAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIB # AwIwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM # AQQBMEkGA1UdHwRCMEAwPqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2Vj # dGlnb1B1YmxpY0NvZGVTaWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBE # BggrBgEFBQcwAoY4aHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGlj # Q29kZVNpZ25pbmdDQVIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl # Y3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvC # wOA/RYQnFJD7R/bSQRu3wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03 # J5PrDTp1WPCWbXKX8Oz9vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9 # URrpiLPJ9rQjfHMb1BUdvaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6s # X93wphwJNBJAstH9Ozs6LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+z # zieGEI50fKnN77FfNKFOWKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcj # GSHw4lTg1Wx+WMAMdl/7nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjs # Nrsk9ZlalBi5SHkIu+F6g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1 # nSX8OM++AXoyVGO+iTj7z+pl6XE9Gw/Td6WKKKsxggMaMIIDFgIBATBoMFQxCzAJ # BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl # Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYCEAec4OTRFH+FzTlzz3Yt # N+swDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZ # BgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYB # BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgs7JO8QmC75lzCXGRS56XChuPMns7fdfF # /yKlPvZxyU0wDQYJKoZIhvcNAQEBBQAEggIAALI/467cyMavnlQ+z9dwXL9NvYPN # oevERN4lf1XWsUWqO2zXooVHbDsBRK0YkyFmbXLE/bGEqsGsGtBvjl/CPO4ihC+H # RQTI5Yb9g3IjGo8Gsgzzfdm8yj5TaXH7IZEms9yh5vdaCHFB8VslNh98Skfiq4dc # dTPGwgT6yBxcyOrcYTN6hRfvQvcXcd8GFjeIszktpnxS8zpBd2QHKZhvFH7O8wTe # XCDOJdTBU8xLn/4EPV8KjUSoAlTeO2+zfcpQe8kMHShSr5hfFRj39W8mW0VhGdik # m4OtuQPTi0lluo5DA5/rwQ3lPZaOOGrCcE0nDvrQZdcRA+wa7AyYzfhrdvU6HEXo # Zly2qQlVQ38HjJCEXjbzp5CvMsPw6qo8YFqasBUT67fAnMizW6Fc7lveEaGPajAs # Yp+IIBGCT51xnSEANG6sL1x8zrXH17qbBz+FDJu6jW6ugfpoqo5HKP74b1oh8mhv # KBDxJ+iIkKZtB3q5+OlDLmjaaI+fnoOgxYpxy7W+4iFK4vM7xjszTzcbsOVvKkgL # WMNLEr03NFeD+wWycPpdqw1Xh79AkIUwAWq14OSZMOnlsvQJrOVX3/lr30Isb9CC # Q/F4mavnSk07eexsMZ8DeLelfS6jzOpfXa2qoohLI7QaxjySIwjHnsnAMdwxpCs1 # B9z5CEJEBe2KpcQ= # SIG # End signature block |