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
+22 -7
View File
@@ -425,22 +425,37 @@ func (p *Provisioner) SetOffsiteFrozen(ctx context.Context, customerID string, f
// resource) is cleared, so re-onboarding re-provisions fresh against the retained choice. No-op-safe:
// an absent/empty offsite block returns the input unchanged.
func ClearProvisionedDescriptor(configJSON string) (string, error) {
cur, err := ReadDescriptor(configJSON)
if err != nil {
return "", err
}
if cur == nil {
return configJSON, nil // no offsite block — nothing to clear
}
cleared := &Descriptor{Enabled: cur.Enabled, Type: cur.Type, QuotaGB: cur.QuotaGB, BoxType: cur.BoxType}
return MergeDescriptor(configJSON, cleared)
}
// ReadDescriptor extracts the non-secret offsite Descriptor from a customer's ConfigJSON, or (nil, nil)
// when there is no offsite block (never provisioned). This is the AUTHORITATIVE tier/quota source — the
// pool-box Σ(quota) aggregate (v0.64.0, R-5) and the RESET clear both read through it, NEVER the report
// echo (a stale/absent report would undercount the sold promises; ConfigJSON is the operator's intent).
func ReadDescriptor(configJSON string) (*Descriptor, error) {
obj := map[string]json.RawMessage{}
if strings.TrimSpace(configJSON) != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &obj); err != nil {
return "", fmt.Errorf("offsite: parse config_json: %w", err)
return nil, fmt.Errorf("offsite: parse config_json: %w", err)
}
}
raw, ok := obj["offsite"]
if !ok {
return configJSON, nil // no offsite block — nothing to clear
return nil, nil
}
var cur Descriptor
if err := json.Unmarshal(raw, &cur); err != nil {
return "", fmt.Errorf("offsite: parse offsite descriptor: %w", err)
var d Descriptor
if err := json.Unmarshal(raw, &d); err != nil {
return nil, fmt.Errorf("offsite: parse offsite descriptor: %w", err)
}
cleared := &Descriptor{Enabled: cur.Enabled, Type: cur.Type, QuotaGB: cur.QuotaGB, BoxType: cur.BoxType}
return MergeDescriptor(configJSON, cleared)
return &d, nil
}
func MergeDescriptor(configJSON string, d *Descriptor) (string, error) {