0ff1d3c883
ProcessEvent routed only warning/error; a critical-severity event was silently dropped. Now routes warning/error/critical, logs unrecognized severities, and guards a nil GetNotificationPrefs (which would panic/crash the hub). host_disk_critical emits its natural critical severity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
225 lines
8.0 KiB
Go
225 lines
8.0 KiB
Go
package monitor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// HostDiskChecker raises an operator alert when a Proxmox HOST's root filesystem crosses a warning or
|
|
// critical fill threshold — the silent-failure class observed on felhom-pve (vzdump piling under
|
|
// /var/lib/vz/dump until PVE/sqlite/logging and even the agent's own writes start failing). It reads the
|
|
// HOST root `disk_percent` the agent rides in its host-report (store.GetHostDiskUsage), so it is a
|
|
// deliberate SIBLING of HostCapabilityChecker / HostLeafChecker on the same 60s sweep, with the same
|
|
// SaveEvent+onEvent plumbing and the dispatcher's 1h operator cooldown.
|
|
//
|
|
// DISTINCT from the controller's GUEST-disk events (disk_warning/disk_critical): those are the guest's own
|
|
// cgroup view; this is the HOST root fs, with its own event types (host_disk_warning/host_disk_critical)
|
|
// so the two never dedup or mask each other.
|
|
//
|
|
// BORN/PERSISTENT (the F2 lesson, §7-B): a disk ALREADY over threshold when the hub/checker (re)starts
|
|
// must alert on cycle 1 — it never observes a "crossing". This is achieved by seeding ONLY ok-band hosts
|
|
// at init and leaving already-breached hosts UNSEEDED, so their first Check sees oldBand=="" (rank 0) and
|
|
// emits. The dispatcher's 1h cooldown dedups the re-emit across a hub restart. A transition-only design
|
|
// would stay silent forever on a persistently-full disk — exactly the wrong outcome.
|
|
type HostDiskChecker struct {
|
|
store *store.Store
|
|
warn float64 // warning threshold (percent)
|
|
crit float64 // critical threshold (percent)
|
|
logger *log.Logger
|
|
onEvent EventNotifyFunc
|
|
|
|
mu sync.Mutex
|
|
states map[string]string // hostID → band (bandOK|bandWarning|bandCritical); breached unseeded at init
|
|
customerOf map[string]string // hostID → customerID (event attribution)
|
|
}
|
|
|
|
const (
|
|
defaultHostDiskWarnPercent = 90.0
|
|
defaultHostDiskCritPercent = 95.0
|
|
)
|
|
|
|
// Disk fill bands, ordered by severity (bandRank).
|
|
const (
|
|
bandOK = "ok"
|
|
bandWarning = "warning"
|
|
bandCritical = "critical"
|
|
)
|
|
|
|
// NewHostDiskChecker creates the checker with the configured thresholds (defaults 90/95 when unset or
|
|
// invalid) and seeds state from the latest reports. NO events on init — except that already-breached
|
|
// hosts are left UNSEEDED so their first Check emits (born/persistent).
|
|
func NewHostDiskChecker(s *store.Store, warnPercent, critPercent float64, onEvent EventNotifyFunc, logger *log.Logger) *HostDiskChecker {
|
|
warn, crit := normalizeDiskThresholds(warnPercent, critPercent)
|
|
dc := &HostDiskChecker{
|
|
store: s,
|
|
warn: warn,
|
|
crit: crit,
|
|
logger: logger,
|
|
onEvent: onEvent,
|
|
states: make(map[string]string),
|
|
customerOf: make(map[string]string),
|
|
}
|
|
rows, err := s.GetHostDiskUsage()
|
|
if err != nil {
|
|
logger.Printf("[WARN] Host disk checker: failed to seed states: %v", err)
|
|
return dc
|
|
}
|
|
var okCount, breachedCount int
|
|
for _, row := range rows {
|
|
if s.IsCustomerBlocked(row.CustomerID) {
|
|
continue
|
|
}
|
|
dc.customerOf[row.HostID] = row.CustomerID
|
|
band := dc.band(row.DiskPercent)
|
|
// F2: seed only OK hosts. A host already over threshold at (re)start is left UNSEEDED so the first
|
|
// Check observes oldBand=="" and emits once — otherwise a box already full when the hub restarts
|
|
// would stay silent forever. The dispatcher's 1h cooldown dedups the re-emit.
|
|
if band != bandOK {
|
|
breachedCount++
|
|
continue
|
|
}
|
|
dc.states[row.HostID] = bandOK
|
|
okCount++
|
|
}
|
|
logger.Printf("[INFO] Host disk checker initialized: warn=%.0f%% crit=%.0f%%, %d ok seeded, %d already-breached left unseeded (first Check emits)", warn, crit, okCount, breachedCount)
|
|
return dc
|
|
}
|
|
|
|
// Check evaluates all hosts and emits on each escalation to a more severe band (ok→warning, ok/warning→
|
|
// critical), including the first-observation born-breach. De-escalation and recovery update state
|
|
// silently (re-arming a future breach). Call on the same 60s sweep as the staleness checker.
|
|
func (dc *HostDiskChecker) Check() {
|
|
rows, err := dc.store.GetHostDiskUsage()
|
|
if err != nil {
|
|
dc.logger.Printf("[WARN] Host disk check failed: %v", err)
|
|
return
|
|
}
|
|
dc.mu.Lock()
|
|
defer dc.mu.Unlock()
|
|
|
|
seen := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
seen[row.HostID] = true
|
|
if dc.store.IsCustomerBlocked(row.CustomerID) {
|
|
delete(dc.states, row.HostID)
|
|
continue
|
|
}
|
|
dc.customerOf[row.HostID] = row.CustomerID
|
|
|
|
newBand := dc.band(row.DiskPercent)
|
|
oldBand := dc.states[row.HostID] // "" (rank 0) for an unseen / breached-at-init host
|
|
// Emit only when moving to a MORE severe band — this covers the born-breach (oldBand=="" → rank 0)
|
|
// and any escalation, while de-escalation/recovery just re-arm (no noise, no recovery event).
|
|
if bandRank(newBand) > bandRank(oldBand) {
|
|
dc.emit(row, oldBand, newBand)
|
|
}
|
|
dc.states[row.HostID] = newBand
|
|
}
|
|
|
|
for id := range dc.states {
|
|
if !seen[id] {
|
|
delete(dc.states, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetState returns the current band for a host ("unknown" if unseen).
|
|
func (dc *HostDiskChecker) GetState(hostID string) string {
|
|
dc.mu.Lock()
|
|
defer dc.mu.Unlock()
|
|
s := dc.states[hostID]
|
|
if s == "" {
|
|
return "unknown"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// band maps a fill percentage to its severity band.
|
|
func (dc *HostDiskChecker) band(pct float64) string {
|
|
switch {
|
|
case pct >= dc.crit:
|
|
return bandCritical
|
|
case pct >= dc.warn:
|
|
return bandWarning
|
|
default:
|
|
return bandOK
|
|
}
|
|
}
|
|
|
|
// bandRank orders the bands so an escalation is a strictly increasing rank ("" / unseen = ok = 0).
|
|
func bandRank(b string) int {
|
|
switch b {
|
|
case bandCritical:
|
|
return 2
|
|
case bandWarning:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (dc *HostDiskChecker) emit(row store.HostDiskRow, oldBand, newBand string) {
|
|
var eventType, severity, message string
|
|
switch newBand {
|
|
case bandCritical:
|
|
eventType = "host_disk_critical"
|
|
// Natural "critical" severity (hub v0.24.0 routes it; the operator email styles it 🔴). Before
|
|
// v0.24.0 the dispatcher silently dropped "critical", so this had to be "error" — now it is honest.
|
|
severity = "critical"
|
|
message = fmt.Sprintf("Host %s: root filesystem CRITICALLY full at %.0f%% (threshold %.0f%%) — PVE/logging/agent writes may start failing; free space (e.g. old vzdump backups) immediately", row.HostID, row.DiskPercent, dc.crit)
|
|
case bandWarning:
|
|
eventType = "host_disk_warning"
|
|
severity = "warning"
|
|
message = fmt.Sprintf("Host %s: root filesystem high at %.0f%% (threshold %.0f%%) — free space (e.g. old backups) before it fills", row.HostID, row.DiskPercent, dc.warn)
|
|
default:
|
|
return // never emit for ok
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]any{
|
|
"host_id": row.HostID,
|
|
"disk_percent": row.DiskPercent,
|
|
"disk_total_bytes": row.DiskTotalBytes,
|
|
"disk_used_bytes": row.DiskUsedBytes,
|
|
"warn_percent": dc.warn,
|
|
"crit_percent": dc.crit,
|
|
})
|
|
|
|
dc.logger.Printf("[INFO] Host disk: %s root %.0f%% %s→%s (%s)", row.HostID, row.DiskPercent, bandLabel(oldBand), newBand, eventType)
|
|
|
|
if _, err := dc.store.SaveEvent(row.CustomerID, eventType, severity, message, string(details), "hub"); err != nil {
|
|
dc.logger.Printf("[WARN] Failed to save host disk event for %s: %v", row.HostID, err)
|
|
return
|
|
}
|
|
if dc.onEvent != nil {
|
|
dc.onEvent(row.CustomerID, eventType, severity, message, string(details), "hub")
|
|
}
|
|
}
|
|
|
|
// bandLabel renders the previous band for the log line ("unknown" for an unseen/breached-at-init host).
|
|
func bandLabel(b string) string {
|
|
if b == "" {
|
|
return "unknown"
|
|
}
|
|
return b
|
|
}
|
|
|
|
// normalizeDiskThresholds applies the 90/95 defaults and guards against an invalid/misordered config
|
|
// (warn/crit out of (0,100), or crit ≤ warn) by falling back to the defaults — a config typo can never
|
|
// silence the alert or invert the bands.
|
|
func normalizeDiskThresholds(warn, crit float64) (float64, float64) {
|
|
if warn <= 0 || warn >= 100 {
|
|
warn = defaultHostDiskWarnPercent
|
|
}
|
|
if crit <= 0 || crit >= 100 {
|
|
crit = defaultHostDiskCritPercent
|
|
}
|
|
if crit <= warn {
|
|
return defaultHostDiskWarnPercent, defaultHostDiskCritPercent
|
|
}
|
|
return warn, crit
|
|
}
|