controller v0.241.0: a bind-data app leans on off-site before its own unit; the hold names what the copy holds (R-479)
gates / gates (push) Successful in 13s

Operator ruling 2026-09-13. An app with classified binds walks second
drive -> off-site -> own unit (its unit holds no files); volume apps keep
2 -> 1 -> 3. RestoreHold.CopyHolds records what the chosen copy holds and
the sentence ends with it; older holds keep their tier-only sentence.
Tests on both halves; red-proof: a layout-blind order fails the bind case.
This commit is contained in:
2026-09-13 21:47:33 +02:00
parent 3013a1cc93
commit 3e813307cc
11 changed files with 208 additions and 7 deletions
@@ -346,7 +346,10 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
}
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
return true, fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate)
if h.CopyHolds != "" { // R-479: name what the copy holds
return true, fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
}
return true, fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
}
return true, fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
}
@@ -0,0 +1,96 @@
package backup
import (
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"io"
"log"
"strings"
"testing"
"time"
)
// R-479 (operator ruling 2026-09-13) — for an app whose data is bind-mounted files, the tiers are
// walked second drive → off-site → own unit, and the hold sentence says what the chosen copy holds.
func r479Manager(t *testing.T, bindApp bool) *Manager {
t.Helper()
m, _ := newOffboxManager(t)
prov := &offbox3aProvider{hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}, deployed: map[string]bool{"app": true}}
if bindApp {
prov.hdd["app"] = t.TempDir()
prov.binds["app"] = []ClassifiedBind{{ComposeBind: appbackup.ComposeBind{RelPath: "appdata/app"}, Class: ClassMandatory}}
prov.has["app"] = true
}
m.SetStackProvider(prov)
return m
}
// COMPANION RED-PROOF (REPORT.md): make UpdateTierOrderFor always return updateTierOrder — the
// bind-app case then picks the own unit ahead of off-site and this fails.
func TestR479_BindDataAppWalksSecondDriveOffsiteThenOwnUnit(t *testing.T) {
for _, tc := range []struct {
bind bool
want string
}{{true, "[2 3 1]"}, {false, "[2 1 3]"}} {
m := r479Manager(t, tc.bind)
if got := fmt.Sprint(m.UpdateTierOrderFor("app")); got != tc.want {
t.Errorf("bind=%v: order %s, want %s", tc.bind, got, tc.want)
}
if m.DataOutsideUnit("app") != tc.bind {
t.Errorf("bind=%v: DataOutsideUnit must follow the classified binds", tc.bind)
}
}
// The consequence, not only the mechanism: with Tier 2 absent and BOTH the unit and off-site
// holding a copy, a bind-data app leans on OFF-SITE; a unit app on its own unit.
for _, tc := range []struct {
bind bool
want int
}{{true, UpdateTierOffsite}, {false, UpdateTierLocal}} {
m := r479Manager(t, tc.bind)
m.updateTier2PointFn = noTier2
m.updateTier1PointsFn = tier1At(r475T0.Add(-time.Hour))
m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) {
return map[string]time.Time{"app": r475T0.Add(-2 * time.Hour)}, nil
}
p, ok, _ := m.UpdateRestorePoints(context.Background(), "app", nil)
if !ok || p.Tier != tc.want {
t.Errorf("bind=%v: chose tier %d, want %d", tc.bind, p.Tier, tc.want)
}
}
}
func TestR479_HoldSentenceNamesWhatTheCopyHolds(t *testing.T) {
at := time.Date(2026, 9, 13, 8, 0, 0, 0, time.UTC)
copyAt := time.Date(2026, 9, 13, 1, 30, 0, 0, time.UTC)
m := r479Manager(t, true)
m.logger = log.New(io.Discard, "", 0)
holds := m.UpdateCopyHolds("app", UpdateTierLocal)
if !strings.HasPrefix(holds, "csak a beállításokat") || !strings.HasSuffix(holds, "a fájlokat nem") {
t.Fatalf("a bind-data app's own unit holds settings and the database only, got %q", holds)
}
if h := m.UpdateCopyHolds("app", UpdateTierOffsite); !strings.Contains(h, "a fájlokat") || strings.Contains(h, "csak") {
t.Errorf("off-site holds the files too, got %q", h)
}
if err := m.HoldAfterFailedUpdateHolding("app", at, copyAt, UpdateTierLocal, holds); err != nil {
t.Fatal(err)
}
_, why := m.RestoreHoldFor("app")
want := fmt.Sprintf(UpdateHoldFmt, "app", "2026-09-13 10:00", "saját meghajtó", "2026-09-13 03:30", holds)
if why != want {
t.Errorf("hold text =\n%q\nwant\n%q", why, want)
}
if !strings.HasSuffix(why, "a fájlokat nem.") {
t.Errorf("the sentence must END with what the copy does not hold, got %q", why)
}
// A hold recorded WITHOUT the phrase (v0.239.0–v0.240.0) keeps the tier-only sentence.
if err := m.HoldAfterFailedUpdate("app2", at, copyAt, UpdateTierSecondDrive); err != nil {
t.Fatal(err)
}
_, why2 := m.RestoreHoldFor("app2")
if why2 != fmt.Sprintf(UpdateHoldTierFmt, "app2", "2026-09-13 10:00", "második meghajtó", "2026-09-13 03:30") {
t.Errorf("tier-only hold text = %q", why2)
}
}
@@ -77,7 +77,7 @@ func TestSlice4_UpdateHoldTextNamesTheTimeAndTheCopy(t *testing.T) {
}
held, why := m.RestoreHoldFor("bookstack")
// Budapest is UTC+2 in September: 08:00Z → 10:00, 01:30Z → 03:30.
want := fmt.Sprintf(UpdateHoldFmt, "bookstack", "2026-09-13 10:00", "második meghajtó", "2026-09-13 03:30")
want := fmt.Sprintf(UpdateHoldTierFmt, "bookstack", "2026-09-13 10:00", "második meghajtó", "2026-09-13 03:30")
if !held || why != want {
t.Errorf("hold text =\n%q\nwant\n%q", why, want)
}
+66 -2
View File
@@ -113,6 +113,56 @@ const (
// then off-site. The first tier holding a copy the caller ACCEPTS is chosen.
var updateTierOrder = []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite}
// updateTierOrderBindData (R-479, operator ruling 2026-09-13, v0.241.0) is the order for an app whose
// DATA lives in bind-mounted files outside its recovery unit: second drive, OFF-SITE, own unit. The own
// unit then holds the definition and the database dumps but not the files, so a route back that names
// it would restore settings and not data — measured on demo-hp with gokapi (v0.239.0: „a beállítások
// visszaálltak … adatot nem"). Off-site carries the mandatory file legs; it comes before the unit.
var updateTierOrderBindData = []int{UpdateTierSecondDrive, UpdateTierOffsite, UpdateTierLocal}
// DataOutsideUnit reports whether the app keeps data in bind-mounted files that the recovery unit does
// not hold — i.e. the app has classified binds. Nil provider or no binds → false (the unit holds the
// data: named volumes and database dumps). Pinned by TestR479_.
func (m *Manager) DataOutsideUnit(stackName string) bool {
if m == nil || m.stackProvider == nil {
return false
}
binds, has := m.stackProvider.GetStackClassifiedBinds(stackName)
return has && len(binds) > 0
}
// UpdateTierOrderFor is the tier order the update walks for this app (R-475 / R-479).
func (m *Manager) UpdateTierOrderFor(stackName string) []int {
if m.DataOutsideUnit(stackName) {
return updateTierOrderBindData
}
return updateTierOrder
}
// UpdateCopyHolds is the customer phrase for what a copy on `tier` holds for this app — the second half
// of the R-479 ruling: the hold sentence names WHAT the chosen copy holds, not only where it is.
func (m *Manager) UpdateCopyHolds(stackName string, tier int) string {
outside := m.DataOutsideUnit(stackName)
switch tier {
case UpdateTierLocal:
if outside {
return "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
case UpdateTierSecondDrive:
if outside {
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
case UpdateTierOffsite:
if outside {
return "a beállításokat, az adatbázist és a fájlokat tartalmazza"
}
return "a beállításokat, az adatbázist és az adatköteteket tartalmazza"
}
return ""
}
// UpdateTierLabel is a tier's name in the customer's hold sentence. "" for an unknown tier.
func UpdateTierLabel(tier int) string {
switch tier {
@@ -147,7 +197,7 @@ type UpdateTierPoint struct {
// makes "the age rule applies to whichever tier is chosen" one rule, not three (R-475 Scenario M).
func (m *Manager) UpdateRestorePoints(ctx context.Context, stackName string, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint) {
var seen []UpdateTierPoint
for _, tier := range updateTierOrder {
for _, tier := range m.UpdateTierOrderFor(stackName) {
p, ok := m.updateTierPoint(ctx, stackName, tier)
if !ok {
continue
@@ -411,6 +461,12 @@ func (m *Manager) WriteUpdateSafetyDump(ctx context.Context, stackName string) (
// (R-364). Since v0.239.0 (R-475) it names the tier: the copy may be on any of three, and each is
// restored from a different place on the Mentések page.
const UpdateHoldFmt = "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. " +
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. " +
"Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s."
// UpdateHoldTierFmt is the v0.239.0–v0.240.0 sentence, kept for a hold that recorded a tier but not
// what the copy holds (CopyHolds empty).
const UpdateHoldTierFmt = "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. " +
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. " +
"Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s."
@@ -448,6 +504,13 @@ func fmtHoldTime(rfc3339 string) string {
// that the next restart button will quietly start again. The caller logs it at ERROR and keeps the
// failure on the page.
func (m *Manager) HoldAfterFailedUpdate(stackName string, at time.Time, copyDate time.Time, copyTier int) error {
return m.HoldAfterFailedUpdateHolding(stackName, at, copyDate, copyTier, "")
}
// HoldAfterFailedUpdateHolding is HoldAfterFailedUpdate with the R-479 phrase for what the copy holds;
// "" records none (the tier-only sentence). The adapter in main.go computes the phrase with
// UpdateCopyHolds at hold time.
func (m *Manager) HoldAfterFailedUpdateHolding(stackName string, at time.Time, copyDate time.Time, copyTier int, copyHolds string) error {
if m == nil || m.settings == nil {
return fmt.Errorf("no settings wired — the update hold for %s cannot be persisted", stackName)
}
@@ -459,11 +522,12 @@ func (m *Manager) HoldAfterFailedUpdate(stackName string, at time.Time, copyDate
if !copyDate.IsZero() {
h.CopyDate = copyDate.UTC().Format(time.RFC3339)
h.CopyTier = copyTier
h.CopyHolds = copyHolds
}
if err := m.settings.SetRestoreHold(h); err != nil {
return fmt.Errorf("persisting the update hold for %s: %w", stackName, err)
}
m.logger.Printf("[WARN] [backup] %s is HELD STOPPED after a failed update (restore point: tier %d %q, %s)", stackName, h.CopyTier, UpdateTierLabel(h.CopyTier), h.CopyDate)
m.logger.Printf("[WARN] [backup] %s is HELD STOPPED after a failed update (restore point: tier %d %q, %s; holds: %q)", stackName, h.CopyTier, UpdateTierLabel(h.CopyTier), h.CopyDate, h.CopyHolds)
return nil
}
@@ -178,7 +178,7 @@ func TestR475_HoldTextNamesTheTier(t *testing.T) {
t.Fatal(err)
}
held, why := m.RestoreHoldFor("gokapi")
want := fmt.Sprintf(UpdateHoldFmt, "gokapi", "2026-09-13 10:00", label, "2026-09-13 03:30")
want := fmt.Sprintf(UpdateHoldTierFmt, "gokapi", "2026-09-13 10:00", label, "2026-09-13 03:30")
if !held || why != want {
t.Errorf("tier %d: hold text =\n%q\nwant\n%q", tier, why, want)
}
+4
View File
@@ -1615,6 +1615,10 @@ type RestoreHold struct {
// unit, 2 the second drive, 3 off-site. 0 means a hold written before the field existed; it keeps
// its original sentence (backup.UpdateHoldLegacyFmt). Only set for HoldReasonUpdateFailed.
CopyTier int `json:"copy_tier,omitempty"`
// CopyHolds (R-479, v0.241.0) is the customer phrase for WHAT the named copy holds — e.g. „a
// beállításokat, az adatbázist és a fájlokat tartalmazza" — recorded at hold time, because an app's
// data layout (bind-mounted files vs. named volumes) decides it and may not be readable later.
CopyHolds string `json:"copy_holds,omitempty"`
}
// Hold reasons. See RestoreHold.Reason.