Files
felhom-controller/controller/internal/channelhealth/checker.go
T
admin b2ad871720 channelhealth: F2 — alert on born/persistent-down (alerted flag), not only transitions v0.91.0
A channel broken at startup/reseed (e.g. controller boots into pin_mismatch) was dashboard-only,
no operator email ever. New 'alerted' flag drives alerting instead of prev=='': born-down
non-transient alerts cycle 1; transient still N>=2; healthy first-obs silent; recovery re-arms.
Red-proof + companion included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
2026-06-29 21:45:57 +02:00

204 lines
9.4 KiB
Go

// Package channelhealth periodically proves the controller→agent local-API channel and surfaces a
// classified operator alert + dashboard entry on a state change. It closes the gap the R1
// pin-mismatch incident exposed: the channel was only probed once at startup (probeLocalAPI) and only
// logged, so a mid-life agent re-key went unnoticed until a user hit the dead disk UI.
//
// Design is spike-driven (SPIKE-controller-agent-channel-health-2026-06-29, GO):
// - Probe via the PRODUCTION memoized agent client (reused, not a fresh one): it self-heals after an
// agent restart, reflects exactly what the UI sees, and avoids the per-call transport leak the
// singleton fixed. The construction error (bad fingerprint → agentClient() fails, latching via
// sync.Once) is surfaced distinctly.
// - Classify the failure by the error substring (the spike Q1 map).
// - Debounce transient reasons (connection-refused / timeout): a clean agent restart shows a ~1s
// socket gap (one failed probe) that must NOT page — require N>=2 consecutive before alerting.
// Pin-mismatch / 401 / construction / DNS alert on first observation (they don't self-clear).
// - First scheduler observation SEEDS state without notifying (mirrors host_staleness/host_capability).
package channelhealth
import (
"context"
"log"
"strings"
"sync"
"time"
)
// debounceThreshold is the number of consecutive down-probes a TRANSIENT reason needs before it is
// treated as a real outage (suppresses the ~1s agent-restart blip from the spike's Q2).
const debounceThreshold = 2
// probeTimeout mirrors probeLocalAPI's 15s budget for the GET /storage call.
const probeTimeout = 15 * time.Second
// Reason is the classified channel-down cause.
type Reason string
const (
ReasonPinMismatch Reason = "pin_mismatch"
ReasonUnauthorized Reason = "unauthorized"
ReasonUnreachable Reason = "unreachable"
ReasonTimeout Reason = "timeout"
ReasonMisconfigured Reason = "misconfigured" // endpoint unresolvable (DNS)
ReasonConstruction Reason = "construction_error" // agentClient() build error — LATCHES until restart
ReasonUnknown Reason = "unknown"
)
// Probe runs one channel check via the production memoized client. It returns whether the failure was
// a CONSTRUCTION error (agentClient() couldn't even build — a latching config fault) and the error
// (nil = channel up). Production wires this to Server.ProbeAgentChannel; tests inject a fake.
type Probe func(ctx context.Context) (constructionErr bool, err error)
// Sink receives the checker's outputs. SetDashboard reflects the CURRENT state every check (idempotent
// — a born-down channel shows immediately, with no notification). NotifyDown/NotifyRecovered fire ONLY
// on a transition (the operator relay, with the hub-side cooldown). Implemented in main.go over the
// notify.Notifier + web.AlertManager (kept out of this package to avoid an import cycle).
type Sink interface {
SetDashboard(down bool, reason Reason, hungarianMsg string)
NotifyDown(reason Reason, eventType, severity, englishMsg string)
NotifyRecovered()
}
type classification struct {
reason Reason
eventType string
severity string // "error" (critical) | "warning" (transient)
english string // operator alert message (relayed to the hub)
hungarian string // short dashboard line
debounce bool // transient → require N>=2 consecutive
}
// classify maps a probe result to a classification. constructionErr is authoritative for the latching
// config fault; otherwise the error substring decides (spike Q1 map). Match substrings, not exact
// strings (the wrapped Get "..." prefix varies).
func classify(constructionErr bool, err error) classification {
if constructionErr {
return classification{ReasonConstruction, "agent_channel_construction_error", "error",
"Controller→agent channel down: agent client misconfigured — local_api.fingerprint is not a valid SHA-256, the controller cannot build the client (config fix + controller restart needed).",
"A tárolókezelő ügynök kapcsolata hibásan beállítva.", false}
}
s := err.Error()
switch {
case strings.Contains(s, "TLS pin mismatch"):
return classification{ReasonPinMismatch, "agent_channel_pin_mismatch", "error",
"Controller→agent channel down: TLS pin mismatch — the agent's leaf cert no longer matches the controller's bootstrap fingerprint (re-pin / re-bootstrap).",
"A tárolókezelő ügynök tanúsítványa megváltozott.", false}
case strings.Contains(s, "HTTP 401"):
return classification{ReasonUnauthorized, "agent_channel_unauthorized", "error",
"Controller→agent channel down: agent rejected the controller token (HTTP 401) — token stale/rotated (re-bootstrap).",
"A tárolókezelő ügynök elutasította a hozzáférést.", false}
case strings.Contains(s, "no such host") || strings.Contains(s, "lookup "):
return classification{ReasonMisconfigured, "agent_channel_misconfigured", "error",
"Controller→agent channel down: agent endpoint unresolvable — local_api.endpoint misconfigured.",
"A tárolókezelő ügynök címe nem feloldható.", false}
case strings.Contains(s, "connection refused"):
return classification{ReasonUnreachable, "agent_channel_unreachable", "warning",
"Controller→agent channel down: agent unreachable (connection refused) — felhom-agent down or :8443 closed.",
"A tárolókezelő ügynök nem elérhető.", true}
case strings.Contains(s, "context deadline exceeded") || strings.Contains(s, "i/o timeout") || strings.Contains(s, "Client.Timeout"):
return classification{ReasonTimeout, "agent_channel_timeout", "warning",
"Controller→agent channel down: timeout — host unreachable / packets dropped.",
"A tárolókezelő ügynök nem válaszol.", true}
default:
return classification{ReasonUnknown, "agent_channel_unknown", "warning",
"Controller→agent channel down: " + s,
"A tárolókezelő ügynök nem elérhető.", true}
}
}
// Checker holds the in-memory channel state. No persistence — the state is re-derived each run
// (mirrors the AlertManager's state-based model). Safe for the single scheduler caller; the mutex
// guards against an overlapping run.
type Checker struct {
probe Probe
sink Sink
logger *log.Logger
mu sync.Mutex
state string // "" (unseeded) | "up" | "down:<reason>"
consecutiveDown int
alerted bool // have we emitted a down alert for the CURRENT down-spell? (F2: drives
// alerting instead of `prev==""`, so a BORN-down — broken at startup/reseed — alerts too, not
// just a live up→down transition; re-armed on recovery / reason-change.)
}
// New builds a checker over the probe + sink seams.
func New(probe Probe, sink Sink, logger *log.Logger) *Checker {
return &Checker{probe: probe, sink: sink, logger: logger}
}
// Check runs one probe cycle: classify, debounce, reflect on the dashboard, and notify on a real
// transition. Best-effort + idempotent — safe to call on a timer. Never returns an error to the
// scheduler (a probe failure IS the signal, not a job failure).
func (c *Checker) Check(ctx context.Context) error {
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
constructionErr, perr := c.probe(pctx)
cancel()
c.mu.Lock()
defer c.mu.Unlock()
// Channel UP.
if perr == nil {
c.consecutiveDown = 0
c.sink.SetDashboard(false, "", "")
prev := c.state
c.state = "up"
c.alerted = false // re-arm for the next down-spell
if prev != "" && prev != "up" {
c.logger.Printf("[INFO] [channel] agent channel recovered (was %s)", prev)
c.sink.NotifyRecovered()
}
return nil // healthy first-obs / steady-up → no notify
}
// Channel DOWN — classify + debounce transient reasons.
cls := classify(constructionErr, perr)
c.consecutiveDown++
if cls.debounce && c.consecutiveDown < debounceThreshold {
// A transient blip (e.g. the ~1s agent-restart socket gap, or the agent not yet up on a cold
// boot). Hold the previous state — do NOT flip the dashboard or notify. Unseeded → assume up
// until confirmed (so a transient born-down still needs N>=2 before it alerts).
if c.state == "" {
c.state = "up"
}
c.logger.Printf("[DEBUG] [channel] transient down (%s, %d/%d) — suppressed pending confirmation: %v",
cls.reason, c.consecutiveDown, debounceThreshold, perr)
return nil
}
// Confirmed down. F2: a NEW down-spell — coming from up/unseeded OR a reason change — re-arms the
// alert, so a BORN-down (broken at startup/reseed) alerts on cycle 1 for non-transient reasons,
// not only a live up->down transition. A steady down that already alerted does not re-fire.
newState := "down:" + string(cls.reason)
c.sink.SetDashboard(true, cls.reason, cls.hungarian) // dashboard reflects current state always
prev := c.state
if prev == "" || prev == "up" || prev != newState {
c.alerted = false
}
c.state = newState
if c.alerted {
return nil // steady down, same reason, already alerted → no duplicate (dashboard stays set)
}
c.logger.Printf("[WARN] [channel] agent channel DOWN (%s->%s): %v", orUnseeded(prev), newState, perr)
c.sink.NotifyDown(cls.reason, cls.eventType, cls.severity, cls.english)
c.alerted = true
return nil
}
func orUnseeded(s string) string {
if s == "" {
return "unseeded"
}
return s
}
// State returns the current channel state (for tests/diagnostics).
func (c *Checker) State() string {
c.mu.Lock()
defer c.mu.Unlock()
if c.state == "" {
return "unseeded"
}
return c.state
}