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