hub v0.64.0 — offsite pool-box aggregate: fill, oversubscription, per-customer bars, operator alert (R-5)

The operator sees the shared pool box's real state on the hub: total box fill vs
capacity, Σ(shared soft quotas) vs capacity (the oversubscription ratio), per-customer
usage/quota bars, and a box-level operator alert (fill % + oversub ratio) on the existing
dispatcher's operator channel. Per-customer fill alerts already existed; the box-level
aggregate was the gap. READ-ONLY against Hetzner (GET only).

Phase-0 probe (gate PASSED): the live pool box 611714 returns capacity via
storage_box_type.size (1 TiB / bx11) and usage via a stats object (size/size_data/
size_snapshots), all bytes; our token reads it (200).

- hetznerapi: additive StorageBoxType + StorageBoxStats on StorageBox (no existing field/
  method changed); fake carries them + a GetBoxCalls counter; golden decode test.
- monitor.OffsiteBoxChecker: OffsiteChecker-sibling for the box; fetch-throttled (1 GET/
  15min), cached BoxSnapshot, escalation-only + recovery re-arm. FILL (used/capacity 80/90)
  + OVERSUB (Σ shared+enabled quotas / capacity, 2.0x) — independent. Σ from the ConfigJSON
  Descriptor (offsite.ReadDescriptor, new), never the report echo; dedicated+disabled
  excluded. Scope "pool-box" -> operator channel only, no SaveEvent. Failed fetch keeps the
  last snapshot degraded; missing data never becomes 0% and never transitions a band.
- config: Alerting.OffsiteBoxFill{Warn,Crit}Percent + OffsiteOversubWarnRatio (80/90/2.0
  defaults; thresholds pending Viktor's ruling). Constructed in the HETZNER_TOKEN branch,
  60s sweep, snapshot handed to the web server.
- web: Offsite-tab panel (fill bar, Σ+ratio, per-customer usage/quota rows) + a compact
  dashboard tile; reads the cached snapshot only, never fetches; nil -> "not configured".

Tests: 10 new + 4 red-proofs (throttle, Σ filter, escalation-only, failed-fetch honesty),
all confirmed red then restored. go build/vet/test all pass; hub confirm gate OK.
This commit is contained in:
2026-07-17 20:15:34 +02:00
parent 85a14192e7
commit 4bb2df0dc4
17 changed files with 1034 additions and 17 deletions
+256
View File
@@ -0,0 +1,256 @@
package monitor
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// OffsiteBoxChecker (v0.64.0, R-5) watches the SHARED POOL BOX as a whole — the aggregate the
// per-customer OffsiteChecker cannot see. Two independent box-level signals, one checker, mirroring the
// OffsiteChecker/StorageFillChecker shape (escalation-only emit, recovery re-arms silently, born/persistent
// via an unseeded band). It is DELIBERATELY separate from OffsiteChecker — a different data source (the
// Hetzner unified API, not the controller report) and a customer-less scope.
//
// - FILL: box used vs box capacity (from the box TYPE's size, never stats.size) at warn 80% / crit 90%.
// - OVERSUBSCRIPTION: Σ(shared+enabled customers' soft quotas) vs capacity, at a warn ratio (2.0×). The
// number that says how far the sold promises exceed the disk. Independent of fill (both can fire).
//
// Fetch is throttled (boxFetchInterval); between refreshes Check() serves the cached snapshot. A failed
// fetch keeps the last snapshot (marked Degraded) and never drives a band transition — absence of data is
// NEVER treated as 0%. Events carry the customer-less scope "pool-box": the dispatcher uses it as a
// cooldown key + display string only, and processCustomer no-ops on it (no prefs → no customer email;
// verified against dispatcher.go), so a box event reaches ONLY the operator channel. NO SaveEvent — the
// scope has no customer row to key it to.
type OffsiteBoxChecker struct {
api hetznerapi.CloudAPI
boxID int64
store *store.Store
fillWarn float64
fillCrit float64
oversubWarn float64
onEvent EventNotifyFunc
logger *log.Logger
now func() time.Time // injectable clock (tests)
mu sync.Mutex
snap BoxSnapshot
haveSnap bool
lastFetch time.Time
fillBand string // "" until first eval → born/persistent (a born-breach emits on the first sweep)
oversubBand string
}
// offsiteBoxScope is the customer-less cooldown/display key for pool-box events.
const offsiteBoxScope = "pool-box"
const (
defaultOffsiteBoxFillWarnPercent = 80.0
defaultOffsiteBoxFillCritPercent = 90.0
defaultOffsiteOversubWarnRatio = 2.0
// boxFetchInterval throttles the Hetzner read: Check() runs on the 60 s sweep but fetches at most
// once per this window (≈4 API reads/hour, not ≈60). A manual page load never forces a fetch.
boxFetchInterval = 15 * time.Minute
)
// BoxSnapshot is the cached pool-box aggregate the web layer renders. All sizes are BYTES. FillBand /
// OversubBand carry the checker's own band verdict so the UI colors bars EXACTLY as the alerts fire (no
// threshold re-derivation in the web layer).
type BoxSnapshot struct {
CapacityBytes int64
UsedBytes int64
DataBytes int64
SnapshotBytes int64
FillPercent float64
SumQuotaGB int64
Ratio float64 // Σ(shared quota bytes) / capacity
FillBand string // "" | ok | warning | critical
OversubBand string // "" | ok | warning
FetchedAt time.Time
BoxType string
Degraded bool // last fetch failed OR capacity==0 → render stale; no alert transitions
}
// NewOffsiteBoxChecker builds the checker. Invalid thresholds fall back to the documented defaults
// (warn 80% / crit 90% / oversub 2.0×). Nothing is seeded → the first Check that finds a breach emits
// (born/persistent; the dispatcher's 1 h cooldown dedups a hub restart).
func NewOffsiteBoxChecker(api hetznerapi.CloudAPI, boxID int64, s *store.Store, fillWarn, fillCrit, oversubWarn float64, onEvent EventNotifyFunc, logger *log.Logger) *OffsiteBoxChecker {
if fillWarn <= 0 || fillWarn >= 100 {
fillWarn = defaultOffsiteBoxFillWarnPercent
}
if fillCrit <= 0 || fillCrit > 100 || fillCrit <= fillWarn {
fillCrit = defaultOffsiteBoxFillCritPercent
}
if fillCrit <= fillWarn {
fillWarn, fillCrit = defaultOffsiteBoxFillWarnPercent, defaultOffsiteBoxFillCritPercent
}
if oversubWarn <= 0 {
oversubWarn = defaultOffsiteOversubWarnRatio
}
c := &OffsiteBoxChecker{
api: api, boxID: boxID, store: s,
fillWarn: fillWarn, fillCrit: fillCrit, oversubWarn: oversubWarn,
onEvent: onEvent, logger: logger, now: time.Now,
}
logger.Printf("[INFO] Offsite pool-box checker initialized: box=%d fill warn=%.0f%% crit=%.0f%%, oversub warn=%.2fx, refresh %s",
boxID, fillWarn, fillCrit, oversubWarn, boxFetchInterval)
return c
}
// Check runs on the 60 s sweep. It fetches (throttled), recomputes the snapshot, and emits on each
// band escalation. Recovery de-escalation re-arms silently. Degraded data drives no transition.
func (c *OffsiteBoxChecker) Check() {
c.mu.Lock()
defer c.mu.Unlock()
// Fetch throttle: between refreshes serve the cache — bands only move on a fresh fetch.
if c.haveSnap && c.now().Sub(c.lastFetch) < boxFetchInterval {
return
}
c.lastFetch = c.now() // updated even on failure → a failing API is retried once per window, not per sweep
box, err := c.api.GetStorageBox(context.Background(), c.boxID)
if err != nil {
c.logger.Printf("[WARN] Offsite pool-box: refresh failed (keeping last snapshot): %v", err)
if c.haveSnap {
c.snap.Degraded = true // serve the last-known, visibly stale; NO band transition (D)
}
return
}
sumQuotaGB, qerr := c.sumSharedQuotaGB()
if qerr != nil {
c.logger.Printf("[WARN] Offsite pool-box: quota sum failed (ratio omitted this cycle): %v", qerr)
sumQuotaGB = 0 // a wrong sum is worse than a nominal 0.00× — never emit oversub off bad data
}
capacity := box.StorageBoxType.Size
used := box.Stats.Size
snap := BoxSnapshot{
CapacityBytes: capacity, UsedBytes: used,
DataBytes: box.Stats.SizeData, SnapshotBytes: box.Stats.SizeSnapshots,
SumQuotaGB: sumQuotaGB, FetchedAt: c.now(), BoxType: box.StorageBoxType.Name,
}
if capacity > 0 {
snap.FillPercent = float64(used) * 100 / float64(capacity)
snap.Ratio = float64(sumQuotaGB<<30) / float64(capacity)
snap.FillBand = bandForPercent(snap.FillPercent, c.fillWarn, c.fillCrit)
snap.OversubBand = bandOK
if snap.Ratio >= c.oversubWarn {
snap.OversubBand = bandWarning // ratio is single-threshold — no "critical"
}
} else {
snap.Degraded = true // zero capacity (initializing box / probe surprise) — guard the division
}
c.snap = snap
c.haveSnap = true
if snap.Degraded {
return // no band transitions on degraded data
}
// FILL band (escalation-only; recovery re-arms because bandOK has rank 0).
if bandRank(snap.FillBand) > bandRank(c.fillBand) {
c.emitFill(snap, snap.FillBand)
}
c.fillBand = snap.FillBand
// OVERSUBSCRIPTION band — independent signal (both can fire in one sweep; neither masks the other).
if bandRank(snap.OversubBand) > bandRank(c.oversubBand) {
c.emitOversub(snap)
}
c.oversubBand = snap.OversubBand
}
// Snapshot returns the current cached aggregate + whether one exists (mutex copy-out). The web layer's
// only source — it NEVER fetches from Hetzner in the request path.
func (c *OffsiteBoxChecker) Snapshot() (BoxSnapshot, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.snap, c.haveSnap
}
// FillState / OversubState expose the current bands (tests).
func (c *OffsiteBoxChecker) FillState() string { c.mu.Lock(); defer c.mu.Unlock(); return orUnknown(c.fillBand) }
func (c *OffsiteBoxChecker) OversubState() string { c.mu.Lock(); defer c.mu.Unlock(); return orUnknown(c.oversubBand) }
func orUnknown(b string) string {
if b == "" {
return "unknown"
}
return b
}
// sumSharedQuotaGB sums the soft quotas of every ENABLED, SHARED customer from the authoritative
// ConfigJSON descriptor (never the report echo). Dedicated + disabled customers are excluded.
func (c *OffsiteBoxChecker) sumSharedQuotaGB() (int64, error) {
cfgs, err := c.store.ListCustomerConfigs()
if err != nil {
return 0, err
}
var sum int64
for _, cfg := range cfgs {
d, derr := offsite.ReadDescriptor(cfg.ConfigJSON)
if derr != nil || d == nil {
continue
}
if d.Enabled && d.Type == "shared" && d.QuotaGB > 0 {
sum += int64(d.QuotaGB)
}
}
return sum, nil
}
func (c *OffsiteBoxChecker) emitFill(snap BoxSnapshot, band string) {
var severity, message string
switch band {
case bandCritical:
severity = "critical"
message = fmt.Sprintf("Offsite pool box %.0f%% full (%s of %s) — approaching capacity; add capacity or reduce retention before customer offsite runs start failing",
snap.FillPercent, fmtSize(snap.UsedBytes), fmtSize(snap.CapacityBytes))
case bandWarning:
severity = "warning"
message = fmt.Sprintf("Offsite pool box %.0f%% full (%s of %s) — the shared pool is filling",
snap.FillPercent, fmtSize(snap.UsedBytes), fmtSize(snap.CapacityBytes))
default:
return
}
details, _ := json.Marshal(map[string]any{
"scope": offsiteBoxScope, "capacity_bytes": snap.CapacityBytes, "used_bytes": snap.UsedBytes,
"fill_percent": snap.FillPercent, "warn_percent": c.fillWarn, "crit_percent": c.fillCrit,
})
c.logger.Printf("[INFO] Offsite pool-box fill: %.0f%% (%s)", snap.FillPercent, band)
// NO SaveEvent — the scope is customer-less; emit straight to the operator dispatcher.
if c.onEvent != nil {
c.onEvent(offsiteBoxScope, "offsite_box_fill", severity, message, string(details), "hub")
}
}
func (c *OffsiteBoxChecker) emitOversub(snap BoxSnapshot) {
message := fmt.Sprintf("Offsite pool oversubscription %.2fx — Σ(shared soft quotas) %d GB against %s capacity exceeds the %.2fx threshold; a full-usage scenario would overrun the pool",
snap.Ratio, snap.SumQuotaGB, fmtSize(snap.CapacityBytes), c.oversubWarn)
details, _ := json.Marshal(map[string]any{
"scope": offsiteBoxScope, "sum_quota_gb": snap.SumQuotaGB, "capacity_bytes": snap.CapacityBytes,
"ratio": snap.Ratio, "oversub_warn_ratio": c.oversubWarn,
})
c.logger.Printf("[INFO] Offsite pool-box oversub: %.2fx (Σquota %d GB)", snap.Ratio, snap.SumQuotaGB)
if c.onEvent != nil {
c.onEvent(offsiteBoxScope, "offsite_box_oversub", "warning", message, string(details), "hub")
}
}
// fmtSize renders bytes as GB (one decimal) below 1 TB, TB above — for the operator email/log only.
func fmtSize(b int64) string {
const gb = 1 << 30
if b >= 1<<40 {
return fmt.Sprintf("%.2f TB", float64(b)/float64(int64(1)<<40))
}
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
}
+228
View File
@@ -0,0 +1,228 @@
package monitor
import (
"fmt"
"math"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const gib = int64(1) << 30
// boxWith builds a fake StorageBox with the given capacity + used bytes (data-only, no snapshots).
func boxWith(capacityBytes, usedBytes int64) hetznerapi.StorageBox {
return hetznerapi.StorageBox{
ID: 1, Status: "active",
StorageBoxType: hetznerapi.StorageBoxType{Name: "bx11", Size: capacityBytes},
Stats: hetznerapi.StorageBoxStats{Size: usedBytes, SizeData: usedBytes},
}
}
// seedOffsiteCfg writes a customer config whose ConfigJSON carries an offsite descriptor.
func seedOffsiteCfg(t *testing.T, st *store.Store, id string, enabled bool, typ string, quotaGB int) {
t.Helper()
cfgJSON := fmt.Sprintf(`{"offsite":{"enabled":%v,"type":%q,"quota_gb":%d}}`, enabled, typ, quotaGB)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, APIKey: "k-" + id, RetrievalPassword: "p", ConfigJSON: cfgJSON}); err != nil {
t.Fatalf("seed config %s: %v", id, err)
}
}
func noEvent(_, _, _, _, _, _ string) {}
// Scenario A — fetch is throttled: ~4 API reads over an hour of 60 s sweeps, not ~60.
// Red-proof (i): drop the throttle guard in Check() → GetBoxCalls ≈ 60 → this fails.
func TestOffsiteBox_FetchThrottle(t *testing.T) {
st := newDiskStore(t)
fake := hetznerapi.NewFake()
fake.Boxes[1] = boxWith(1<<40, 300*gib) // 1 TiB, 300 GiB used
var cur time.Time
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, noEvent, quietLog())
c.now = func() time.Time { return cur }
base := time.Now().UTC()
for i := 0; i < 60; i++ { // 60 sweeps at 60 s spacing = one simulated hour
cur = base.Add(time.Duration(i) * 60 * time.Second)
c.Check()
}
if fake.GetBoxCalls < 3 || fake.GetBoxCalls > 5 {
t.Fatalf("GetStorageBox called %d times over an hour of 60 s sweeps, want ≈4 (fetch-throttled to 15 min)", fake.GetBoxCalls)
}
snap, ok := c.Snapshot()
if !ok || snap.CapacityBytes != 1<<40 || snap.UsedBytes != 300*gib {
t.Fatalf("snapshot wrong: %+v ok=%v", snap, ok)
}
}
// Scenario B — oversubscription math: Σ = shared+enabled only (A+B); dedicated (C) and disabled (D)
// excluded; ratio = Σquota / CAPACITY (not used).
// Red-proof (ii): include dedicated/disabled in the sum → this fails.
func TestOffsiteBox_OversubMath(t *testing.T) {
st := newDiskStore(t)
seedOffsiteCfg(t, st, "a", true, "shared", 500)
seedOffsiteCfg(t, st, "b", true, "shared", 700)
seedOffsiteCfg(t, st, "c", true, "dedicated", 9999) // excluded: dedicated
seedOffsiteCfg(t, st, "d", false, "shared", 300) // excluded: disabled
fake := hetznerapi.NewFake()
fake.Boxes[1] = boxWith(1024*gib, 100*gib) // capacity 1024 GB
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, noEvent, quietLog())
c.Check()
snap, _ := c.Snapshot()
if snap.SumQuotaGB != 1200 {
t.Fatalf("Σquota = %d GB, want 1200 (A 500 + B 700 only)", snap.SumQuotaGB)
}
want := 1200.0 / 1024.0
if math.Abs(snap.Ratio-want) > 0.001 {
t.Fatalf("ratio = %.4f, want %.4f (Σquota/capacity)", snap.Ratio, want)
}
}
// captureEvents records (eventType, severity, customerID) per emit.
type capturedBox struct{ typ, sev, cust []string }
func (c *capturedBox) fn(cust, et, sev, _, _, _ string) {
c.cust = append(c.cust, cust)
c.typ = append(c.typ, et)
c.sev = append(c.sev, sev)
}
// Scenario C (fill) — escalation-only, in-band no re-emit, recovery re-arms. Every emit carries the
// "pool-box" scope. Red-proof (iii): remove the escalation-only guard → C3 fails.
func TestOffsiteBox_FillBands(t *testing.T) {
st := newDiskStore(t)
fake := hetznerapi.NewFake()
cap := int64(1000) * gib
fake.Boxes[1] = boxWith(cap, 750*gib) // 75%
ev := &capturedBox{}
var cur time.Time
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, ev.fn, quietLog())
c.now = func() time.Time { return cur }
base := time.Now().UTC()
step := func(usedGiB int64) {
cur = cur.Add(16 * time.Minute) // bust the 15-min throttle each step
fake.Boxes[1] = boxWith(cap, usedGiB*gib)
c.Check()
}
cur = base
c.Check() // C1: 75% → no emit
if len(ev.typ) != 0 {
t.Fatalf("C1 75%% must not emit, got %v", ev.typ)
}
step(820) // C2: 82% → one warning
if count(ev.typ, "offsite_box_fill") != 1 || ev.sev[0] != "warning" {
t.Fatalf("C2 82%% must warn once, got typ=%v sev=%v", ev.typ, ev.sev)
}
step(850) // C3: 85% same band → no second emit
if count(ev.typ, "offsite_box_fill") != 1 {
t.Fatalf("C3 same-band must NOT re-emit, got %d", count(ev.typ, "offsite_box_fill"))
}
step(920) // C4: 92% → escalate to critical
if count(ev.typ, "offsite_box_fill") != 2 || ev.sev[len(ev.sev)-1] != "critical" {
t.Fatalf("C4 92%% must escalate to critical, got typ=%v sev=%v", ev.typ, ev.sev)
}
step(700) // C5: 70% → recovery re-arm (no emit)
if c.FillState() != bandOK {
t.Fatalf("C5 recovery must re-arm to ok, got %s", c.FillState())
}
if count(ev.typ, "offsite_box_fill") != 2 {
t.Fatalf("C5 recovery must be silent, got %d emits", count(ev.typ, "offsite_box_fill"))
}
step(920) // re-breach after recovery → emits again
if count(ev.typ, "offsite_box_fill") != 3 {
t.Fatalf("re-breach after recovery must emit again, got %d", count(ev.typ, "offsite_box_fill"))
}
for _, cust := range ev.cust {
if cust != "pool-box" {
t.Fatalf("every emit must carry the pool-box scope, got %q", cust)
}
}
}
// Scenario C6 — oversubscription is an INDEPENDENT signal: it fires on ratio alone even when fill is
// nominal, with its own event type.
func TestOffsiteBox_OversubIndependent(t *testing.T) {
st := newDiskStore(t)
seedOffsiteCfg(t, st, "a", true, "shared", 2300) // Σ 2300 GB
fake := hetznerapi.NewFake()
fake.Boxes[1] = boxWith(1000*gib, 500*gib) // 50% fill (nominal), ratio 2.3×
ev := &capturedBox{}
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, ev.fn, quietLog())
c.Check()
if count(ev.typ, "offsite_box_oversub") != 1 {
t.Fatalf("2.3× oversub must warn once, got %v", ev.typ)
}
if count(ev.typ, "offsite_box_fill") != 0 {
t.Fatalf("fill is nominal (50%%) — no fill emit, got %v", ev.typ)
}
if ev.cust[0] != "pool-box" {
t.Fatalf("oversub emit scope = %q, want pool-box", ev.cust[0])
}
}
// Scenario D — API failure honesty: the last snapshot is KEPT (marked degraded), no band transition,
// and a failed fetch NEVER zeroes the snapshot (the recovery re-arm must not trigger off missing data).
// Red-proof (iv): let a failed fetch zero the snapshot → this fails.
func TestOffsiteBox_FailedFetchHonesty(t *testing.T) {
st := newDiskStore(t)
fake := hetznerapi.NewFake()
cap := int64(1000) * gib
fake.Boxes[1] = boxWith(cap, 920*gib) // 92% → critical
ev := &capturedBox{}
var cur time.Time
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, ev.fn, quietLog())
c.now = func() time.Time { return cur }
base := time.Now().UTC()
cur = base
c.Check() // establishes a 92% critical snapshot + emit
if count(ev.typ, "offsite_box_fill") != 1 {
t.Fatalf("setup: want one critical emit, got %v", ev.typ)
}
before, _ := c.Snapshot()
// Now the API fails.
fake.FailGetBox = fmt.Errorf("hetzner timeout")
cur = cur.Add(16 * time.Minute)
c.Check()
after, ok := c.Snapshot()
if !ok {
t.Fatal("a failed fetch must KEEP the last snapshot, not drop it")
}
if after.UsedBytes != before.UsedBytes || after.CapacityBytes != before.CapacityBytes {
t.Fatalf("a failed fetch must not alter the last-known values (before %d/%d, after %d/%d)",
before.UsedBytes, before.CapacityBytes, after.UsedBytes, after.CapacityBytes)
}
if !after.Degraded {
t.Fatal("a failed fetch must mark the served snapshot Degraded (visible staleness)")
}
if c.FillState() != bandCritical {
t.Fatalf("a failed fetch must NOT transition the band (missing data ≠ 0%%), got %s", c.FillState())
}
// No new emit on the failure.
if count(ev.typ, "offsite_box_fill") != 1 {
t.Fatalf("a failed fetch must not emit, got %d total", count(ev.typ, "offsite_box_fill"))
}
}
// Not-configured / zero-capacity guard: a zero-capacity box marks degraded, never divides, never alerts.
func TestOffsiteBox_ZeroCapacityGuard(t *testing.T) {
st := newDiskStore(t)
fake := hetznerapi.NewFake()
fake.Boxes[1] = boxWith(0, 0) // initializing box — no capacity yet
ev := &capturedBox{}
c := NewOffsiteBoxChecker(fake, 1, st, 80, 90, 2.0, ev.fn, quietLog())
c.Check()
snap, ok := c.Snapshot()
if !ok || !snap.Degraded {
t.Fatalf("zero capacity must yield a degraded snapshot, got %+v", snap)
}
if len(ev.typ) != 0 {
t.Fatalf("zero capacity must not alert, got %v", ev.typ)
}
}