Files
felhom-controller/controller/internal/fillwatch/fillwatch.go
T
admin 9a3c4855d7
gates / gates (push) Successful in 9s
v0.191.2 — a quiet fill check now says so (R-167)
Earned during v0.191.1's own live validation. After the customer had been
warned, a restart produced ZERO fillwatch lines — equally consistent with
'ran and chose silence' and 'never ran'. Proving the checker was alive
needed a deliberate crossing into the critical band.

For an edge-triggered check the quiet run IS the healthy steady state, so
that ambiguity is permanent rather than rare. Check now logs a per-RUN
summary on every run, counting unreadable separately from healthy so a
drive that has quietly gone unreadable cannot read as 'all fine'.
2026-08-02 23:37:04 +02:00

404 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package fillwatch warns the CUSTOMER that a filesystem is filling, BEFORE anything fails.
//
// R-167, operator decision D-c (2026-08-02, felhom.eu CONTEXT.md S-5). Nothing warned before this:
// the first sign of a full filesystem was a backup that did not happen. The healthcheck folded disk
// pressure into a generic `health_degraded` at 90%, for REGISTERED STORAGE PATHS ONLY — it never
// looked at the docker area or the system-data area, never reported free bytes, and never named the
// drive.
//
// WHY IT SHIPS BEFORE THE mp1→mp0 MERGE (D-a / R-165), not with it. Today an app that outgrows the
// 20 G backup area is refused per app with its last good recovery unit preserved byte-identical —
// a wall, but one that fails safely, one app at a time. The merge removes that wall so backups share
// the large area. D-a's own condition (2) says the monitoring lands in the same step and never after;
// landing it FIRST is strictly better and costs nothing, so the warnings go in and get proven on real
// hardware while the wall is still standing.
//
// WHY THE FILESYSTEM IS THE UNIT AND NOT THE APP. One full disk holding ten apps would produce ten
// identical warnings, nine of them noise. The customer's action — free space, delete files, add a
// drive — is per filesystem too.
package fillwatch
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// ── Thresholds ───────────────────────────────────────────────────────────────────────────────────
//
// TWO TERMS, AND WHICHEVER TRIPS FIRST WINS, because a percentage alone lies at both ends of the
// size range this fleet actually has: 85% of a 20 G backup area leaves 3 G — not enough for one
// DB-backed app's recovery unit, which is up to ~2× its data (measured: 21.1 GB → 40.2 GB,
// architecture/07-backup-architecture.md §7.5) — while 85% of a 4 TB media drive leaves 600 G and is
// nothing to write home about. A free-bytes floor catches the first; a percentage catches the second.
const (
// WarnUsedPercent / WarnFreeGiB — enter the warning band.
WarnUsedPercent = 85.0
WarnFreeGiB = 5.0
// CritUsedPercent / CritFreeGiB — enter the critical band. Below 2 GiB a volume tar of almost any
// real app fails, so this is "the next backup will not complete", not "it is getting tight".
CritUsedPercent = 95.0
CritFreeGiB = 2.0
// ClearUsedPercent / ClearFreeGiB — the RETURN threshold, deliberately well below the warn pair.
// Both must hold. Between clear and warn is a dead zone in which the previous band is HELD, so a
// filesystem hovering on the line does not flap between warned and cleared. The gap is pinned by
// TestThresholdsKeepTheirHysteresisGap — a warn and clear threshold that can be edited into
// equality is a flapping bug waiting to be introduced.
ClearUsedPercent = 75.0
ClearFreeGiB = 7.0
)
// Band is a filesystem's fill state. Ordered: escalation is an increase.
type Band int
const (
BandOK Band = iota
BandWarning
BandCritical
)
func (b Band) String() string {
switch b {
case BandWarning:
return "warning"
case BandCritical:
return "critical"
default:
return "ok"
}
}
// EventType maps a band to the hub event type it emits.
//
// These two types already existed in the hub's allowedEventTypes, already carried Hungarian copy,
// already sat in the controller's DefaultEnabledEvents and already had a UI checkbox — and NOTHING
// IN ANY REPO EMITTED THEM. A complete customer pipeline with no producer, the sixth "built but
// never wired" instance in this project. This package is that producer; a new near-duplicate event
// type would have left the pair inert forever.
func (b Band) EventType() string {
switch b {
case BandCritical:
return "disk_critical"
case BandWarning:
return "disk_warning"
default:
return ""
}
}
// Severity is the hub severity for a band. `disk_warning` must be "warning" and `disk_critical`
// "critical" — the dispatcher drops "info" entirely (severityNotifies), so getting this wrong stores
// the event and mails nobody.
func (b Band) Severity() string {
switch b {
case BandCritical:
return "critical"
case BandWarning:
return "warning"
default:
return ""
}
}
// Usage is one filesystem's occupancy. Deliberately not `system.DiskUsageInfo` — this package must be
// testable without a real filesystem, and the seam is the reason it is.
type Usage struct {
UsedPercent float64
AvailGB float64
UsedGB float64
TotalGB float64
}
// Target is a watched filesystem: the path to stat, and the name a CUSTOMER will recognise.
type Target struct {
Path string
Label string
}
// Event is one crossing, handed to the notify seam.
type Event struct {
Target Target
Band Band
Usage Usage
Message string // the Hungarian customer text, already rendered
}
// Watcher holds the persisted per-filesystem band and emits on escalation only.
type Watcher struct {
statePath string
logger *log.Logger
// targets returns the filesystems to watch, resolved at CHECK time — a drive added or removed
// between checks must be picked up without a restart.
targets func() []Target
// usage reads one filesystem. NIL RESULT MEANS UNREADABLE, NEVER FULL (§8.4).
usage func(path string) *Usage
// notify pushes the customer event. Nil-safe.
notify func(Event)
mu sync.Mutex
bands map[string]Band
}
// New builds a Watcher over a state file. Nothing is read until Check runs.
func New(statePath string, logger *log.Logger, targets func() []Target, usage func(string) *Usage) *Watcher {
if logger == nil {
logger = log.Default()
}
return &Watcher{
statePath: statePath,
logger: logger,
targets: targets,
usage: usage,
bands: make(map[string]Band),
}
}
// SetNotify wires the customer event push. INIT-ONLY — call once at startup.
func (w *Watcher) SetNotify(fn func(Event)) { w.notify = fn }
// classify decides the band from a reading AND the previous band, which is what makes the hysteresis
// work: between the clear and warn thresholds the previous band is HELD rather than recomputed.
//
// Pure, so it is unit-testable without a filesystem, a clock or a notifier.
func classify(u Usage, prev Band) Band {
switch {
case u.UsedPercent >= CritUsedPercent || u.AvailGB < CritFreeGiB:
return BandCritical
case u.UsedPercent >= WarnUsedPercent || u.AvailGB < WarnFreeGiB:
return BandWarning
case u.UsedPercent <= ClearUsedPercent && u.AvailGB >= ClearFreeGiB:
return BandOK
default:
// The dead zone. Holding `prev` is the whole hysteresis: a filesystem sitting at 80% neither
// warns (it is below the warn line) nor clears (it is above the clear line).
return prev
}
}
// Check reads every target once and emits for each ESCALATION. Safe to call from the scheduler.
func (w *Watcher) Check() error {
if w == nil {
return nil
}
w.mu.Lock()
defer w.mu.Unlock()
w.loadLocked()
targets := w.targets()
seen := make(map[string]bool, len(targets))
changed := false
var checked, skipped, emitted int
for _, t := range targets {
if t.Path == "" {
continue
}
seen[t.Path] = true
u := w.usage(t.Path)
if u == nil {
// §8.4 — NOT-YET-MOUNTED IS NOT FULL. An absent, unmounted or unreadable filesystem is
// the drive gate's business and already has its own alert (storage_disconnected /
// backup_target_absent). Reporting it as "full" would be a false alarm with a misleading
// cause, and would tell the customer to delete files that are not the problem.
w.logger.Printf("[DEBUG] [fillwatch] %s: usage unreadable — skipped (an unreadable filesystem is not a full one)", t.Path)
skipped++
continue
}
checked++
prev := w.bands[t.Path]
next := classify(*u, prev)
if next == prev {
continue
}
w.bands[t.Path] = next
changed = true
if next <= prev {
// De-escalation, including the return to OK, is SILENT. The customer already acted, or
// the app deleted its own temp files; telling them a resolved problem is resolved is
// noise. The state change re-arms the warning, which is what Scenario F asks for.
w.logger.Printf("[INFO] [fillwatch] %s: %s → %s (%.0f%% used, %.1f GB free) — cleared silently, re-armed",
t.Path, prev, next, u.UsedPercent, u.AvailGB)
continue
}
msg := Message(t, *u, next)
w.logger.Printf("[WARN] [fillwatch] %s (%q): %s → %s — %.0f%% used, %.1f GB free of %.1f GB; notifying the customer",
t.Path, t.Label, prev, next, u.UsedPercent, u.AvailGB, u.TotalGB)
emitted++
if w.notify != nil {
w.notify(Event{Target: t, Band: next, Usage: *u, Message: msg})
}
}
// Forget filesystems that no longer exist (a decommissioned drive), so the state file cannot grow
// without bound and a re-added drive starts from OK rather than from a stale band.
for path := range w.bands {
if !seen[path] {
delete(w.bands, path)
changed = true
}
}
// A POSITIVE OBSERVABLE, every run, healthy or not (standing rule 3). Without it a quiet run is
// indistinguishable from a checker that never ran — and because this check is edge-triggered, the
// HEALTHY steady state IS a quiet run, so the ambiguity is permanent rather than rare. It was hit
// for real while live-validating v0.191.1 on 9201: an unchanged band produced zero log lines, and
// proving the checker was alive needed a deliberate threshold crossing.
w.logger.Printf("[INFO] [fillwatch] checked %d filesystem(s), %d unreadable/skipped, %d notification(s); bands: %s",
checked, skipped, emitted, w.bandSummaryLocked())
if !changed {
return nil
}
return w.saveLocked()
}
// bandSummaryLocked renders the current per-path bands for the run summary, deterministically. Caller
// holds w.mu.
func (w *Watcher) bandSummaryLocked() string {
if len(w.bands) == 0 {
return "all ok"
}
paths := make([]string, 0, len(w.bands))
for p := range w.bands {
paths = append(paths, p)
}
sort.Strings(paths)
parts := make([]string, 0, len(paths))
for _, p := range paths {
parts = append(parts, p+"="+w.bands[p].String())
}
return strings.Join(parts, " ")
}
// Bands returns a copy of the current per-path state (diagnostics + tests).
func (w *Watcher) Bands() map[string]Band {
w.mu.Lock()
defer w.mu.Unlock()
out := make(map[string]Band, len(w.bands))
for k, v := range w.bands {
out[k] = v
}
return out
}
// ── State persistence ────────────────────────────────────────────────────────────────────────────
//
// Persisted so a controller restart does not re-warn about a filesystem the customer has already
// been told about — the offboxEnlargeBlockedNotify discipline. LOSING IT IS THE ACCEPTABLE
// DIRECTION: at most one extra warning, never a missed one. THE HUB OWNS COOLDOWN; this package must
// never add a timer of its own.
type persisted struct {
Bands map[string]string `json:"bands"`
}
func (w *Watcher) loadLocked() {
if w.statePath == "" {
return
}
data, err := os.ReadFile(w.statePath)
if err != nil {
if !os.IsNotExist(err) {
w.logger.Printf("[WARN] [fillwatch] could not read %s: %v — starting from OK (at most one extra warning)", w.statePath, err)
}
return
}
var p persisted
if err := json.Unmarshal(data, &p); err != nil {
w.logger.Printf("[WARN] [fillwatch] %s is corrupt: %v — starting from OK (at most one extra warning)", w.statePath, err)
return
}
for path, band := range p.Bands {
switch band {
case "critical":
w.bands[path] = BandCritical
case "warning":
w.bands[path] = BandWarning
}
}
}
func (w *Watcher) saveLocked() error {
if w.statePath == "" {
return nil
}
p := persisted{Bands: make(map[string]string, len(w.bands))}
for path, band := range w.bands {
if band == BandOK {
continue // OK is the default — do not persist it
}
p.Bands[path] = band.String()
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(w.statePath), 0o755); err != nil {
return err
}
tmp := w.statePath + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, w.statePath)
}
// ── Customer copy (Hungarian) ────────────────────────────────────────────────────────────────────
// Message renders the customer-facing Hungarian warning.
//
// IT MUST SAY WHAT TO DO, not merely that something is happening — a warning a customer cannot act
// on is an operator alert wearing the wrong clothes. It names the storage by its LABEL (the customer
// named it; a path means nothing to them) and gives the free space in GB.
//
// It is sent as the event's MESSAGE, and the hub deliberately has no `customerMessages` entry for
// these two types: `FormatCustomerEmail` PREFERS the entry over the message, so a static template
// would discard the label and the figures — exactly why `offbox_enlarge_blocked` and
// `disk_health_degraded` have none either.
func Message(t Target, u Usage, band Band) string {
name := t.Label
if name == "" {
name = t.Path
}
if band == BandCritical {
return fmt.Sprintf(
"A(z) „%s” tároló kritikusan megtelt: %s szabad hely maradt (%s foglalt). "+
"A biztonsági mentések és az alkalmazások írásai bármikor meghiúsulhatnak. "+
"Kérjük, mielőbb szabadíts fel helyet: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót.",
name, hunGB(u.AvailGB), hunPercent(u.UsedPercent))
}
return fmt.Sprintf(
"A(z) „%s” tároló %s foglalt — %s szabad hely maradt. "+
"Kérjük, szabadíts fel helyet, mielőtt megtelik: törölj felesleges fájlokat, vagy csatlakoztass új meghajtót. "+
"Ha megtelik, a biztonsági mentések meghiúsulnak.",
name, hunPercent(u.UsedPercent), hunGB(u.AvailGB))
}
// hunGB formats a GB figure the Hungarian way — decimal COMMA, one decimal place. A "4.2 GB" in a
// Hungarian sentence reads as a typo to the customer.
func hunGB(gb float64) string {
if gb >= 1024 {
return strings.Replace(fmt.Sprintf("%.1f TB", gb/1024), ".", ",", 1)
}
return strings.Replace(fmt.Sprintf("%.1f GB", gb), ".", ",", 1)
}
func hunPercent(p float64) string { return fmt.Sprintf("%.0f%%", p) }
// SortTargets gives Check a deterministic order, so a multi-filesystem crossing produces its events
// in a stable sequence (tests, and a readable log).
func SortTargets(ts []Target) []Target {
sort.Slice(ts, func(i, j int) bool { return ts[i].Path < ts[j].Path })
return ts
}