12f038618e
Constructors seed only healthy hosts; an already-degraded/stale host is left unseeded so the first Check() emits once (cooldown dedups on hub restart). Born-degraded red-proof + staleness test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
189 lines
6.3 KiB
Go
189 lines
6.3 KiB
Go
package monitor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// HostCapabilityChecker is the operator-facing alert for the agent's privileged-capability
|
|
// self-check (felhom-agent v0.44.0). It is a deliberate SIBLING of HostStalenessChecker: same
|
|
// state-transition shape (per-host ok/degraded map, seed-without-event, emit only on change), same
|
|
// dispatcher/event plumbing — but it keys on the capability snapshot the agent rides in its host
|
|
// report (whether each required `sudo -n` grant is usable) rather than report recency.
|
|
//
|
|
// A host is "degraded" iff its latest report has any CRITICAL capability with status "degraded"
|
|
// (a missing user-facing grant — the multi-drive-flapping class). Non-critical degradations ride
|
|
// the report + the agent's own logs but do NOT alert the operator (avoid noise). The event is
|
|
// attributed to the host's customer, so the existing operator notification UX picks it up
|
|
// unchanged. Customer is NEVER notified — internal capability health is operator-only.
|
|
type HostCapabilityChecker struct {
|
|
store *store.Store
|
|
logger *log.Logger
|
|
onEvent EventNotifyFunc
|
|
|
|
mu sync.Mutex
|
|
states map[string]string // hostID → "ok" | "degraded"
|
|
customerOf map[string]string // hostID → customerID
|
|
}
|
|
|
|
// NewHostCapabilityChecker creates the checker and seeds state from the current snapshots. No
|
|
// events are generated during initialization (mirrors the staleness checker).
|
|
func NewHostCapabilityChecker(s *store.Store, onEvent EventNotifyFunc, logger *log.Logger) *HostCapabilityChecker {
|
|
cc := &HostCapabilityChecker{
|
|
store: s,
|
|
logger: logger,
|
|
onEvent: onEvent,
|
|
states: make(map[string]string),
|
|
customerOf: make(map[string]string),
|
|
}
|
|
rows, err := s.GetHostCapabilities()
|
|
if err != nil {
|
|
logger.Printf("[WARN] Host capability checker: failed to seed states: %v", err)
|
|
return cc
|
|
}
|
|
var okCount, degCount int
|
|
for _, row := range rows {
|
|
if s.IsCustomerBlocked(row.CustomerID) {
|
|
continue
|
|
}
|
|
cc.customerOf[row.HostID] = row.CustomerID
|
|
st, _, _ := capabilityState(row.Capabilities)
|
|
// F2: seed only HEALTHY hosts. A host already degraded at (re)start is left UNSEEDED so the
|
|
// first Check() observes oldState=="" and emits once — otherwise a box already broken when the
|
|
// hub restarts would stay silent forever. The dispatcher's 1h cooldown dedups the re-emit.
|
|
if st == "degraded" {
|
|
degCount++
|
|
continue
|
|
}
|
|
cc.states[row.HostID] = st
|
|
okCount++
|
|
}
|
|
logger.Printf("[INFO] Host capability checker initialized: %d ok, %d degraded (degraded left unseeded → first Check emits)", okCount, degCount)
|
|
return cc
|
|
}
|
|
|
|
// Check evaluates all hosts and emits an operator event on each ok↔degraded transition. Call on the
|
|
// same sweep as the staleness checker (every 60s).
|
|
func (cc *HostCapabilityChecker) Check() {
|
|
rows, err := cc.store.GetHostCapabilities()
|
|
if err != nil {
|
|
cc.logger.Printf("[WARN] Host capability check failed: %v", err)
|
|
return
|
|
}
|
|
cc.mu.Lock()
|
|
defer cc.mu.Unlock()
|
|
|
|
seen := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
seen[row.HostID] = true
|
|
if cc.store.IsCustomerBlocked(row.CustomerID) {
|
|
delete(cc.states, row.HostID)
|
|
continue
|
|
}
|
|
cc.customerOf[row.HostID] = row.CustomerID
|
|
|
|
newState, names, features := capabilityState(row.Capabilities)
|
|
oldState := cc.states[row.HostID]
|
|
if oldState == "" {
|
|
// F2: first observation (incl. a hub restart). A healthy first-obs seeds silently; a host
|
|
// already DEGRADED at first sight emits once (else a host that was already broken when the
|
|
// hub (re)started would never alert). The dispatcher's 1h operator cooldown dedups a re-emit
|
|
// across a hub restart.
|
|
cc.states[row.HostID] = newState
|
|
if newState == "degraded" {
|
|
cc.emitTransition(row.HostID, row.CustomerID, "unknown", newState, names, features)
|
|
}
|
|
continue
|
|
}
|
|
if oldState == newState {
|
|
continue
|
|
}
|
|
cc.states[row.HostID] = newState
|
|
cc.emitTransition(row.HostID, row.CustomerID, oldState, newState, names, features)
|
|
}
|
|
|
|
for id := range cc.states {
|
|
if !seen[id] {
|
|
delete(cc.states, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetState returns the current capability state for a host ("unknown" if unseen).
|
|
func (cc *HostCapabilityChecker) GetState(hostID string) string {
|
|
cc.mu.Lock()
|
|
defer cc.mu.Unlock()
|
|
s := cc.states[hostID]
|
|
if s == "" {
|
|
return "unknown"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// capabilityState reduces a snapshot to "ok"/"degraded" plus the degraded CRITICAL capability names
|
|
// + features (for the event detail). An empty/absent snapshot (pre-v0.44.0 agent) is "ok" — an old
|
|
// agent can't trip a false alert.
|
|
func capabilityState(caps []store.CapabilityStatus) (state string, names, features []string) {
|
|
for _, c := range caps {
|
|
if c.Critical && c.Status == "degraded" {
|
|
names = append(names, c.Name)
|
|
features = append(features, c.Feature)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) > 0 {
|
|
return "degraded", names, features
|
|
}
|
|
return "ok", nil, nil
|
|
}
|
|
|
|
func (cc *HostCapabilityChecker) emitTransition(hostID, customerID, oldState, newState string, names, features []string) {
|
|
var eventType, severity, message string
|
|
switch {
|
|
case newState == "degraded":
|
|
eventType = "agent_capability_degraded"
|
|
severity = "warning"
|
|
message = "Host " + hostID + ": agent privileged capability degraded — " +
|
|
strings.Join(names, ", ") + " (impairs: " + strings.Join(dedup(features), "; ") + ")"
|
|
case newState == "ok" && oldState == "degraded":
|
|
eventType = "agent_capability_recovered"
|
|
severity = "info"
|
|
message = "Host " + hostID + ": agent privileged capabilities recovered (all required grants restored)"
|
|
default:
|
|
return
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]any{
|
|
"host_id": hostID,
|
|
"degraded_capabilities": names,
|
|
"gated_features": dedup(features),
|
|
})
|
|
|
|
cc.logger.Printf("[INFO] Host capability: %s %s → %s (%s)", hostID, oldState, newState, eventType)
|
|
|
|
if _, err := cc.store.SaveEvent(customerID, eventType, severity, message, string(details), "hub"); err != nil {
|
|
cc.logger.Printf("[WARN] Failed to save host capability event for %s: %v", hostID, err)
|
|
return
|
|
}
|
|
if cc.onEvent != nil {
|
|
cc.onEvent(customerID, eventType, severity, message, string(details), "hub")
|
|
}
|
|
}
|
|
|
|
func dedup(in []string) []string {
|
|
seen := make(map[string]bool, len(in))
|
|
var out []string
|
|
for _, s := range in {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|