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
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:
@@ -1,3 +1,28 @@
|
||||
## v0.241.0 — a bind-data app leans on off-site before its own unit, and the hold says what the copy holds (2026-09-13, R-479)
|
||||
|
||||
**MinAgent: 0.129.0** (unchanged)
|
||||
|
||||
**Operator ruling 2026-09-13 (R-479).** For an app whose data lives in bind-mounted files, the
|
||||
recovery unit holds the definition and the database dumps but not the files, so a route back that
|
||||
names it restores settings and not data (measured on demo-hp with gokapi: „a beállítások
|
||||
visszaálltak … adatot nem"). Two changes:
|
||||
|
||||
- **The tier order depends on the data layout.** An app with classified binds (`DataOutsideUnit`)
|
||||
walks second drive → **off-site** → own unit (`updateTierOrderBindData`); an app whose data is in
|
||||
named volumes keeps second drive → own unit → off-site. `UpdateTierOrderFor` is the one place that
|
||||
decides; `UpdateRestorePoints` reads it.
|
||||
- **The hold sentence names what the copy holds.** `RestoreHold.CopyHolds`, computed at hold time by
|
||||
`UpdateCopyHolds` from the app's layout and the chosen tier, e.g. *„… ebből a biztonsági mentésből:
|
||||
saját meghajtó, 2026-09-13 17:34 — ez a másolat csak a beállításokat és az adatbázist tartalmazza,
|
||||
a fájlokat nem."* A hold written by v0.239.0–v0.240.0 (tier, no phrase) keeps its sentence
|
||||
(`UpdateHoldTierFmt`).
|
||||
|
||||
**TESTS.** `internal/backup/r479_tier_order_test.go` — the order per layout, the CONSEQUENCE (Tier 2
|
||||
absent, unit and off-site both fresh: a bind app leans on off-site, a volume app on its unit), the
|
||||
phrases, the sentence verbatim, the tier-only sentence for older holds; the adapter wiring test
|
||||
demands the phrase travel through `HoldAfterFailedUpdateHolding`. **Red-proof:** making the order
|
||||
layout-blind fails the consequence case (felhom.eu `audits/v0241-2026-09-13/`).
|
||||
|
||||
## v0.240.0 — seven defects the any-tier proof and the first nightly rotation found (2026-09-13, R-486 / R-484 / R-485 / R-480 / R-477 / R-478 / R-474)
|
||||
|
||||
**MinAgent: 0.129.0** (unchanged)
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
| `Manager.UpdatePreflight` / `StartGuardedUpdate` / `RecoverUpdates` / `ResumeInterruptedUpdates` (v0.237.0) | controller/internal/stacks/update.go | `UpdatePreflight(name) *UpdateRefusal`; `StartGuardedUpdate(name) error` | THE update — refusals, then a 202 job with phases on `Stack.Updating/UpdatePhase/UpdateError` | **Never report an update complete before health is known (R-443).** Every cheap refusal runs BEFORE the intent write. Safety dump BEFORE the pin moves; pin BEFORE pull; pull failure → pin back; health failure → stop + HOLD, pin stays. Journal-before-mutate (`update-journal.json`); `RecoverUpdates` MUST run before the boot sweep and `ResumeInterruptedUpdates` AFTER `SetUpdateGuards`. Seams: `updateComposeFn`, `updateHealthFn`, `updateMemoryFn`, `updateDiskFreeFn`, `updateNowFn` (R-457: the age check and the test read ONE clock). Unwired guards ⇒ every update refused |
|
||||
| `stacks.UpdateGuards` + `updateGuardsAdapter` (v0.237.0; tiers v0.239.0) | controller/internal/stacks/update.go, controller/cmd/controller/main.go | `HoldFor`, `Busy`, `RestorePoints`, `CanBackUp`, `BackupNow`, `SafetyDump`, `HoldAfterFailedUpdate` | the ONLY bridge from the update job to the backup side (stacks cannot import backup) | Wired by `stackMgr.SetUpdateGuards` — pinned by `TestSlice4_UpdateGuardsAreWiredAtStartup`. Add a guard HERE, never by importing backup into stacks |
|
||||
| `backup.Manager.Tier2UnitRestorePoint` + `Tier2RestorePoint.ProvenCopyTime` (v0.237.0) | controller/internal/backup/update_guard.go | `(stack) (Tier2RestorePoint, error)` | "can this app be restored from Tier 2, and from when" — the predicate that gates BOTH the „Teljes visszaállítás" action and an update | **ONE predicate, two callers** (extracted from `buildAppBackupRows`, not copied). `CopyDate` is what the page NAMES (the package date, R-403); `ProvenCopyTime` is how OLD the data is — the last successful copy, because the manifest's `created_at` moves only when the DEFINITION changes (measured: a fresh dump under a 22-h-older manifest). Do not age a copy by `CopyDate`. **Since v0.239.0 the UPDATE no longer calls it directly** — it goes through `UpdateRestorePoints` (next row); the page still does |
|
||||
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
|
||||
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. **The ORDER is `UpdateTierOrderFor` (v0.241.0, R-479): 2 → 3 → 1 for an app with classified binds (`DataOutsideUnit`), 2 → 1 → 3 otherwise; `UpdateCopyHolds` is the matching phrase for the hold.** Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
|
||||
| `backup.Manager.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
|
||||
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
|
||||
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait |
|
||||
|
||||
@@ -579,6 +579,10 @@ second drive (Tier 2), the app's own recovery unit (Tier 1, „helyi"), off-site
|
||||
a 15 s bound — unreachable counts as absent, with a WARN; since v0.240.0 it runs no per-app `stats`). `update.backup_max_age` applies to whichever
|
||||
tier is chosen. An app with no copy anywhere is backed up first. Tier 2 is required nowhere in the
|
||||
update path; the backups page's „Teljes visszaállítás" still reads the Tier-2 predicate alone.
|
||||
**Since v0.241.0 (R-479) an app whose data is bind-mounted files walks second drive → off-site → own
|
||||
unit** (its unit holds settings and database dumps, not the files), and the hold sentence ends with
|
||||
what the chosen copy holds („… — ez a másolat csak a beállításokat és az adatbázist tartalmazza, a
|
||||
fájlokat nem." / „… a beállításokat, az adatbázist és a fájlokat tartalmazza.").
|
||||
|
||||
**The sequence.** With no fresh copy on any tier the app is backed up first (`RunAppBackupNow`: this
|
||||
app's DB dump, volume dump, unit capture, then a Tier-2 copy whose failure is only a WARN — the unit
|
||||
|
||||
@@ -3424,5 +3424,6 @@ func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, r
|
||||
if a.b == nil {
|
||||
return fmt.Errorf("backup is not enabled on this box — the hold cannot be recorded")
|
||||
}
|
||||
return a.b.HoldAfterFailedUpdate(name, at, rp.ProvenAt, rp.Tier)
|
||||
// R-479: the sentence names what the chosen copy holds, decided by the app's data layout NOW.
|
||||
return a.b.HoldAfterFailedUpdateHolding(name, at, rp.ProvenAt, rp.Tier, a.b.UpdateCopyHolds(name, rp.Tier))
|
||||
}
|
||||
|
||||
@@ -68,4 +68,8 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) {
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " Tier ") {
|
||||
t.Errorf("the hold must be told the chosen TIER, or it cannot name it; selectors:%s", h)
|
||||
}
|
||||
// R-479: and WHAT the copy holds, computed from the app's data layout at hold time.
|
||||
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " UpdateCopyHolds ") || !strings.Contains(h, " HoldAfterFailedUpdateHolding ") {
|
||||
t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user