v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s

Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-30 10:31:55 +02:00
parent 1cfb1244d6
commit 6be6c53e29
38 changed files with 3097 additions and 46 deletions
+10
View File
@@ -117,6 +117,10 @@ type Manager struct {
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
offboxSizer func(path string) int64
// offsiteFit (decision 50, v0.283.0) — the last off-site size estimate against the quota, for the page.
offsiteFitMu sync.Mutex
offsiteFit OffsiteFit
offsiteFitRunning bool
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
//
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
@@ -958,6 +962,12 @@ func (m *Manager) DumpAppVolumesSafe(stackName string) error {
dumpErr := m.DumpAppVolumes(stackName)
if hs, ok := m.stackProvider.(interface{ WantsStopped(string) bool }); ok && hs.WantsStopped(stackName) {
// R-721: the household pressed Stop while the dump had the app down — it stays stopped.
m.logger.Printf("[INFO] [backup] %s NOT restarted after the volume dump: the household stopped it meanwhile", stackName)
m.appStop.End() // nothing is owed a restart — the household wants it stopped
return dumpErr
}
m.logger.Printf("[INFO] [backup] Restarting %s after volume dump", stackName)
startErr := m.stackProvider.StartStack(stackName)
if startErr != nil {
+1
View File
@@ -1301,6 +1301,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// Pre-run hygiene: clear any lock restic can prove stale before we start (cheap; the --remove-all
// crash-lock escalation lives in resticStep for the locks restic can't self-detect).
m.unlockStale(ctx, base, env)
m.RefreshOffsiteFit() // decision 50: the page's size estimate is taken before every push
var firstErr error
for _, stack := range apps {
src, ok := m.discoverOffboxUnit(stack)
+101
View File
@@ -0,0 +1,101 @@
package backup
import (
"sort"
"time"
)
// ── Decision 50 (2026-09-30, R-720): will the apps fit the off-site quota? Say so BEFORE a push ──────────
//
// With every new app in the off-site copy by default, a household with a big photo library can select more
// than the customer's quota holds. What the box already does is SAFE and stays (measured 2026-09-30):
// * over the quota, NEW pushes are refused and only the ruled retention runs (`forget --keep-daily 7
// --keep-weekly 4 --keep-monthly 6 --prune`, the same policy as every night — pinned by
// TestDecision50_OverQuotaDeletesNothingExtra), so no history is deleted to make room;
// * an app whose files would cross the quota is pushed UNIT-ONLY (settings + database), with a warning.
// What was missing is the page saying it BEFOREHAND, with names and sizes, so the household chooses which
// apps stay off-site. The estimate is taken at the start of every off-site run and, in the background, when
// the page is opened and the last one is older than offsiteFitMaxAge; the page only ever reads the cache
// (a `du` over a photo library is never run in a page request).
// OffsiteAppSize is one app's estimated off-site size: its recovery unit plus its mandatory files.
type OffsiteAppSize struct {
Stack string
Bytes int64
}
// OffsiteFit is the estimate the page shows.
type OffsiteFit struct {
At time.Time
Apps []OffsiteAppSize // largest first
TotalBytes int64
QuotaBytes int64 // 0 = no quota (dedicated box or the household's own NAS) → always fits
Fits bool
}
const offsiteFitMaxAge = 6 * time.Hour
// estimateOffsiteFit is the pure rule: sum the apps' sizes and compare against the quota. An estimate of 0
// for an app (no unit yet) counts as 0 — it cannot make the verdict "does not fit" on its own.
func estimateOffsiteFit(apps []string, sizeOf func(stack string) int64, quotaGB int, now time.Time) OffsiteFit {
f := OffsiteFit{At: now, QuotaBytes: int64(quotaGB) * offboxGiB}
for _, a := range apps {
b := sizeOf(a)
f.Apps = append(f.Apps, OffsiteAppSize{Stack: a, Bytes: b})
f.TotalBytes += b
}
sort.SliceStable(f.Apps, func(i, j int) bool { return f.Apps[i].Bytes > f.Apps[j].Bytes })
f.Fits = f.QuotaBytes <= 0 || f.TotalBytes <= f.QuotaBytes
return f
}
// offsiteAppBytes: the recovery unit on disk + the mandatory off-site capture set (what a push carries).
func (m *Manager) offsiteAppBytes(stack string) int64 {
var n int64
if src, ok := m.discoverOffboxUnit(stack); ok {
n += m.offboxSize()(src)
}
extra, _, _ := m.offboxCaptureSet(stack)
for _, p := range extra {
n += m.offboxSize()(p)
}
return n
}
// RefreshOffsiteFit measures now (blocking) and caches the result.
func (m *Manager) RefreshOffsiteFit() OffsiteFit {
t := m.settings.GetOffboxTarget()
quota := 0
if t != nil {
quota = t.QuotaGB
}
f := estimateOffsiteFit(m.settings.GetOffboxApps(), m.offsiteAppBytes, quota, time.Now())
m.offsiteFitMu.Lock()
m.offsiteFit = f
m.offsiteFitMu.Unlock()
if !f.Fits {
m.logger.Printf("[WARN] [offbox] the apps selected for off-site (~%s) do not fit the quota (%d GB) — the page asks the household to choose",
humanizeBytes(f.TotalBytes), quota)
}
return f
}
// OffsiteFitForPage returns the cached estimate and, when it is missing or older than offsiteFitMaxAge,
// starts ONE background refresh. The page never waits on a measurement.
func (m *Manager) OffsiteFitForPage() OffsiteFit {
m.offsiteFitMu.Lock()
f := m.offsiteFit
stale := f.At.IsZero() || time.Since(f.At) > offsiteFitMaxAge
start := stale && !m.offsiteFitRunning
if start {
m.offsiteFitRunning = true
}
m.offsiteFitMu.Unlock()
if start {
go func() {
defer func() { m.offsiteFitMu.Lock(); m.offsiteFitRunning = false; m.offsiteFitMu.Unlock() }()
m.RefreshOffsiteFit()
}()
}
return f
}
@@ -0,0 +1,81 @@
package backup
import (
"context"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Decision 50 / R-95, measured 2026-09-30: over the quota the box deletes NOTHING beyond the ruled retention.
// The consequence asserted: the forget an over-quota run executes carries EXACTLY the retention arguments of a
// normal run — no stricter keep, no --keep-last, no extra forget. (If someone "helpfully" made the over-quota
// path prune harder to get the household back under quota, it would delete history nobody chose to delete.)
// COMPANION RED-PROOF: change offboxPruneOnly's "--keep-daily", "7" to "1" → this test fails with the two
// argument lists printed.
func TestDecision50_OverQuotaDeletesNothingExtra(t *testing.T) {
forgetArgs := func(over bool) []string {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.QuotaGB = 50
if over {
o.RepoSizeBytes = 51 << 30
} else {
o.RepoSizeBytes = 1 << 30
}
})
var got []string
n := 0
m.SetOffboxSizer(func(string) int64 { return 0 })
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{}`), nil
case contains(args, "forget"):
n++
for i, a := range args {
if a == "forget" {
got = append([]string{}, args[i:]...)
}
}
case contains(args, "stats"):
return []byte(`{"total_size":123}`), nil
case contains(args, "snapshots"):
return []byte(`[]`), nil
}
return nil, nil
})
_ = m.RunOffboxBackup(context.Background())
if n != 1 {
t.Fatalf("over=%v: expected exactly one forget, got %d", over, n)
}
return got
}
normal, over := forgetArgs(false), forgetArgs(true)
if strings.Join(normal, " ") != strings.Join(over, " ") {
t.Fatalf("the over-quota forget differs from the normal retention — it would delete history nobody chose to delete:\n normal: %v\n over: %v", normal, over)
}
}
// The size rule: sum against the quota, largest first; no quota → always fits.
// COMPANION RED-PROOF: compare with < instead of <= (or drop the QuotaBytes<=0 arm) → a case fails.
func TestDecision50_EstimateOffsiteFit(t *testing.T) {
sizes := map[string]int64{"immich": 80 << 30, "nextcloud": 30 << 30, "bookstack": 1 << 20}
sizeOf := func(s string) int64 { return sizes[s] }
now := time.Now()
f := estimateOffsiteFit([]string{"bookstack", "immich", "nextcloud"}, sizeOf, 100, now)
if f.Fits {
t.Fatalf("110 GiB into 100 GiB reported as fitting: %+v", f)
}
if f.Apps[0].Stack != "immich" || f.Apps[1].Stack != "nextcloud" {
t.Fatalf("largest first expected: %+v", f.Apps)
}
if g := estimateOffsiteFit([]string{"immich"}, func(string) int64 { return 100 << 30 }, 100, now); !g.Fits {
t.Fatal("exactly the quota must fit")
}
if g := estimateOffsiteFit([]string{"immich"}, sizeOf, 0, now); !g.Fits {
t.Fatal("no quota (own NAS / dedicated box) must always fit")
}
}
@@ -0,0 +1,40 @@
package backup
import "testing"
// stopDuringDump is the real DumpAppVolumesSafe's provider: the household presses Stop while the dump holds
// the app down (between StopStack and the restart).
type stopDuringDump struct {
suppressWatchProvider
wantsStopped bool
started int
}
func (p *stopDuringDump) StopStack(n string) error {
p.wantsStopped = true // the household's Stop lands during the dump
return p.suppressWatchProvider.StopStack(n)
}
func (p *stopDuringDump) StartStack(n string) error {
p.started++
return p.suppressWatchProvider.StartStack(n)
}
func (p *stopDuringDump) WantsStopped(string) bool { return p.wantsStopped }
// R-721 (v0.283.0): the nightly volume dump does not start an app the household stopped while it was down.
// COMPANION RED-PROOF: remove the WantsStopped check in DumpAppVolumesSafe → started=1.
func TestR721_VolumeDumpKeepsTheHouseholdsStop(t *testing.T) {
p := &stopDuringDump{}
m := newSuppressManager(t, &p.suppressWatchProvider)
m.stackProvider = p
if err := m.DumpAppVolumesSafe("actualbudget"); err != nil {
t.Fatalf("DumpAppVolumesSafe: %v", err)
}
if p.started != 0 {
t.Fatalf("the dump started the app %d time(s) after the household stopped it (R-721)", p.started)
}
// The crash marker is what a restart after a controller crash reads: it must owe nothing. (The short
// alarm-grace set is a different thing and stays by design.)
if got := m.appStop.HeldStacks(); len(got) != 0 {
t.Fatalf("the stop marker still holds %v — the next startup would restart an app the household stopped", got)
}
}