ProxmoxMonitor.psm1
|
#requires -Version 5.1 Set-StrictMode -Version Latest $script:ProxmoxMonitorVersion = '1.3.1' #region NableMonitoring function Get-PVEValue { param([AllowNull()]$Object, [Parameter(Mandatory)][string]$Name, $Default = $null) if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] } if ($null -ne $Object) { $property = $Object.PSObject.Properties[$Name] if ($null -ne $property -and $null -ne $property.Value) { return $property.Value } } return $Default } function New-MonitoringResult { param([Parameter(Mandatory)][string]$CheckName) [pscustomobject]@{ CheckName = $CheckName Status = 'OK' Findings = [Collections.Generic.List[object]]::new() Metrics = [ordered]@{} } } function Add-MonitoringFinding { param( [Parameter(Mandatory)]$Result, [Parameter(Mandatory)][ValidateSet('OK', 'WARNING', 'CRITICAL', 'UNKNOWN')][string]$Status, [Parameter(Mandatory)][string]$Message ) $rank = @{ OK = 0; WARNING = 1; UNKNOWN = 2; CRITICAL = 3 } if ($rank[$Status] -gt $rank[$Result.Status]) { $Result.Status = $Status } $Result.Findings.Add([pscustomobject]@{ Status = $Status; Message = $Message }) } function Add-MonitoringMetric { param([Parameter(Mandatory)]$Result, [Parameter(Mandatory)][string]$Name, $Value) $Result.Metrics[$Name] = $Value } function Get-Percent { param([double]$Used, [double]$Total) if ($Total -le 0) { return 0.0 } return [Math]::Round(($Used / $Total) * 100, 2) } #endregion #region ProxmoxAPI $script:PVEContext = $null function Initialize-PVECertificateValidator { if ($null -ne ('PVECertificateValidation' -as [type])) { return } $source = @' using System; using System.Net.Http; using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; public static class PVECertificateValidation { public static Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> Ignore() { return delegate(HttpRequestMessage request, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors errors) { return true; }; } public static Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool> Pin(string expected) { return delegate(HttpRequestMessage request, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors errors) { if (certificate == null) return false; using (SHA256 sha256 = SHA256.Create()) { string actual = BitConverter.ToString(sha256.ComputeHash(certificate.RawData)).Replace("-", ""); return String.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); } }; } } '@ if ($PSVersionTable.PSEdition -eq 'Desktop') { Add-Type -TypeDefinition $source -ReferencedAssemblies 'System.Net.Http.dll' } else { Add-Type -TypeDefinition $source } } function Set-PVEErrorCategory { param([Parameter(Mandatory)][Exception]$Exception, [Parameter(Mandatory)][string]$Category) $Exception.Data['PVECategory'] = $Category return $Exception } function Invoke-PVEHttpSend { param([Parameter(Mandatory)]$Client, [Parameter(Mandatory)]$Request) $response = $Client.SendAsync($Request).GetAwaiter().GetResult() try { [pscustomobject]@{ IsSuccessStatusCode = $response.IsSuccessStatusCode StatusCode = [int]$response.StatusCode Content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() } } finally { $response.Dispose() } } function Connect-Proxmox { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][string]$TokenID, [Parameter(Mandatory)][string]$TokenSecret, [ValidateSet('Ignore', 'Validate', 'Pin')][string]$TlsMode = 'Ignore', [string]$CertificateSha256, [ValidateRange(1, 300)][int]$TimeoutSec = 30, [switch]$SkipValidation ) $serverUri = $null if (-not [Uri]::TryCreate($Server, [UriKind]::Absolute, [ref]$serverUri) -or $serverUri.Scheme -ne 'https') { throw (Set-PVEErrorCategory ([ArgumentException]::new('Server must be an absolute HTTPS URL.')) 'Configuration') } if ($TokenID -notmatch '^[^!\s]+![^=\s]+$') { throw (Set-PVEErrorCategory ([ArgumentException]::new('TokenID must use the format user@realm!tokenid.')) 'Configuration') } if ([string]::IsNullOrWhiteSpace($TokenSecret)) { throw (Set-PVEErrorCategory ([ArgumentException]::new('TokenSecret cannot be empty.')) 'Configuration') } $pin = ($CertificateSha256 -replace '[^0-9A-Fa-f]', '').ToUpperInvariant() if ($TlsMode -eq 'Pin' -and $pin.Length -ne 64) { throw (Set-PVEErrorCategory ([ArgumentException]::new('CertificateSha256 must contain a 64-character SHA-256 thumbprint when TlsMode is Pin.')) 'Configuration') } if ($null -ne $script:PVEContext -and $null -ne $script:PVEContext.Client) { $script:PVEContext.Client.Dispose() $script:PVEContext.Handler.Dispose() } Add-Type -AssemblyName System.Net.Http $handler = [Net.Http.HttpClientHandler]::new() if ($TlsMode -eq 'Ignore') { Initialize-PVECertificateValidator $handler.ServerCertificateCustomValidationCallback = [PVECertificateValidation]::Ignore() } elseif ($TlsMode -eq 'Pin') { Initialize-PVECertificateValidator $handler.ServerCertificateCustomValidationCallback = [PVECertificateValidation]::Pin($pin) } $client = [Net.Http.HttpClient]::new($handler) $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSec) $null = $client.DefaultRequestHeaders.TryAddWithoutValidation('Authorization', "PVEAPIToken=$TokenID=$TokenSecret") $null = $client.DefaultRequestHeaders.TryAddWithoutValidation('Accept', 'application/json') $baseUri = $Server.TrimEnd('/') if ($baseUri -notmatch '/api2/json$') { $baseUri = "$baseUri/api2/json" } $script:PVEContext = [pscustomobject]@{ BaseUri = $baseUri Client = $client Handler = $handler } if (-not $SkipValidation) { $null = Invoke-PVERequest -Path '/version' } [pscustomobject]@{ Server = $baseUri; TokenID = $TokenID; TlsMode = $TlsMode; Connected = $true } } function Invoke-PVERequest { [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [ValidateSet('GET', 'POST', 'PUT', 'DELETE')][string]$Method = 'GET', [hashtable]$Body ) if ($null -eq $script:PVEContext) { throw (Set-PVEErrorCategory ([InvalidOperationException]::new('Connect-Proxmox must be called before making API requests.')) 'Configuration') } $uri = '{0}/{1}' -f $script:PVEContext.BaseUri, $Path.TrimStart('/') $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $uri) if ($Body) { $request.Content = [Net.Http.FormUrlEncodedContent]::new($Body) } try { $response = Invoke-PVEHttpSend -Client $script:PVEContext.Client -Request $request $content = $response.Content if (-not $response.IsSuccessStatusCode) { $code = $response.StatusCode if ($code -in 401, 403) { $exception = Set-PVEErrorCategory ([UnauthorizedAccessException]::new("Proxmox authentication or authorization failed (HTTP $code) for '$Path'.")) 'Authentication' $exception.Data['HttpStatusCode'] = $code throw $exception } $exception = Set-PVEErrorCategory ([InvalidOperationException]::new("Proxmox API returned HTTP $code for '$Path'.")) 'Availability' $exception.Data['HttpStatusCode'] = $code throw $exception } try { $envelope = $content | ConvertFrom-Json -ErrorAction Stop } catch { throw [IO.InvalidDataException]::new("Proxmox returned invalid JSON for '$Path'.", $_.Exception) } if ($null -eq $envelope -or $envelope.PSObject.Properties.Name -notcontains 'data') { throw [IO.InvalidDataException]::new("Proxmox returned an invalid response envelope for '$Path'.") } return $envelope.data } catch { if ($_.Exception.Data.Contains('PVECategory')) { throw } if ($_.Exception -is [IO.InvalidDataException] -or $_.Exception.InnerException -is [IO.InvalidDataException]) { throw (Set-PVEErrorCategory ([IO.InvalidDataException]::new($_.Exception.Message, $_.Exception)) 'InvalidResponse') } if ($_.Exception -is [Threading.Tasks.TaskCanceledException] -or $_.Exception.InnerException -is [Threading.Tasks.TaskCanceledException]) { throw (Set-PVEErrorCategory ([TimeoutException]::new("Proxmox API request timed out for '$Path'.", $_.Exception)) 'Timeout') } throw (Set-PVEErrorCategory ([InvalidOperationException]::new("Proxmox API request failed for '$Path': $($_.Exception.Message)", $_.Exception)) 'Availability') } finally { $request.Dispose() } } function Disconnect-Proxmox { if ($null -ne $script:PVEContext) { $script:PVEContext.Client.Dispose() $script:PVEContext.Handler.Dispose() $script:PVEContext = $null } } function Get-PVECluster { Invoke-PVERequest '/cluster/status' } function Get-PVENodes { Invoke-PVERequest '/nodes' } function Get-PVEStorage { Invoke-PVERequest '/cluster/resources?type=storage' } function Get-PVEGuests { Invoke-PVERequest '/cluster/resources?type=vm' } function Get-PVEHA { Invoke-PVERequest '/cluster/ha/status/current' } function Get-PVECephStatus { Invoke-PVERequest '/cluster/ceph/status' } function Get-PVECephMetadata { Invoke-PVERequest '/cluster/ceph/metadata' } function Get-PVEReplication { Invoke-PVERequest '/cluster/replication' } function Get-PVEBackups { @(Invoke-PVERequest '/cluster/tasks') | Where-Object { $_.type -eq 'vzdump' } } function Get-PVENodeStatus { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/status" } function Get-PVEDisks { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/list" } function Get-PVEZfsPools { param([Parameter(Mandatory)][string]$Node) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/zfs" } function Get-PVEZfsPoolDetails { param([Parameter(Mandatory)][string]$Node, [Parameter(Mandatory)][string]$Pool) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/disks/zfs/$([Uri]::EscapeDataString($Pool))" } function Get-PVEReplicationStatus { param([Parameter(Mandatory)][string]$Node, [Parameter(Mandatory)][string]$JobID) Invoke-PVERequest "/nodes/$([Uri]::EscapeDataString($Node))/replication/$([Uri]::EscapeDataString($JobID))/status" } #endregion #region ProxmoxCollector function Get-CollectorValue { param([AllowNull()]$Object, [string]$Name, $Default = $null) if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] } if ($null -ne $Object) { $property = $Object.PSObject.Properties[$Name] if ($null -ne $property -and $null -ne $property.Value) { return $property.Value } } return $Default } function Invoke-PVEDomainRequest { param([Parameter(Mandatory)][scriptblock]$Request) try { [pscustomobject]@{ Data = @(& $Request); Error = $null } } catch { [pscustomobject]@{ Data = @(); Error = [pscustomobject]@{ Message = $_.Exception.Message; Category = [string]$_.Exception.Data['PVECategory']; HttpStatusCode = $_.Exception.Data['HttpStatusCode'] } } } } function Read-PVESnapshot { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } try { Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { return $null } } function ConvertTo-PVEUtcDateTime { param([Parameter(Mandatory)]$Value) if ($Value -is [DateTime]) { return ([DateTime]$Value).ToUniversalTime() } if ($Value -is [DateTimeOffset]) { return ([DateTimeOffset]$Value).UtcDateTime } return [DateTimeOffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind).UtcDateTime } function Write-PVESnapshot { param([Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)][string]$Path) $directory = Split-Path -Parent $Path if (-not (Test-Path -LiteralPath $directory)) { $null = New-Item -ItemType Directory -Path $directory -Force } $temp = "$Path.$([Guid]::NewGuid().ToString('N')).tmp" try { $Snapshot | ConvertTo-Json -Depth 20 -Compress | Set-Content -LiteralPath $temp -Encoding UTF8 Move-Item -LiteralPath $temp -Destination $Path -Force } finally { if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Force } } } function Get-GuestRates { param([object[]]$CurrentGuests, $PreviousSnapshot, [DateTime]$CurrentTime) $rates = [Collections.Generic.List[object]]::new() if ($null -eq $PreviousSnapshot) { return $rates } $previousTime = ConvertTo-PVEUtcDateTime $PreviousSnapshot.CollectedAtUtc $seconds = ($CurrentTime - $previousTime).TotalSeconds if ($seconds -le 0) { return $rates } $previous = @{} foreach ($guest in @($PreviousSnapshot.Guests.Data)) { $previous["$(Get-CollectorValue $guest 'type')/$(Get-CollectorValue $guest 'vmid')"] = $guest } foreach ($guest in $CurrentGuests) { $key = "$(Get-CollectorValue $guest 'type')/$(Get-CollectorValue $guest 'vmid')" if (-not $previous.ContainsKey($key)) { continue } $old = $previous[$key] $netInDelta = [double](Get-CollectorValue $guest 'netin' 0) - [double](Get-CollectorValue $old 'netin' 0) $netOutDelta = [double](Get-CollectorValue $guest 'netout' 0) - [double](Get-CollectorValue $old 'netout' 0) if ($netInDelta -lt 0 -or $netOutDelta -lt 0) { continue } $rates.Add([pscustomobject]@{ vmid = [int](Get-CollectorValue $guest 'vmid' 0); type = [string](Get-CollectorValue $guest 'type'); netin_bps = [Math]::Round($netInDelta / $seconds, 2); netout_bps = [Math]::Round($netOutDelta / $seconds, 2) }) } return $rates } function New-PVESnapshot { [CmdletBinding()] param([Parameter(Mandatory)]$Config, $PreviousSnapshot, [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TokenSecret) $secret = $TokenSecret $tls = Get-CollectorValue $Config 'Tls' ([pscustomobject]@{}) Connect-Proxmox -Server $Config.Server -TokenID $Config.TokenID -TokenSecret $secret ` -TlsMode ([string](Get-CollectorValue $tls 'Mode' 'Ignore')) ` -CertificateSha256 ([string](Get-CollectorValue $tls 'CertificateSha256' '')) ` -TimeoutSec ([int](Get-CollectorValue $Config 'TimeoutSec' 30)) | Out-Null $secret = $null try { $collectedAt = [DateTime]::UtcNow $cluster = Invoke-PVEDomainRequest { Get-PVECluster } $nodes = Invoke-PVEDomainRequest { Get-PVENodes } $storage = Invoke-PVEDomainRequest { Get-PVEStorage } $guests = Invoke-PVEDomainRequest { Get-PVEGuests } $backups = Invoke-PVEDomainRequest { Get-PVEBackups } $replication = Invoke-PVEDomainRequest { Get-PVEReplication } $ha = Invoke-PVEDomainRequest { Get-PVEHA } $cephStatus = Invoke-PVEDomainRequest { Get-PVECephStatus } $cephMetadata = Invoke-PVEDomainRequest { Get-PVECephMetadata } $nodeStatus = [ordered]@{}; $disks = [ordered]@{}; $zfsPools = [ordered]@{}; $zfsPoolDetails = [Collections.Generic.List[object]]::new() foreach ($node in @($nodes.Data)) { if ((Get-CollectorValue $node 'status') -ne 'online') { continue } $name = [string](Get-CollectorValue $node 'node') $nodeStatus[$name] = Invoke-PVEDomainRequest { Get-PVENodeStatus -Node $name } $disks[$name] = Invoke-PVEDomainRequest { Get-PVEDisks -Node $name } $zfsPools[$name] = Invoke-PVEDomainRequest { Get-PVEZfsPools -Node $name } foreach ($pool in @($zfsPools[$name].Data)) { $poolName = [string](Get-CollectorValue $pool 'name' '') if ([string]::IsNullOrWhiteSpace($poolName)) { continue } $zfsPoolDetails.Add([pscustomobject]@{ node = $name; pool = $poolName; Result = Invoke-PVEDomainRequest { Get-PVEZfsPoolDetails -Node $name -Pool $poolName } }) } } $replicationStatus = [Collections.Generic.List[object]]::new() foreach ($job in @($replication.Data)) { $source = [string](Get-CollectorValue $job 'source' '') $id = [string](Get-CollectorValue $job 'id' '') if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($id)) { continue } $state = Invoke-PVEDomainRequest { Get-PVEReplicationStatus -Node $source -JobID $id } $replicationStatus.Add([pscustomobject]@{ id = $id; source = $source; Result = $state }) } [pscustomobject]@{ SchemaVersion = 2; ClusterName = $Config.ClusterName; CollectedAtUtc = $collectedAt.ToString('o') Cluster = $cluster; Nodes = $nodes; NodeStatus = $nodeStatus; Storage = $storage; Disks = $disks Guests = $guests; GuestRates = @(Get-GuestRates @($guests.Data) $PreviousSnapshot $collectedAt) Backups = $backups; Replication = $replication; ReplicationStatus = $replicationStatus; HA = $ha CephStatus = $cephStatus; CephMetadata = $cephMetadata; ZfsPools = $zfsPools; ZfsPoolDetails = $zfsPoolDetails } } finally { Disconnect-Proxmox } } function Get-PVESnapshot { [CmdletBinding()] param([Parameter(Mandatory)]$Config, [string]$TokenSecret, [switch]$Force) $cachePath = $Config.Runtime.CachePath $ttl = [int](Get-CollectorValue $Config 'CacheTtlSeconds' 120) $cached = Read-PVESnapshot $cachePath if (-not $Force -and $null -ne $cached -and (([DateTime]::UtcNow - (ConvertTo-PVEUtcDateTime $cached.CollectedAtUtc)).TotalSeconds -le $ttl)) { return $cached } $sha256 = [Security.Cryptography.SHA256]::Create() try { $hash = ([BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($cachePath.ToLowerInvariant())))).Replace('-', '').Substring(0, 16) } finally { $sha256.Dispose() } $mutex = [Threading.Mutex]::new($false, "Local\ProxmoxMonitor_$hash") $locked = $false try { try { $locked = $mutex.WaitOne([TimeSpan]::FromSeconds(60)) } catch [Threading.AbandonedMutexException] { $locked = $true } if (-not $locked) { throw [TimeoutException]::new('Timed out waiting for the Proxmox snapshot collector lock.') } $cached = Read-PVESnapshot $cachePath if (-not $Force -and $null -ne $cached -and (([DateTime]::UtcNow - (ConvertTo-PVEUtcDateTime $cached.CollectedAtUtc)).TotalSeconds -le $ttl)) { return $cached } $snapshot = New-PVESnapshot -Config $Config -PreviousSnapshot $cached -TokenSecret $TokenSecret Write-PVESnapshot -Snapshot $snapshot -Path $cachePath return $snapshot } finally { if ($locked) { $mutex.ReleaseMutex() } $mutex.Dispose() } } #endregion #region ProxmoxChecks function Get-CheckValue { param([AllowNull()]$Object, [string]$Name, $Default = $null) if ($Object -is [Collections.IDictionary] -and $Object.Contains($Name) -and $null -ne $Object[$Name]) { return $Object[$Name] } if ($null -ne $Object) { $property = $Object.PSObject.Properties[$Name] if ($null -ne $property -and $null -ne $property.Value) { return $property.Value } } return $Default } function Get-CheckEntries { param($Object) if ($Object -is [Collections.IDictionary]) { foreach ($key in $Object.Keys) { [pscustomobject]@{ Name = [string]$key; Value = $Object[$key] } } } elseif ($null -ne $Object) { foreach ($property in $Object.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty', 'Property' }) { [pscustomobject]@{ Name = $property.Name; Value = $property.Value } } } } function Add-DomainFailure { param($Result, $Domain, [string]$Name) $errorValue = Get-CheckValue $Domain 'Error' if ($null -ne $errorValue) { Add-MonitoringFinding $Result CRITICAL "$Name collection failed: $(Get-CheckValue $errorValue 'Message' 'unknown error')"; return $true } return $false } function Test-Threshold { param($Result, [string]$Label, [double]$Value, [double]$Warning, [double]$Critical, [switch]$LowerIsWorse) if ($LowerIsWorse) { if ($Value -le $Critical) { Add-MonitoringFinding $Result CRITICAL "$Label is $Value." } elseif ($Value -le $Warning) { Add-MonitoringFinding $Result WARNING "$Label is $Value." } } else { if ($Value -ge $Critical) { Add-MonitoringFinding $Result CRITICAL "$Label is $Value." } elseif ($Value -ge $Warning) { Add-MonitoringFinding $Result WARNING "$Label is $Value." } } } function Test-PVEClusterHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox cluster health' if (Add-DomainFailure $result $Snapshot.Cluster 'Cluster') { return $result } $entries = @($Snapshot.Cluster.Data) $nodes = @($entries | Where-Object { (Get-CheckValue $_ 'type') -eq 'node' }) $online = @($nodes | Where-Object { [int](Get-CheckValue $_ 'online' 0) -eq 1 }) Add-MonitoringMetric $result 'nodes_total' $nodes.Count Add-MonitoringMetric $result 'nodes_online' $online.Count if ($nodes.Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No cluster nodes were returned.' } foreach ($node in $nodes | Where-Object { [int](Get-CheckValue $_ 'online' 0) -ne 1 }) { Add-MonitoringFinding $result CRITICAL "Node $(Get-CheckValue $node 'name' 'unknown') is offline." } $cluster = $entries | Where-Object { (Get-CheckValue $_ 'type') -eq 'cluster' } | Select-Object -First 1 if ([bool](Get-CheckValue (Get-CheckValue $Config 'Cluster' ([pscustomobject]@{})) 'RequireQuorum' $true)) { if ($null -eq $cluster) { Add-MonitoringFinding $result WARNING 'No cluster quorum record was returned.' } elseif ([int](Get-CheckValue $cluster 'quorate' 0) -ne 1) { Add-MonitoringFinding $result CRITICAL 'The cluster does not have quorum.' } else { Add-MonitoringMetric $result 'quorate' 1 } } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "Cluster has quorum and $($online.Count) online node(s)." } return $result } function Test-PVENodeHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox node health' if (Add-DomainFailure $result $Snapshot.Nodes 'Node list') { return $result } $thresholds = Get-CheckValue $Config 'Nodes' ([pscustomobject]@{}) if (@($Snapshot.Nodes.Data).Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No nodes were returned.'; return $result } foreach ($node in @($Snapshot.Nodes.Data)) { $name = [string](Get-CheckValue $node 'node' 'unknown') if ((Get-CheckValue $node 'status' 'unknown') -ne 'online') { Add-MonitoringFinding $result CRITICAL "Node $name is offline."; continue } $domain = Get-CheckValue $Snapshot.NodeStatus $name if (Add-DomainFailure $result $domain "Node $name status") { continue } $detail = @($domain.Data) | Select-Object -First 1 $cpu = [Math]::Round([double](Get-CheckValue $detail 'cpu' 0) * 100, 2) $memoryObject = Get-CheckValue $detail 'memory' ([pscustomobject]@{}) $memory = Get-Percent ([double](Get-CheckValue $memoryObject 'used' (Get-CheckValue $detail 'mem' 0))) ([double](Get-CheckValue $memoryObject 'total' (Get-CheckValue $detail 'maxmem' 0))) $cpuInfo = Get-CheckValue $detail 'cpuinfo' ([pscustomobject]@{}) $cores = [Math]::Max(1, [int](Get-CheckValue $cpuInfo 'cpus' (Get-CheckValue $detail 'maxcpu' 1))) $load = @(Get-CheckValue $detail 'loadavg' @('0')) $loadPerCore = [Math]::Round(([double]$load[0]) / $cores, 2) Add-MonitoringMetric $result "$name.cpu_percent" $cpu Add-MonitoringMetric $result "$name.memory_percent" $memory Add-MonitoringMetric $result "$name.load_per_core" $loadPerCore Add-MonitoringMetric $result "$name.uptime_seconds" ([long](Get-CheckValue $detail 'uptime' 0)) Test-Threshold $result "Node $name CPU percent" $cpu ([double](Get-CheckValue $thresholds 'CpuWarningPercent' 80)) ([double](Get-CheckValue $thresholds 'CpuCriticalPercent' 95)) Test-Threshold $result "Node $name memory percent" $memory ([double](Get-CheckValue $thresholds 'MemoryWarningPercent' 85)) ([double](Get-CheckValue $thresholds 'MemoryCriticalPercent' 95)) Test-Threshold $result "Node $name load per core" $loadPerCore ([double](Get-CheckValue $thresholds 'LoadPerCoreWarning' 1)) ([double](Get-CheckValue $thresholds 'LoadPerCoreCritical' 2)) } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $(@($Snapshot.Nodes.Data).Count) node(s) are healthy." } return $result } function Test-PVEStorageHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox storage and disk health' if (Add-DomainFailure $result $Snapshot.Storage 'Storage') { return $result } $thresholds = Get-CheckValue $Config 'Storage' ([pscustomobject]@{}) if (@($Snapshot.Storage.Data).Count -eq 0) { Add-MonitoringFinding $result CRITICAL 'No storage resources were returned.'; return $result } foreach ($storage in @($Snapshot.Storage.Data)) { $id = "$(Get-CheckValue $storage 'node' 'cluster')/$(Get-CheckValue $storage 'storage' 'unknown')" if ((Get-CheckValue $storage 'status' 'unknown') -ne 'available') { Add-MonitoringFinding $result CRITICAL "Storage $id is $(Get-CheckValue $storage 'status' 'unknown')."; continue } $used = [double](Get-CheckValue $storage 'disk' 0); $total = [double](Get-CheckValue $storage 'maxdisk' 0) $usage = Get-Percent $used $total; $freeGB = [Math]::Round(($total - $used) / 1GB, 2) Add-MonitoringMetric $result "$id.usage_percent" $usage; Add-MonitoringMetric $result "$id.free_gb" $freeGB Test-Threshold $result "Storage $id usage percent" $usage ([double](Get-CheckValue $thresholds 'UsageWarningPercent' 80)) ([double](Get-CheckValue $thresholds 'UsageCriticalPercent' 90)) Test-Threshold $result "Storage $id free GB" $freeGB ([double](Get-CheckValue $thresholds 'FreeWarningGB' 20)) ([double](Get-CheckValue $thresholds 'FreeCriticalGB' 10)) -LowerIsWorse } foreach ($property in @(Get-CheckEntries $Snapshot.Disks)) { $node = $property.Name; $domain = $property.Value if (Add-DomainFailure $result $domain "Physical disks on $node") { continue } foreach ($disk in @($domain.Data)) { $device = [string](Get-CheckValue $disk 'devpath' (Get-CheckValue $disk 'name' 'unknown')) $health = [string](Get-CheckValue $disk 'health' '') if (-not [string]::IsNullOrWhiteSpace($health)) { Add-MonitoringMetric $result "$node.$device.health" $health if ($health.ToUpperInvariant() -notin 'PASSED', 'OK', 'UNKNOWN') { Add-MonitoringFinding $result CRITICAL "Disk $device on $node reports SMART health '$health'." } } $wear = Get-CheckValue $disk 'wearout' if ($null -ne $wear -and [string]$wear -match '^\d+(\.\d+)?$') { $remaining = [Math]::Max(0, [Math]::Round([double]$wear, 2)) Add-MonitoringMetric $result "$node.$device.life_remaining_percent" $remaining Test-Threshold $result "Disk $device on $node life remaining percent" $remaining ([double](Get-CheckValue $thresholds 'LifeRemainingWarningPercent' 20)) ([double](Get-CheckValue $thresholds 'LifeRemainingCriticalPercent' 10)) -LowerIsWorse } } } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK 'Storage resources and reported physical disk health are healthy.' } return $result } function Test-PVEGuestHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox VM and LXC health' if (Add-DomainFailure $result $Snapshot.Guests 'Guest') { return $result } $settings = Get-CheckValue $Config 'Guests' ([pscustomobject]@{}) $expected = @((Get-CheckValue $settings 'ExpectedRunningVMIDs' @()) | ForEach-Object { [int]$_ }) $guests = @($Snapshot.Guests.Data) foreach ($vmid in $expected) { $guest = $guests | Where-Object { [int](Get-CheckValue $_ 'vmid' 0) -eq $vmid } | Select-Object -First 1 if ($null -eq $guest) { Add-MonitoringFinding $result CRITICAL "Expected guest $vmid is missing." } elseif ((Get-CheckValue $guest 'status') -ne 'running') { Add-MonitoringFinding $result CRITICAL "Expected guest $vmid is $(Get-CheckValue $guest 'status' 'unknown')." } } foreach ($guest in $guests | Where-Object { (Get-CheckValue $_ 'status') -eq 'running' }) { $id = "$(Get-CheckValue $guest 'type' 'guest')/$(Get-CheckValue $guest 'vmid' 0)" $cpu = [Math]::Round([double](Get-CheckValue $guest 'cpu' 0) * 100, 2) $memory = Get-Percent ([double](Get-CheckValue $guest 'mem' 0)) ([double](Get-CheckValue $guest 'maxmem' 0)) $disk = Get-Percent ([double](Get-CheckValue $guest 'disk' 0)) ([double](Get-CheckValue $guest 'maxdisk' 0)) Add-MonitoringMetric $result "$id.cpu_percent" $cpu; Add-MonitoringMetric $result "$id.memory_percent" $memory Add-MonitoringMetric $result "$id.disk_percent" $disk; Add-MonitoringMetric $result "$id.uptime_seconds" ([long](Get-CheckValue $guest 'uptime' 0)) Test-Threshold $result "Guest $id CPU percent" $cpu ([double](Get-CheckValue $settings 'CpuWarningPercent' 80)) ([double](Get-CheckValue $settings 'CpuCriticalPercent' 95)) Test-Threshold $result "Guest $id memory percent" $memory ([double](Get-CheckValue $settings 'MemoryWarningPercent' 85)) ([double](Get-CheckValue $settings 'MemoryCriticalPercent' 95)) if ([double](Get-CheckValue $guest 'maxdisk' 0) -gt 0) { Test-Threshold $result "Guest $id disk percent" $disk ([double](Get-CheckValue $settings 'DiskWarningPercent' 80)) ([double](Get-CheckValue $settings 'DiskCriticalPercent' 90)) } } foreach ($rate in @($Snapshot.GuestRates)) { $id = "$(Get-CheckValue $rate 'type')/$(Get-CheckValue $rate 'vmid')" Add-MonitoringMetric $result "$id.netin_bps" (Get-CheckValue $rate 'netin_bps' 0); Add-MonitoringMetric $result "$id.netout_bps" (Get-CheckValue $rate 'netout_bps' 0) } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "$($guests.Count) guest(s) evaluated; expected-running guests are online." } return $result } function Test-PVEBackupHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox backup health' if (Add-DomainFailure $result $Snapshot.Backups 'Backup task') { return $result } $settings = Get-CheckValue $Config 'Backups' ([pscustomobject]@{}) $expected = @((Get-CheckValue $settings 'ExpectedVMIDs' @()) | ForEach-Object { [int]$_ }) $maximumAge = [double](Get-CheckValue $settings 'MaximumAgeHours' 26) foreach ($vmid in $expected) { $tasks = @($Snapshot.Backups.Data | Where-Object { [int](Get-CheckValue $_ 'id' 0) -eq $vmid -and $null -ne (Get-CheckValue $_ 'endtime') } | Sort-Object { [long](Get-CheckValue $_ 'endtime' 0) } -Descending) if ($tasks.Count -eq 0) { Add-MonitoringFinding $result CRITICAL "No completed backup task was found for guest $vmid."; continue } $latest = $tasks[0]; $status = [string](Get-CheckValue $latest 'status' 'unknown') if ($status -ne 'OK') { Add-MonitoringFinding $result CRITICAL "Latest backup for guest $vmid failed with status '$status'."; continue } $age = [Math]::Round(([DateTime]::UtcNow - [DateTimeOffset]::FromUnixTimeSeconds([long]$latest.endtime).UtcDateTime).TotalHours, 2) Add-MonitoringMetric $result "$vmid.last_success_age_hours" $age if ($age -gt $maximumAge) { Add-MonitoringFinding $result CRITICAL "Latest successful backup for guest $vmid is $age hours old (maximum $maximumAge)." } } if ($expected.Count -eq 0) { Add-MonitoringFinding $result OK 'No guests are configured for required backup coverage.' } elseif ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($expected.Count) required guest backup(s) are current." } return $result } function Test-PVEReplicationHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox replication health' if (Add-DomainFailure $result $Snapshot.Replication 'Replication') { return $result } $jobs = @($Snapshot.Replication.Data | Where-Object { [int](Get-CheckValue $_ 'disable' 0) -ne 1 }) if ($jobs.Count -eq 0) { Add-MonitoringFinding $result OK 'Replication is not configured.'; return $result } $maxAge = [double](Get-CheckValue (Get-CheckValue $Config 'Replication' ([pscustomobject]@{})) 'MaximumSyncAgeMinutes' 30) foreach ($job in $jobs) { $id = [string](Get-CheckValue $job 'id' 'unknown'); $wrapper = @($Snapshot.ReplicationStatus | Where-Object { (Get-CheckValue $_ 'id') -eq $id }) | Select-Object -First 1 if ($null -eq $wrapper) { Add-MonitoringFinding $result CRITICAL "No status was collected for replication job $id."; continue } if (Add-DomainFailure $result $wrapper.Result "Replication job $id") { continue } $status = @($wrapper.Result.Data) | Select-Object -First 1 $errorText = [string](Get-CheckValue $status 'error' '') if (-not [string]::IsNullOrWhiteSpace($errorText)) { Add-MonitoringFinding $result CRITICAL "Replication job $id reports: $errorText" } $lastSync = [long](Get-CheckValue $status 'last_sync' 0) if ($lastSync -gt 0) { $age = [Math]::Round(([DateTime]::UtcNow - [DateTimeOffset]::FromUnixTimeSeconds($lastSync).UtcDateTime).TotalMinutes, 2) Add-MonitoringMetric $result "$id.last_sync_age_minutes" $age if ($age -gt $maxAge) { Add-MonitoringFinding $result CRITICAL "Replication job $id last synchronized $age minutes ago (maximum $maxAge)." } } else { Add-MonitoringFinding $result CRITICAL "Replication job $id has never completed a synchronization." } } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($jobs.Count) replication job(s) are healthy." } return $result } function Test-PVEHAHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox HA health' if (Add-DomainFailure $result $Snapshot.HA 'HA') { return $result } $services = @($Snapshot.HA.Data | Where-Object { (Get-CheckValue $_ 'type') -eq 'service' }) if ($services.Count -eq 0) { Add-MonitoringFinding $result OK 'HA is not configured.'; return $result } foreach ($service in $services) { $id = [string](Get-CheckValue $service 'sid' (Get-CheckValue $service 'id' 'unknown')) $state = [string](Get-CheckValue $service 'status' (Get-CheckValue $service 'state' 'unknown')) if ($state -notin 'started', 'running') { Add-MonitoringFinding $result CRITICAL "HA resource $id is '$state'." } } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $($services.Count) HA resource(s) are healthy." } return $result } function Test-PVECephHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox Ceph health' $cephStorage = @($Snapshot.Storage.Data | Where-Object { ([string](Get-CheckValue $_ 'type' '')).ToLowerInvariant() -in 'rbd', 'cephfs' -or ([string](Get-CheckValue $_ 'plugintype' '')).ToLowerInvariant() -in 'rbd', 'cephfs' }) $statusDomain = Get-CheckValue $Snapshot 'CephStatus' ([pscustomobject]@{ Data = @(); Error = $null }) $statusError = Get-CheckValue $statusDomain 'Error' if ($null -ne $statusError) { if ($cephStorage.Count -eq 0 -and [int](Get-CheckValue $statusError 'HttpStatusCode' 0) -eq 500) { Add-MonitoringFinding $result OK 'Ceph is not configured.' return $result } Add-DomainFailure $result $statusDomain 'Ceph status' | Out-Null return $result } $status = @($statusDomain.Data) | Select-Object -First 1 if ($null -eq $status) { if ($cephStorage.Count -eq 0) { Add-MonitoringFinding $result OK 'Ceph is not configured.' } else { Add-MonitoringFinding $result UNKNOWN 'Ceph storage is configured but the status response was empty.' } return $result } $healthObject = Get-CheckValue $status 'health' ([pscustomobject]@{}) $health = ([string](Get-CheckValue $healthObject 'status' (Get-CheckValue $status 'status' 'UNKNOWN'))).ToUpperInvariant() Add-MonitoringMetric $result 'ceph.health' $health if ($health -eq 'HEALTH_WARN') { Add-MonitoringFinding $result WARNING 'Ceph reports HEALTH_WARN.' } elseif ($health -ne 'HEALTH_OK') { Add-MonitoringFinding $result CRITICAL "Ceph reports $health." } $monMap = Get-CheckValue $status 'monmap' ([pscustomobject]@{}) $monitors = @(Get-CheckValue $monMap 'mons' @()) $quorum = @(Get-CheckValue $status 'quorum_names' @()) if ($monitors.Count -gt 0) { Add-MonitoringMetric $result 'ceph.mon_total' $monitors.Count Add-MonitoringMetric $result 'ceph.mon_quorum' $quorum.Count if ($quorum.Count -lt $monitors.Count) { Add-MonitoringFinding $result CRITICAL "Only $($quorum.Count) of $($monitors.Count) Ceph monitors are in quorum." } } $mgrMap = Get-CheckValue $status 'mgrmap' ([pscustomobject]@{}) $mgrAvailable = Get-CheckValue $mgrMap 'available' $activeManager = [string](Get-CheckValue $mgrMap 'active_name' '') if ($null -ne $mgrAvailable -or -not [string]::IsNullOrWhiteSpace($activeManager)) { $managerOnline = (-not [string]::IsNullOrWhiteSpace($activeManager)) -or [bool]$mgrAvailable Add-MonitoringMetric $result 'ceph.mgr_active' ([int]$managerOnline) if (-not $managerOnline) { Add-MonitoringFinding $result CRITICAL 'Ceph has no active manager.' } } $outerOsdMap = Get-CheckValue $status 'osdmap' ([pscustomobject]@{}) $osdMap = Get-CheckValue $outerOsdMap 'osdmap' $outerOsdMap $totalOsds = [int](Get-CheckValue $osdMap 'num_osds' 0) $upOsds = [int](Get-CheckValue $osdMap 'num_up_osds' 0) $inOsds = [int](Get-CheckValue $osdMap 'num_in_osds' 0) Add-MonitoringMetric $result 'ceph.osd_total' $totalOsds Add-MonitoringMetric $result 'ceph.osd_up' $upOsds Add-MonitoringMetric $result 'ceph.osd_in' $inOsds if ($totalOsds -gt 0 -and $upOsds -lt $totalOsds) { Add-MonitoringFinding $result CRITICAL "$($totalOsds - $upOsds) of $totalOsds Ceph OSDs are down." } if ($totalOsds -gt 0 -and $inOsds -lt $totalOsds) { Add-MonitoringFinding $result WARNING "$($totalOsds - $inOsds) of $totalOsds Ceph OSDs are out." } $metadata = Get-CheckValue $Snapshot 'CephMetadata' if ($null -ne $metadata -and $null -ne (Get-CheckValue $metadata 'Error')) { Add-MonitoringFinding $result WARNING 'Ceph daemon metadata could not be collected.' } if ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "Ceph is $health; MON quorum, MGR and all $totalOsds OSD(s) are healthy." } return $result } function Add-ZfsVdevFindings { param($Result, $Vdev, [string]$Node, [string]$Pool) if ($null -eq $Vdev) { return } $name = [string](Get-CheckValue $Vdev 'name' 'unknown') $state = ([string](Get-CheckValue $Vdev 'state' '')).ToUpperInvariant() if ($state -in 'DEGRADED', 'FAULTED', 'OFFLINE', 'UNAVAIL', 'REMOVED', 'SUSPENDED') { Add-MonitoringFinding $Result CRITICAL "ZFS vdev $name in $Node/$Pool is $state." } foreach ($counter in 'read', 'write', 'cksum') { $value = [long](Get-CheckValue $Vdev $counter 0) if ($value -gt 0) { Add-MonitoringFinding $Result CRITICAL "ZFS vdev $name in $Node/$Pool reports $value $counter error(s)." } } foreach ($child in @(Get-CheckValue $Vdev 'children' @())) { Add-ZfsVdevFindings -Result $Result -Vdev $child -Node $Node -Pool $Pool } } function Test-PVEZfsHealth { param($Snapshot, $Config) $result = New-MonitoringResult 'Proxmox ZFS pool and scrub health' $settings = Get-CheckValue $Config 'ZFS' ([pscustomobject]@{}) $warningAge = [double](Get-CheckValue $settings 'ScrubWarningAgeDays' 35) $criticalAge = [double](Get-CheckValue $settings 'ScrubCriticalAgeDays' 45) $poolCount = 0 foreach ($entry in @(Get-CheckEntries (Get-CheckValue $Snapshot 'ZfsPools' ([ordered]@{})))) { $node = $entry.Name; $domain = $entry.Value if (Add-DomainFailure $result $domain "ZFS pools on $node") { continue } foreach ($pool in @($domain.Data)) { $poolCount++ $poolName = [string](Get-CheckValue $pool 'name' 'unknown') $health = ([string](Get-CheckValue $pool 'health' 'UNKNOWN')).ToUpperInvariant() Add-MonitoringMetric $result "$node.$poolName.health" $health if ($health -ne 'ONLINE') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName is $health." } $detailWrapper = @(Get-CheckValue $Snapshot 'ZfsPoolDetails' @() | Where-Object { (Get-CheckValue $_ 'node') -eq $node -and (Get-CheckValue $_ 'pool') -eq $poolName }) | Select-Object -First 1 if ($null -eq $detailWrapper) { Add-MonitoringFinding $result UNKNOWN "No details were collected for ZFS pool $node/$poolName."; continue } if (Add-DomainFailure $result $detailWrapper.Result "ZFS pool $node/$poolName details") { continue } $detail = @($detailWrapper.Result.Data) | Select-Object -First 1 if ($null -eq $detail) { Add-MonitoringFinding $result UNKNOWN "ZFS pool $node/$poolName returned empty details."; continue } Add-ZfsVdevFindings -Result $result -Vdev $detail -Node $node -Pool $poolName $dataErrors = [string](Get-CheckValue $detail 'errors' '') if (-not [string]::IsNullOrWhiteSpace($dataErrors) -and $dataErrors -notmatch '^No known data errors$') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName reports: $dataErrors" } $scan = [string](Get-CheckValue $detail 'scan' '') if ([string]::IsNullOrWhiteSpace($scan) -or $scan -match '^none requested') { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName has no recorded scrub."; continue } Add-MonitoringMetric $result "$node.$poolName.scrub" $scan if ($scan -match 'with\s+([1-9][0-9]*)\s+errors?') { Add-MonitoringFinding $result CRITICAL "ZFS pool $node/$poolName scrub reports $($Matches[1]) error(s)." } elseif ($scan -match 'scrub canceled') { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName scrub was canceled." } elseif ($scan -match 'scrub in progress') { continue } elseif ($scan -match '\son\s+(.+)$') { $completionText = [string]$Matches[1] $completed = [DateTime]::MinValue [string[]]$formats = @('ddd MMM dd HH:mm:ss yyyy', 'ddd MMM d HH:mm:ss yyyy') if ([DateTime]::TryParseExact($completionText, $formats, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AllowWhiteSpaces, [ref]$completed)) { $ageDays = [Math]::Round(((Get-Date).ToUniversalTime() - $completed.ToUniversalTime()).TotalDays, 2) Add-MonitoringMetric $result "$node.$poolName.scrub_age_days" $ageDays Test-Threshold $result "ZFS pool $node/$poolName scrub age in days" $ageDays $warningAge $criticalAge } else { Add-MonitoringFinding $result WARNING "ZFS pool $node/$poolName scrub completion time could not be parsed." } } } } if ($poolCount -eq 0 -and $result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK 'ZFS is not configured.' } elseif ($result.Findings.Count -eq 0) { Add-MonitoringFinding $result OK "All $poolCount ZFS pool(s) and recorded scrubs are healthy." } return $result } function Invoke-PVEHealthCheck { param([Parameter(Mandatory)][ValidateSet('Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs')][string]$Check, [Parameter(Mandatory)]$Snapshot, [Parameter(Mandatory)]$Config) & "Test-PVE${Check}Health" -Snapshot $Snapshot -Config $Config } #endregion #region ProxmoxPublic function New-ProxmoxMonitorConfig { param( [string]$ClusterName, [string]$Server, [string]$TokenID, [string]$TlsMode, [string]$CertificateSha256, [int]$TimeoutSec, [bool]$RequireQuorum, [int[]]$ExpectedRunningVMIDs, [int[]]$ExpectedBackupVMIDs ) [pscustomobject]@{ SchemaVersion = 1 ClusterName = $ClusterName Server = $Server TokenID = $TokenID Tls = [pscustomobject]@{ Mode = $TlsMode; CertificateSha256 = $CertificateSha256 } TimeoutSec = $TimeoutSec Cluster = [pscustomobject]@{ RequireQuorum = $RequireQuorum } Nodes = [pscustomobject]@{ CpuWarningPercent = 80; CpuCriticalPercent = 95 MemoryWarningPercent = 85; MemoryCriticalPercent = 95 LoadPerCoreWarning = 1.0; LoadPerCoreCritical = 2.0 } Storage = [pscustomobject]@{ UsageWarningPercent = 80; UsageCriticalPercent = 90 FreeWarningGB = 20; FreeCriticalGB = 10 LifeRemainingWarningPercent = 20; LifeRemainingCriticalPercent = 10 } ZFS = [pscustomobject]@{ ScrubWarningAgeDays = 35; ScrubCriticalAgeDays = 45 } Guests = [pscustomobject]@{ ExpectedRunningVMIDs = @($ExpectedRunningVMIDs) CpuWarningPercent = 80; CpuCriticalPercent = 95 MemoryWarningPercent = 85; MemoryCriticalPercent = 95 DiskWarningPercent = 80; DiskCriticalPercent = 90 } Backups = [pscustomobject]@{ ExpectedVMIDs = @($ExpectedBackupVMIDs); MaximumAgeHours = 26 } Replication = [pscustomobject]@{ MaximumSyncAgeMinutes = 30 } } } function ConvertTo-ProxmoxMetricList { param($Metrics) $items = [Collections.Generic.List[object]]::new() foreach ($entry in $Metrics.GetEnumerator()) { $items.Add([pscustomobject]@{ Name = [string]$entry.Key; Value = $entry.Value }) } return @($items) } function Select-ProxmoxProperties { param([object[]]$InputObject, [string[]]$Property) @($InputObject | ForEach-Object { $source = $_; $selected = [ordered]@{} foreach ($name in $Property) { $value = Get-PVEValue -Object $source -Name $name if ($null -ne $value) { $selected[$name] = $value } } [pscustomobject]$selected }) } function Get-ProxmoxObjectEntries { param($Object) if ($Object -is [Collections.IDictionary]) { return @($Object.Keys | ForEach-Object { [pscustomobject]@{ Name = [string]$_; Value = $Object[$_] } }) } if ($null -eq $Object) { return @() } return @($Object.PSObject.Properties | Where-Object { $_.MemberType -in 'NoteProperty', 'Property' }) } function Get-ProxmoxCounterStatePath { param([string]$StateDirectory, [string]$ClusterName, [string]$Server) if ([string]::IsNullOrWhiteSpace($StateDirectory)) { return $null } $sha256 = [Security.Cryptography.SHA256]::Create() try { $hash = ([BitConverter]::ToString($sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($Server.ToLowerInvariant())))).Replace('-', '').Substring(0, 12) } finally { $sha256.Dispose() } return (Join-Path $StateDirectory ("$ClusterName-$hash-counters.json")) } function Write-ProxmoxCounterState { param($Snapshot, [string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } $guests = @($Snapshot.Guests.Data | ForEach-Object { [pscustomobject]@{ type = Get-PVEValue $_ 'type' vmid = Get-PVEValue $_ 'vmid' netin = Get-PVEValue $_ 'netin' 0 netout = Get-PVEValue $_ 'netout' 0 } }) Write-PVESnapshot -Path $Path -Snapshot ([pscustomobject]@{ SchemaVersion = 1 CollectedAtUtc = $Snapshot.CollectedAtUtc Guests = [pscustomobject]@{ Data = $guests } }) } function New-ProxmoxEmptyInventory { [pscustomobject][ordered]@{ Nodes = @() Storage = @() Disks = @() Guests = @() GuestNetworkRates = @() BackupTasks = @() ReplicationJobs = @() HAStatus = @() CephStatus = @() CephMetadata = @() ZfsPools = @() } } function New-ProxmoxCompleteReport { param($Snapshot, $Config, [string]$ModuleVersion) $checks = [Collections.Generic.List[object]]::new() $rank = @{ OK = 0; WARNING = 1; UNKNOWN = 2; CRITICAL = 3 } $overallStatus = 'OK' foreach ($checkName in 'Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs') { $result = Invoke-PVEHealthCheck -Check $checkName -Snapshot $Snapshot -Config $Config if ($rank[$result.Status] -gt $rank[$overallStatus]) { $overallStatus = $result.Status } $checks.Add([pscustomobject]@{ Check = $checkName Status = [string]$result.Status Findings = @($result.Findings | ForEach-Object { [pscustomobject]@{ Status = [string]$_.Status; Message = [string]$_.Message } }) Metrics = @(ConvertTo-ProxmoxMetricList $result.Metrics) }) } $diskInventory = [Collections.Generic.List[object]]::new() foreach ($nodeEntry in @(Get-ProxmoxObjectEntries $Snapshot.Disks)) { foreach ($disk in @($nodeEntry.Value.Data)) { foreach ($item in @(Select-ProxmoxProperties @($disk) @('devpath', 'model', 'serial', 'type', 'health', 'wearout', 'size'))) { $item | Add-Member -NotePropertyName node -NotePropertyValue $nodeEntry.Name -Force $diskInventory.Add($item) } } } $zfsInventory = [Collections.Generic.List[object]]::new() foreach ($nodeEntry in @(Get-ProxmoxObjectEntries $Snapshot.ZfsPools)) { foreach ($pool in @($nodeEntry.Value.Data)) { foreach ($item in @(Select-ProxmoxProperties @($pool) @('name', 'health', 'size', 'alloc', 'free', 'frag', 'dedup'))) { $item | Add-Member -NotePropertyName node -NotePropertyValue $nodeEntry.Name -Force $detailWrapper = @($Snapshot.ZfsPoolDetails | Where-Object { (Get-PVEValue $_ 'node') -eq $nodeEntry.Name -and (Get-PVEValue $_ 'pool') -eq (Get-PVEValue $pool 'name') }) | Select-Object -First 1 if ($null -ne $detailWrapper -and $null -eq $detailWrapper.Result.Error) { $detail = @($detailWrapper.Result.Data) | Select-Object -First 1 foreach ($name in 'state', 'scan', 'errors') { $value = Get-PVEValue $detail $name if ($null -ne $value) { $item | Add-Member -NotePropertyName $name -NotePropertyValue $value -Force } } } $zfsInventory.Add($item) } } } [pscustomobject][ordered]@{ SchemaVersion = 1 GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') ModuleVersion = $ModuleVersion ClusterName = [string]$Config.ClusterName Server = [string]$Config.Server CollectedAtUtc = [string]$Snapshot.CollectedAtUtc OverallStatus = $overallStatus Checks = @($checks) Inventory = [pscustomobject][ordered]@{ Nodes = @(Select-ProxmoxProperties @($Snapshot.Nodes.Data) @('node', 'status', 'cpu', 'maxcpu', 'mem', 'maxmem', 'uptime')) Storage = @(Select-ProxmoxProperties @($Snapshot.Storage.Data) @('storage', 'node', 'type', 'plugintype', 'status', 'disk', 'maxdisk')) Disks = @($diskInventory) Guests = @(Select-ProxmoxProperties @($Snapshot.Guests.Data) @('vmid', 'name', 'type', 'node', 'status', 'cpu', 'maxcpu', 'mem', 'maxmem', 'disk', 'maxdisk', 'uptime')) GuestNetworkRates = @(Select-ProxmoxProperties @($Snapshot.GuestRates) @('vmid', 'type', 'netin_bps', 'netout_bps')) BackupTasks = @(Select-ProxmoxProperties @($Snapshot.Backups.Data) @('id', 'node', 'status', 'starttime', 'endtime', 'upid')) ReplicationJobs = @(Select-ProxmoxProperties @($Snapshot.Replication.Data) @('id', 'source', 'target', 'type', 'disable')) HAStatus = @(Select-ProxmoxProperties @($Snapshot.HA.Data) @('sid', 'type', 'state', 'status', 'node', 'group')) CephStatus = @($Snapshot.CephStatus.Data) CephMetadata = @($Snapshot.CephMetadata.Data) ZfsPools = @($zfsInventory) } } } function Invoke-ProxmoxMonitor { [CmdletBinding()] param( [Parameter(Mandatory)][ValidatePattern('^https://')][string]$Server, [Parameter(Mandatory)][ValidatePattern('^[^!\s]+![^=\s]+$')][string]$TokenID, [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TokenSecret, [ValidatePattern('^[A-Za-z0-9._-]+$')][string]$ClusterName = 'default', [ValidateSet('Ignore', 'Validate', 'Pin')][string]$TlsMode = 'Ignore', [string]$CertificateSha256 = '', [ValidateRange(1, 300)][int]$TimeoutSec = 30, [int[]]$ExpectedRunningVMIDs = @(), [int[]]$ExpectedBackupVMIDs = @(), [switch]$Standalone, [string]$StateDirectory = (Join-Path $env:ProgramData 'N-able\ProxmoxMonitor\State'), [ValidateSet('Json', 'Object')][string]$OutputFormat = 'Json', [switch]$Compress ) $moduleVersion = if ($ExecutionContext.SessionState.Module) { [string]$ExecutionContext.SessionState.Module.Version } else { 'source' } $secretValue = $TokenSecret try { $config = New-ProxmoxMonitorConfig -ClusterName $ClusterName -Server $Server -TokenID $TokenID ` -TlsMode $TlsMode -CertificateSha256 $CertificateSha256 -TimeoutSec $TimeoutSec ` -RequireQuorum (-not $Standalone) -ExpectedRunningVMIDs $ExpectedRunningVMIDs -ExpectedBackupVMIDs $ExpectedBackupVMIDs $statePath = Get-ProxmoxCounterStatePath -StateDirectory $StateDirectory -ClusterName $ClusterName -Server $Server $previousSnapshot = if ($null -ne $statePath) { Read-PVESnapshot -Path $statePath } else { $null } $snapshot = New-PVESnapshot -Config $config -PreviousSnapshot $previousSnapshot -TokenSecret $secretValue $secretValue = $null Write-ProxmoxCounterState -Snapshot $snapshot -Path $statePath $report = New-ProxmoxCompleteReport -Snapshot $snapshot -Config $config -ModuleVersion $moduleVersion } catch { $secretValue = $null $category = [string]$_.Exception.Data['PVECategory'] $status = if ($category -in 'InvalidResponse', 'Configuration' -or $_.Exception -is [IO.InvalidDataException]) { 'UNKNOWN' } else { 'CRITICAL' } $message = $_.Exception.Message $failedChecks = @('Cluster', 'Node', 'Storage', 'Guest', 'Backup', 'Replication', 'HA', 'Ceph', 'Zfs' | ForEach-Object { [pscustomobject]@{ Check = $_ Status = $status Findings = @([pscustomobject]@{ Status = $status; Message = "Collection failed: $message" }) Metrics = @() } }) $report = [pscustomobject][ordered]@{ SchemaVersion = 1 GeneratedAtUtc = [DateTime]::UtcNow.ToString('o') ModuleVersion = $moduleVersion ClusterName = $ClusterName Server = $Server CollectedAtUtc = $null OverallStatus = $status Checks = $failedChecks Inventory = New-ProxmoxEmptyInventory } } if ($OutputFormat -eq 'Object') { return $report } return ($report | ConvertTo-Json -Depth 30 -Compress:$Compress) } #endregion Export-ModuleMember -Function Invoke-ProxmoxMonitor |