8fc2b4a1a9
gates / gates (push) Successful in 26s
The guarded update gains a folder copy of the app's named volumes, taken after the pull where the app stops anyway (decision 19, chosen by the 2026-09-23 bake-off). On a failed health check the box undoes: every copy validated by its finished-marker first, volumes refilled, definition and pin from the job's own pre-update copies, the old version checked with the OLD .felhom.yml probe. It holds only if the undo fails, and the hold sentence says so and what state the data is in. Bind-mounted folders are never touched. - R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639 (pre-update copies incl. .felhom.yml kept until the undo is over). - journal phases copying/undoing with power-cut recovery. - app.yaml last_update_undone + one line on the app page (hu/en). - R-642: start/restart never answer "completed". - Removal deletes kept undo copies. MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
97 lines
3.7 KiB
Go
97 lines
3.7 KiB
Go
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)
|
||
}
|
||
}
|