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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user