Files
felhom.eu/hub/internal/monitor/host_capability.go
T
admin 5121536f10 hub v0.159.0: a refused whole-guest backup has its own alarm (R-1150); a reinstall re-issues the claim code and the off-site login when the old host is stale or off (R-1154)
R-1150: the agent's localapi:backup-cadence-cap is a separate per-host state in
HostCapabilityChecker: alone it raises whole_guest_backup_refused (warning, plain text,
agent reason in details) and whole_guest_backup_refusal_cleared (info); other critical
caps keep agent_capability_degraded/_recovered unchanged. New types operator-only and
hub-origin only (not in allowedEventTypes: the suspected controller must not post them).
agent_capability_degraded/_recovered join operatorOnlyEvents (the checker's "never the
customer" claim was configuration only).

R-1154: handleHostEnroll's reuse path re-issues (claim code + off-site login) when no
controller wait is open and either deliberate_off_at is held or both the host report and
the controller report are older than alerting.stale_threshold (wired from main); once per
silence (host_reenroll_reissued record, also written at mint); a live box gets nothing
re-issued and the skip is recorded (host_reenroll_not_reissued). 05 section 8 [DESIGN].

Tests red-proved; go build/vet/test green.
2026-10-19 03:18:34 +02:00

295 lines
12 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: both event
// types sit in notify.operatorOnlyEvents (R-1150, hub v0.159.0; before that only configuration kept
// them from a household). Pinned by notify/r1150_operator_only_test.go.
//
// R-1150 (hub v0.159.0) — ONE capability is not a grant. The agent's `localapi:backup-cadence-cap`
// (felhom-agent cmd/felhom-agent/main.go backupCapStatusName, R-973) is CRITICAL and "degraded" for
// 24 h after the agent REFUSED a whole-guest backup a guest asked for beyond its schedule. Reported
// as „agent privileged capability degraded" it read as a broken sudo grant. It is therefore a SECOND,
// independent state per host with its own event pair:
//
// - the grants (every other critical capability): ok↔degraded → agent_capability_degraded /
// agent_capability_recovered, exactly as before, the cadence cap never named in them;
// - the backup refusal (the cadence cap alone): ok→degraded → whole_guest_backup_refused (warning),
// degraded→ok → whole_guest_backup_refusal_cleared (info: recorded, never mailed).
//
// Two states rather than one verdict with a special case, because the two can change on the SAME
// report independently (a refusal while a grant is missing, a grant restored while the refusal
// stands): each fires on its own edge, so one report flip is at most one event per state — never
// two events for one state, and never a "grants restored" line when only the refusal window ended.
// Pinned by host_capability_test.go TestR1150_*.
type HostCapabilityChecker struct {
store *store.Store
logger *log.Logger
onEvent EventNotifyFunc
mu sync.Mutex
states map[string]string // hostID → "ok" | "degraded" (the grants)
refusals map[string]string // hostID → "ok" | "degraded" (the backup cadence cap, R-1150)
customerOf map[string]string // hostID → customerID
}
// backupCadenceCapName is the agent's R-973 cadence-cap status name. It must equal felhom-agent's
// backupCapStatusName (cmd/felhom-agent/main.go) — the agent's status name stays (R-1150).
const backupCadenceCapName = "localapi:backup-cadence-cap"
// R-1150 event types — hub-origin ONLY (deliberately not in api.allowedEventTypes: the guest's
// controller is the party this alarm suspects, and a box able to POST the type could fake it or,
// by spending the operator cooldown first, drown the real one) and operator-only.
const (
EventWholeGuestBackupRefused = "whole_guest_backup_refused"
EventWholeGuestBackupRefusalCleared = "whole_guest_backup_refusal_cleared"
)
// 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),
refusals: 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, refusedCount 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.
// The same rule, separately, for the backup refusal (R-1150).
if st == "degraded" {
degCount++
} else {
cc.states[row.HostID] = st
okCount++
}
if rst, _ := backupRefusalState(row.Capabilities); rst == "degraded" {
refusedCount++
} else {
cc.refusals[row.HostID] = rst
}
}
logger.Printf("[INFO] Host capability checker initialized: %d ok, %d degraded, %d with a refused whole-guest backup (degraded/refused left unseeded → first Check emits)", okCount, degCount, refusedCount)
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)
delete(cc.refusals, row.HostID)
continue
}
cc.customerOf[row.HostID] = row.CustomerID
newState, names, features := capabilityState(row.Capabilities)
if old, changed := step(cc.states, row.HostID, newState); changed {
cc.emitTransition(row.HostID, row.CustomerID, old, newState, names, features)
}
refState, reason := backupRefusalState(row.Capabilities)
if old, changed := step(cc.refusals, row.HostID, refState); changed {
cc.emitRefusal(row.HostID, row.CustomerID, old, refState, reason)
}
}
for _, m := range []map[string]string{cc.states, cc.refusals} {
for id := range m {
if !seen[id] {
delete(m, id)
}
}
}
}
// step records newState for hostID in m and reports whether an event is due, with the state it left
// ("unknown" on a first observation). F2: a first observation (incl. a hub restart) seeds a healthy
// state silently, and a state already DEGRADED at first sight is due 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. A steady state is never due.
func step(m map[string]string, hostID, newState string) (oldState string, due bool) {
oldState = m[hostID]
m[hostID] = newState
if oldState == "" {
return "unknown", newState == "degraded"
}
return oldState, oldState != newState
}
// 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
}
// GetBackupRefusalState returns the host's whole-guest backup refusal state (R-1150): "ok", "degraded"
// (the agent refused one inside its window), or "unknown" if unseen.
func (cc *HostCapabilityChecker) GetBackupRefusalState(hostID string) string {
cc.mu.Lock()
defer cc.mu.Unlock()
s := cc.refusals[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. The backup cadence cap is NOT a grant and is left to
// backupRefusalState (R-1150).
func capabilityState(caps []store.CapabilityStatus) (state string, names, features []string) {
for _, c := range caps {
if c.Name == backupCadenceCapName {
continue
}
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")
}
}
// backupRefusalState reads the agent's cadence-cap status (R-1150): "degraded" with the agent's own
// reason while it reports a refused whole-guest backup, else "ok" (absent on an agent without the cap).
// The agent always reports the cap as critical; Critical is required here as it is for every alarm.
func backupRefusalState(caps []store.CapabilityStatus) (state, reason string) {
for _, c := range caps {
if c.Name == backupCadenceCapName && c.Critical && c.Status == "degraded" {
return "degraded", c.Reason
}
}
return "ok", ""
}
// emitRefusal raises the operator's alarm for a refused whole-guest backup (R-1150) — the agent turned
// down a backup the guest asked for beyond its schedule — and records its end.
func (cc *HostCapabilityChecker) emitRefusal(hostID, customerID, oldState, newState, reason string) {
var eventType, severity, message string
switch {
case newState == "degraded":
eventType = EventWholeGuestBackupRefused
severity = "warning"
message = "Host " + hostID + ": a whole-guest backup was refused — the guest asked for more whole-guest " +
"backups than its schedule allows. Its controller may be misbehaving or compromised: check the guest."
case newState == "ok" && oldState == "degraded":
eventType = EventWholeGuestBackupRefusalCleared
severity = "info"
message = "Host " + hostID + ": the agent no longer reports a refused whole-guest backup (its refusal window has ended)"
default:
return
}
details, _ := json.Marshal(map[string]any{
"host_id": hostID,
"capability": backupCadenceCapName,
"agent_reason": reason,
})
cc.logger.Printf("[INFO] Host capability: %s backup refusal %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 backup refusal 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
}