v0.109.0: soft-quota gate + usage bar + offsite report status (SLICE 4)
QuotaGB rides the descriptor into OffboxTarget; RepoSizeBytes persisted from restic stats. Pre-run gate: >=100% refuses NEW backups (Hungarian notice + operator alert) but prune STILL runs (red-proofed) and restore is never gated; >=80% warns. /backups usage bar (quota>0 only). The hub report gains the non-secret offsite status object for the OffsiteChecker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -272,6 +273,147 @@ func TestOffbox_RestoreRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A (SLICE 4) — over the soft quota: the BACKUP step is refused (Hungarian error + status),
|
||||
// but the retention/prune step STILL RUNS (pruning is the customer's only way back under quota) and
|
||||
// restore is untouched. The prune-still-runs assert is the red-proofed core.
|
||||
func TestOffbox_QuotaRefusesBackupButPrunes(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 50
|
||||
o.RepoSizeBytes = 51 << 30 // 51 GiB — over the 50 GiB soft quota
|
||||
})
|
||||
var backups, forgets, restores int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "forget"):
|
||||
forgets++
|
||||
case contains(args, "restore"):
|
||||
restores++
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err == nil {
|
||||
t.Fatal("an over-quota run must report the refusal as an error")
|
||||
}
|
||||
if backups != 0 {
|
||||
t.Fatalf("NEW backups must be refused over quota, got %d backup call(s)", backups)
|
||||
}
|
||||
if forgets != 1 {
|
||||
t.Fatalf("prune MUST still run over quota (the only way back down — gating it deadlocks the customer), got %d", forgets)
|
||||
}
|
||||
tgt := sett.GetOffboxTarget()
|
||||
if tgt.LastStatus != "error" || !strings.Contains(tgt.LastError, "túllépte a tárhelykeretet") ||
|
||||
!strings.Contains(tgt.LastError, "51/50") {
|
||||
t.Fatalf("Hungarian over-quota status wrong: status=%q err=%q", tgt.LastStatus, tgt.LastError)
|
||||
}
|
||||
// restore is NEVER quota-gated: it must reach the runner even over quota
|
||||
_ = m.RestoreOffbox(context.Background(), "rallly", t.TempDir())
|
||||
if restores != 1 {
|
||||
t.Fatal("restore must NOT be quota-gated")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B (SLICE 4) — approaching the quota (≥80%, <100%): the run proceeds OK and the Hungarian
|
||||
// usage warning is set (visible on /backups).
|
||||
func TestOffbox_QuotaWarnsAt80Percent(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50 })
|
||||
var backups int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[{"id":"s1"}]`), nil
|
||||
case contains(args, "stats"):
|
||||
return []byte(fmt.Sprintf(`{"total_size":%d}`, int64(42)<<30)), nil // 42 GiB of 50 = 84%
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("an 84%% run must proceed, got %v", err)
|
||||
}
|
||||
tgt := sett.GetOffboxTarget()
|
||||
if tgt.LastStatus != "ok" {
|
||||
t.Fatalf("status must be ok at 84%%, got %q (%q)", tgt.LastStatus, tgt.LastError)
|
||||
}
|
||||
if !strings.Contains(tgt.LastWarning, "84%-át használja") || !strings.Contains(tgt.LastWarning, "42/50") {
|
||||
t.Fatalf("the 80%%+ usage warning must be set, got %q", tgt.LastWarning)
|
||||
}
|
||||
if tgt.RepoSizeBytes != int64(42)<<30 {
|
||||
t.Fatalf("RepoSizeBytes must persist from stats, got %d", tgt.RepoSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C (SLICE 4) — quota 0 (dedicated/unset): no soft gate regardless of size.
|
||||
func TestOffbox_QuotaZeroMeansNoGate(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 0
|
||||
o.RepoSizeBytes = 900 << 30 // enormous — must not matter
|
||||
})
|
||||
var backups int
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "backup"):
|
||||
backups++
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("quota-0 run must proceed, got %v", err)
|
||||
}
|
||||
if tgt := sett.GetOffboxTarget(); strings.Contains(tgt.LastWarning, "keret") || strings.Contains(tgt.LastError, "keret") {
|
||||
t.Fatalf("quota 0 must produce no quota warning/refusal: warn=%q err=%q", tgt.LastWarning, tgt.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// SLICE 4 — the report carries the non-secret offsite object when enabled; nil when unconfigured
|
||||
// (absent on the wire via omitempty → the hub checker skips, no false staleness on old/plain boxes).
|
||||
func TestOffboxReportStatus(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 50
|
||||
o.RepoSizeBytes = 45 << 30
|
||||
o.LastStatus = "ok"
|
||||
o.LastRun = "2026-07-09T20:00:00Z"
|
||||
o.SnapshotCount = 7
|
||||
})
|
||||
st := m.OffboxReportStatus()
|
||||
if st == nil || !st.Enabled || st.EscrowState != "escrowed" || st.QuotaGB != 50 ||
|
||||
st.RepoSizeBytes != int64(45)<<30 || st.LastStatus != "ok" || st.SnapshotCount != 7 {
|
||||
t.Fatalf("offsite report status wrong: %+v", st)
|
||||
}
|
||||
// unconfigured manager → nil
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
dataDir := t.TempDir()
|
||||
sett2, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = dataDir
|
||||
if got := NewManager(cfg, sett2, logger).OffboxReportStatus(); got != nil {
|
||||
t.Fatalf("unconfigured offbox must report nil (absent on the wire), got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call).
|
||||
func TestOffbox_SingleFlight(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
|
||||
Reference in New Issue
Block a user