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
+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
}