controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -347,6 +347,9 @@ func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) {
|
||||
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
|
||||
// restore hold names nothing, because the restore it refers to already consumed the copy.
|
||||
if h.Reason == settings.HoldReasonUpdateFailed {
|
||||
if h.NoWholeCopy { // R-659: the sentence carries the undo's failure itself and names no copy
|
||||
return true, util.Text(lang, "hold.update.no_whole_copy", stack)
|
||||
}
|
||||
return true, m.undoHoldPrefix(lang, h.UndoState) + updateHoldSentence(lang, stack, h)
|
||||
}
|
||||
when := h.At
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-658 (v0.268.0) — the unit restore recreated each named volume with a bare `docker volume create`,
|
||||
// so the volume carried no compose label; the undo (until v0.267.0) selected volumes by that label and
|
||||
// copied nothing after any restore. The restore now creates the volume WITH compose's labels.
|
||||
//
|
||||
// The docker here is a STUB script in t.TempDir() on PATH (R-650's seam) that records every call.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop composeVolumeLabelArgs from the create call. This test then
|
||||
// fails at "the restore created vikunja_files WITHOUT the project label".
|
||||
func TestR658_RestoreCreatesLabelledVolumes(t *testing.T) {
|
||||
bin := t.TempDir()
|
||||
calls := filepath.Join(bin, "calls.log")
|
||||
stub := "#!/bin/sh\necho \"$@\" >> " + calls + "\n" +
|
||||
"case \"$*\" in 'compose version --short') echo v2.29.7;; esac\nexit 0\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
dump := t.TempDir()
|
||||
for _, n := range []string{"vikunja_files.tar", "vikunja_db.tar"} {
|
||||
if err := os.WriteFile(filepath.Join(dump, n), []byte("tar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0)}
|
||||
n, err := m.restoreDockerVolumesFrom("vikunja", dump)
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("restored %d, err %v — want 2, nil", n, err)
|
||||
}
|
||||
b, _ := os.ReadFile(calls)
|
||||
var creates []string
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "volume create") {
|
||||
creates = append(creates, l)
|
||||
}
|
||||
}
|
||||
if len(creates) != 2 {
|
||||
t.Fatalf("want 2 volume creates, got %q", creates)
|
||||
}
|
||||
for _, want := range []struct{ vol, key string }{{"vikunja_files", "files"}, {"vikunja_db", "db"}} {
|
||||
found := false
|
||||
for _, c := range creates {
|
||||
if !strings.HasSuffix(c, " "+want.vol) {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(c, "--label com.docker.compose.project=vikunja") {
|
||||
t.Fatalf("the restore created %s WITHOUT the project label: %q", want.vol, c)
|
||||
}
|
||||
if !strings.Contains(c, "--label com.docker.compose.volume="+want.key) {
|
||||
t.Fatalf("the restore created %s without its volume key %q: %q", want.vol, want.key, c)
|
||||
}
|
||||
if !strings.Contains(c, "--label com.docker.compose.version=2.29.7") {
|
||||
t.Fatalf("the restore created %s without the compose version: %q", want.vol, c)
|
||||
}
|
||||
if strings.Contains(c, "config-hash") {
|
||||
t.Fatalf("a guessed config-hash label would make compose offer to recreate the volume: %q", c)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no create for %s in %q", want.vol, creates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A volume without the `<project>_` prefix is created without labels — never with a guessed key.
|
||||
func TestR658_LabelArgsNeverGuessAKey(t *testing.T) {
|
||||
if got := composeVolumeLabelArgs("vikunja", "custom_name", "2.29.7"); got != nil {
|
||||
t.Fatalf("got %v, want no labels", got)
|
||||
}
|
||||
if got := composeVolumeLabelArgs("vikunja", "vikunja_db", ""); len(got) != 4 {
|
||||
t.Fatalf("an unreadable version drops only the version label; got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-659 (v0.268.0; operator ruling 2026-09-24, `09` §3 decision 25, option A) — a held app's sentence
|
||||
// names only a copy that brings the app back WHOLE; with none, it says so and names nothing.
|
||||
//
|
||||
// Measured 2026-09-24 00:00 on 9202, chaos round 11: nextcloud (drive files declared), own unit the only
|
||||
// copy; the hold named „saját meghajtó"; the restore of exactly that copy REFUSED (R-538).
|
||||
|
||||
var r659At = time.Date(2026, 9, 23, 21, 42, 39, 0, time.UTC)
|
||||
|
||||
// r659Manager: one app; `files` declares a mandatory drive leg (nextcloud's class). `tiers` are the
|
||||
// copies present, each at its own time.
|
||||
func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager {
|
||||
t.Helper()
|
||||
drive := t.TempDir()
|
||||
m, _, prov := classifiedOffboxManager(t, drive)
|
||||
prov.hdd["nextcloud"] = drive
|
||||
if files {
|
||||
prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")}
|
||||
prov.has["nextcloud"] = true
|
||||
}
|
||||
m.updateTier2PointFn = func(string) (Tier2RestorePoint, error) {
|
||||
at, ok := tiers[UpdateTierSecondDrive]
|
||||
if !ok {
|
||||
return Tier2RestorePoint{}, errors.New("no Tier-2 copy")
|
||||
}
|
||||
return Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: at.Format(time.RFC3339), CopyDate: at.Format(time.RFC3339)}, nil
|
||||
}
|
||||
m.updateTier1PointsFn = func(string) ([]RestorePoint, bool) {
|
||||
at, ok := tiers[UpdateTierLocal]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1}}, true
|
||||
}
|
||||
m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) {
|
||||
at, ok := tiers[UpdateTierOffsite]
|
||||
if !ok {
|
||||
return map[string]time.Time{}, nil
|
||||
}
|
||||
return map[string]time.Time{"nextcloud": at}, nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// TestR659_TheHoldNamesOnlyAWholeCopy — app class × copies present.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make WholeOnTier answer true for every tier (the v0.267.0 reading:
|
||||
// any copy is a route back). The round-11 case then fails at "names „saját meghajtó"".
|
||||
func TestR659_TheHoldNamesOnlyAWholeCopy(t *testing.T) {
|
||||
unit := r659At.Add(-2 * time.Hour)
|
||||
second := r659At.Add(-20 * time.Hour)
|
||||
off := r659At.Add(-5 * time.Hour)
|
||||
noWhole := util.Text("hu", "hold.update.no_whole_copy", "nextcloud")
|
||||
cases := []struct {
|
||||
name string
|
||||
files bool
|
||||
tiers map[int]time.Time
|
||||
wantNone bool
|
||||
wantLabel string // the tier label the sentence must name (hu)
|
||||
}{
|
||||
{"files / unit only (round 11)", true, map[int]time.Time{UpdateTierLocal: unit}, true, ""},
|
||||
{"files / second drive only", true, map[int]time.Time{UpdateTierSecondDrive: second}, true, ""},
|
||||
{"files / unit + second drive", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, true, ""},
|
||||
{"files / off-site + unit", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierOffsite: off}, false, "távoli mentés"},
|
||||
{"files / none", true, map[int]time.Time{}, true, ""},
|
||||
{"volumes / unit only", false, map[int]time.Time{UpdateTierLocal: unit}, false, "saját meghajtó"},
|
||||
{"volumes / second drive older than unit", false, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, false, "saját meghajtó"},
|
||||
{"volumes / off-site newest", false, map[int]time.Time{UpdateTierSecondDrive: second, UpdateTierOffsite: off}, false, "távoli mentés"},
|
||||
{"volumes / none", false, map[int]time.Time{}, true, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
m := r659Manager(t, c.files, c.tiers)
|
||||
none, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, why := m.RestoreHoldForLang("nextcloud", "hu")
|
||||
if !held {
|
||||
t.Fatal("not held")
|
||||
}
|
||||
if c.wantNone {
|
||||
if !none || why != noWhole {
|
||||
t.Fatalf("no whole copy exists, yet the hold names %q (none=%v)", why, none)
|
||||
}
|
||||
if !m.HoldNoWholeCopy("nextcloud") {
|
||||
t.Fatal("the page flag is not set — the restore button would stay")
|
||||
}
|
||||
h, _ := m.UpdateHold("nextcloud")
|
||||
if len(h.CopiesSeen) != len(c.tiers) {
|
||||
t.Fatalf("copies seen %v, want %d recorded for support", h.CopiesSeen, len(c.tiers))
|
||||
}
|
||||
return
|
||||
}
|
||||
if none || strings.Contains(why, "nincs olyan másolat") {
|
||||
t.Fatalf("a whole copy exists, yet the hold says none: %q", why)
|
||||
}
|
||||
if !strings.Contains(why, c.wantLabel) {
|
||||
t.Fatalf("the hold names %q, want the copy %q", why, c.wantLabel)
|
||||
}
|
||||
if m.HoldNoWholeCopy("nextcloud") {
|
||||
t.Fatal("the page flag is set over a whole copy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The English sentence is the operator's copy, verbatim, and the Hungarian likewise.
|
||||
func TestR659_SentenceBothLanguages(t *testing.T) {
|
||||
m := r659Manager(t, true, map[int]time.Time{UpdateTierLocal: r659At.Add(-time.Hour)})
|
||||
if _, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, en := m.RestoreHoldForLang("nextcloud", "en")
|
||||
if en != "The update of nextcloud did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app." {
|
||||
t.Fatalf("en = %q", en)
|
||||
}
|
||||
_, hu := m.RestoreHoldForLang("nextcloud", "hu")
|
||||
if hu != "A(z) nextcloud frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást." {
|
||||
t.Fatalf("hu = %q", hu)
|
||||
}
|
||||
}
|
||||
|
||||
// TestR659_TruthTableAgreesWithTheRestoresRefusal — WholeOnTier for the unit tiers must be exactly
|
||||
// "the unit restore does not refuse for missing files". The two predicates cannot drift.
|
||||
func TestR659_TruthTableAgreesWithTheRestoresRefusal(t *testing.T) {
|
||||
for _, files := range []bool{true, false} {
|
||||
drive := t.TempDir()
|
||||
m, _, prov := classifiedOffboxManager(t, drive)
|
||||
prov.hdd["nextcloud"] = drive
|
||||
if files {
|
||||
prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")}
|
||||
prov.has["nextcloud"] = true
|
||||
}
|
||||
mkUnit(t, drive, "nextcloud")
|
||||
_, err := m.RestoreFromRecoveryUnitAt("nextcloud", RecoveryUnitPath(drive, "nextcloud"))
|
||||
var refusal *ErrUnitLacksFileLegs
|
||||
refused := errors.As(err, &refusal)
|
||||
for _, tier := range []int{UpdateTierLocal, UpdateTierSecondDrive} {
|
||||
if m.WholeOnTier("nextcloud", tier) == refused {
|
||||
t.Fatalf("files=%v tier %d: WholeOnTier=%v but the unit restore refused=%v — the page would send a household to a refusal",
|
||||
files, tier, m.WholeOnTier("nextcloud", tier), refused)
|
||||
}
|
||||
}
|
||||
if !m.WholeOnTier("nextcloud", UpdateTierOffsite) {
|
||||
t.Fatal("the off-site full restore brings files and database back — it is whole")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-660 — UpdateHeldStacks names the apps an UPDATE hold stops, and never an R-379 restore hold (that
|
||||
// hold has no event of its own; the app-down alarm stays its voice).
|
||||
func TestR660_UpdateHeldStacksIsUpdateHoldsOnly(t *testing.T) {
|
||||
sett := slice4Settings(t)
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
|
||||
if got := m.UpdateHeldStacks(); len(got) != 0 {
|
||||
t.Fatalf("no holds → %v", got)
|
||||
}
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "nextcloud", At: "2026-09-23T21:42:39Z", Reason: settings.HoldReasonUpdateFailed})
|
||||
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"})
|
||||
got := m.UpdateHeldStacks()
|
||||
if !got["nextcloud"] || got["docmost"] || len(got) != 1 {
|
||||
t.Fatalf("got %v, want exactly {nextcloud}", got)
|
||||
}
|
||||
var nilM *Manager
|
||||
if nilM.UpdateHeldStacks() != nil {
|
||||
t.Fatal("a nil manager must answer nil")
|
||||
}
|
||||
}
|
||||
@@ -139,19 +139,32 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro
|
||||
|
||||
var restored int
|
||||
var failed []string
|
||||
composeVer := ""
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") {
|
||||
continue
|
||||
}
|
||||
volName := strings.TrimSuffix(entry.Name(), ".tar")
|
||||
if composeVer == "" {
|
||||
composeVer = composeVersionShort()
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
|
||||
|
||||
// Remove existing volume (ignore errors — may not exist)
|
||||
dockerexec.Command("docker", "volume", "rm", "-f", volName).Run()
|
||||
|
||||
// Create fresh volume
|
||||
if out, err := dockerexec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
|
||||
// Create fresh volume — WITH the labels compose gives its own volumes (R-658, v0.268.0). A bare
|
||||
// `volume create` made a volume the update's undo (until v0.267.0) could not find, and one
|
||||
// compose warns it "was not created by Docker Compose". The config-hash label is deliberately
|
||||
// NOT set: compose only compares it when present, and a guessed hash would make it offer to
|
||||
// recreate (empty) the volume.
|
||||
args := append([]string{"volume", "create"}, composeVolumeLabelArgs(stackName, volName, composeVer)...)
|
||||
if len(args) == 2 {
|
||||
m.logger.Printf("[WARN] [backup] volume %s does not carry the %s_ prefix — created WITHOUT compose labels (the undo finds it by the app's definition anyway)", volName, stackName)
|
||||
}
|
||||
args = append(args, volName)
|
||||
if out, err := dockerexec.Command("docker", args...).CombinedOutput(); err != nil {
|
||||
m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
|
||||
failed = append(failed, volName)
|
||||
continue
|
||||
@@ -215,3 +228,29 @@ func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error
|
||||
}
|
||||
return fmt.Errorf("stack %s did not reach running state within %s after restore", stackName, timeout)
|
||||
}
|
||||
|
||||
// composeVolumeLabelArgs returns the `--label` arguments that make a restored volume look like one
|
||||
// compose created for this project: project, volume key and compose version. The key is the part of
|
||||
// the Docker name after `<project>_`, which is how compose names a volume without its own `name:` —
|
||||
// every catalog template today (R-658, measured 2026-09-24). A name without that prefix gets no labels
|
||||
// rather than a guessed key. The version label is left out when the version could not be read.
|
||||
func composeVolumeLabelArgs(project, volName, composeVersion string) []string {
|
||||
key := strings.TrimPrefix(volName, project+"_")
|
||||
if key == volName || key == "" {
|
||||
return nil
|
||||
}
|
||||
args := []string{"--label", "com.docker.compose.project=" + project, "--label", "com.docker.compose.volume=" + key}
|
||||
if composeVersion != "" {
|
||||
args = append(args, "--label", "com.docker.compose.version="+composeVersion)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// composeVersionShort is `docker compose version --short`, "" when it cannot be read.
|
||||
func composeVersionShort() string {
|
||||
out, err := dockerexec.Command("docker", "compose", "version", "--short").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimSpace(string(out)), "v")
|
||||
}
|
||||
|
||||
@@ -608,3 +608,132 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) {
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At)
|
||||
}
|
||||
|
||||
// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the
|
||||
// FOURTH way the product stops an app on purpose, and until v0.268.0 the one `classifyRunStates` did
|
||||
// not know: each hold's `app_update_held` was followed ~11 s later by an `app_start_failed` for the
|
||||
// same app (chaos rounds 8 and 11, 2026-09-23 night). A RESTORE hold (R-379) is not in the set: it
|
||||
// has no event of its own, so the app-down alarm stays its only voice. Nil-safe.
|
||||
func (m *Manager) UpdateHeldStacks() map[string]bool {
|
||||
if m == nil || m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
var out map[string]bool
|
||||
for _, h := range m.settings.ListRestoreHolds() {
|
||||
if h.Reason != settings.HoldReasonUpdateFailed {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]bool{}
|
||||
}
|
||||
out[h.Stack] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── R-659 (v0.268.0): the hold names only a copy that can bring the app back WHOLE ────────────────
|
||||
//
|
||||
// MEASURED 2026-09-24 00:00 on 9202 (chaos round 11): nextcloud's update and its undo both failed; the
|
||||
// hold named „saját meghajtó" (the precondition copy — decision 8 lets an update lean on any tier);
|
||||
// the household pressed exactly that restore and was REFUSED, because the unit holds no copy of the
|
||||
// app's files on the drive (R-538). The box had no other copy, so nothing on any page brought the app
|
||||
// back. Operator ruling 2026-09-24 (`09` §3 decision 25, option A): the hold names only a copy that
|
||||
// brings the app back whole; with none, it says so, says support is informed, and support is told.
|
||||
//
|
||||
// THE TRUTH TABLE, read from the restores' OWN refusals (measured from source, v0.267.0), not from
|
||||
// what each tier stores:
|
||||
//
|
||||
// app own unit (1) second drive (2) off-site (3)
|
||||
// no declared drive files whole (unit restore) whole („Teljes visszaállítás") whole (full restore)
|
||||
// declared drive files NOT — refused (R-538) NOT — its unit restore is refused whole („Teljes
|
||||
// (DeclaredDriveFileLegs) by the same guard; its file restore visszaállítás (fájlok
|
||||
// only ADDS missing files, no database + adatbázis)")
|
||||
//
|
||||
// So the question is asked of the SAME predicate the refusal uses (DeclaredDriveFileLegs), and a test
|
||||
// pins that the two cannot drift (TestR659_TruthTableAgreesWithTheRestoresRefusal). Tier 2 holds a
|
||||
// file app's files AND its unit, but no single action brings the app back whole from it — R-661.
|
||||
|
||||
// WholeOnTier reports whether a copy on `tier` can bring this app back WHOLE through the restore the
|
||||
// Mentések page offers for that tier.
|
||||
func (m *Manager) WholeOnTier(stackName string, tier int) bool {
|
||||
switch tier {
|
||||
case UpdateTierOffsite:
|
||||
return true
|
||||
case UpdateTierLocal, UpdateTierSecondDrive:
|
||||
return !m.HasDriveFileLegs(stackName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HoldCopies walks EVERY tier (not only until the first acceptable one, as the update does) and
|
||||
// returns the newest copy that brings the app back whole, whether there is one, and every copy seen.
|
||||
func (m *Manager) HoldCopies(ctx context.Context, stackName string) (UpdateTierPoint, bool, []UpdateTierPoint) {
|
||||
var seen []UpdateTierPoint
|
||||
var best UpdateTierPoint
|
||||
found := false
|
||||
for _, tier := range []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite} {
|
||||
p, ok := m.updateTierPoint(ctx, stackName, tier)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen = append(seen, p)
|
||||
if m.WholeOnTier(stackName, tier) && (!found || p.At.After(best.At)) {
|
||||
best, found = p, true
|
||||
}
|
||||
}
|
||||
return best, found, seen
|
||||
}
|
||||
|
||||
// HoldAfterFailedUpdateWhole records the update hold naming the newest WHOLE copy, or — with none —
|
||||
// a hold that names nothing and says support is informed (NoWholeCopy). The copies seen are recorded
|
||||
// either way. Returns whether no whole copy exists.
|
||||
func (m *Manager) HoldAfterFailedUpdateWhole(ctx context.Context, stackName string, at time.Time, undoState string) (bool, error) {
|
||||
best, found, seen := m.HoldCopies(ctx, stackName)
|
||||
var seenS []string
|
||||
for _, p := range seen {
|
||||
seenS = append(seenS, fmt.Sprintf("tier %d at %s", p.Tier, p.At.UTC().Format(time.RFC3339)))
|
||||
}
|
||||
if !found {
|
||||
if m == nil || m.settings == nil {
|
||||
return true, fmt.Errorf("no settings wired — the update hold for %s cannot be persisted", stackName)
|
||||
}
|
||||
h := settings.RestoreHold{Stack: stackName, At: at.UTC().Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed,
|
||||
UndoState: undoState, NoWholeCopy: true, CopiesSeen: seenS}
|
||||
if err := m.settings.SetRestoreHold(h); err != nil {
|
||||
return true, fmt.Errorf("persisting the update hold for %s: %w", stackName, err)
|
||||
}
|
||||
m.logger.Printf("[ERROR] [backup] %s is HELD STOPPED after a failed update and NO copy on this box brings it back whole (seen: %v; drive files declared: %v; undo: %q) — support must act (R-659)",
|
||||
stackName, seenS, m.HasDriveFileLegs(stackName), undoState)
|
||||
return true, nil
|
||||
}
|
||||
if err := m.HoldAfterFailedUpdateHolding(stackName, at, best.At, best.Tier, m.UpdateCopyHolds(stackName, best.Tier), undoState); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if h, ok := m.settings.GetRestoreHold(stackName); ok {
|
||||
h.CopiesSeen = seenS
|
||||
_ = m.settings.SetRestoreHold(h)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// HoldNoWholeCopy reports whether the app's hold names no copy (R-659) — the page then offers no
|
||||
// restore button for it.
|
||||
func (m *Manager) HoldNoWholeCopy(stackName string) bool {
|
||||
if m == nil || m.settings == nil {
|
||||
return false
|
||||
}
|
||||
h, ok := m.settings.GetRestoreHold(stackName)
|
||||
return ok && h.Reason == settings.HoldReasonUpdateFailed && h.NoWholeCopy
|
||||
}
|
||||
|
||||
// UpdateHold returns the stored update hold, for the operator event (R-659).
|
||||
func (m *Manager) UpdateHold(stackName string) (settings.RestoreHold, bool) {
|
||||
if m == nil || m.settings == nil {
|
||||
return settings.RestoreHold{}, false
|
||||
}
|
||||
h, ok := m.settings.GetRestoreHold(stackName)
|
||||
if !ok || h.Reason != settings.HoldReasonUpdateFailed {
|
||||
return settings.RestoreHold{}, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user