package stacks import ( "context" "errors" "fmt" "testing" "time" ) // R-475 — any backup tier lets an app update (operator ruling 2026-09-13, controller v0.239.0). // Tier order and the Tier-3 timeout are the backup side's (internal/backup/update_tiers_test.go); // here is the update job's half: which copy it leans on, when it backs up first, when it refuses. // Every age here is read against slice4T0, the same clock the job reads (R-457). func hasGuardCall(g *fakeGuards, name string) bool { for _, c := range g.callList() { if c == name { return true } } return false } // failedUpdateHold runs an update whose new version never becomes healthy, so the hold records the // copy the job chose. That is the observable consequence of the choice: the customer is told to // restore from exactly that copy. func failedUpdateHold(t *testing.T, points, afterBackup []UpdateRestorePoint) (*fakeGuards, *Stack) { t.Helper() m, _, g, _ := newSlice4Manager(t) g.points, g.pointsAfterBackup = points, afterBackup m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "crash loop" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatalf("start: %v", err) } return g, waitUpdateDone(t, m, "nextcloud") } func TestR475_G_TheSecondDriveIsChosenWhenPresent(t *testing.T) { fresh := slice4T0.Add(-time.Hour) g, st := failedUpdateHold(t, []UpdateRestorePoint{ {Tier: UpdateTierSecondDrive, ProvenAt: fresh}, {Tier: UpdateTierLocal, ProvenAt: fresh.Add(30 * time.Minute)}, {Tier: UpdateTierOffsite, ProvenAt: fresh}, }, nil) if st.UpdatePhase != UpdatePhaseFailed || g.holdRP.Tier != UpdateTierSecondDrive || !g.holdRP.ProvenAt.Equal(fresh) { t.Errorf("with a fresh second-drive copy that copy is chosen; hold = %+v phase=%q", g.holdRP, st.UpdatePhase) } if hasGuardCall(g, "BackupNow") { t.Error("a fresh copy needs no backup first") } } func TestR475_H_TheOwnUnitAloneCarriesTheUpdate(t *testing.T) { fresh := slice4T0.Add(-2 * time.Hour) g, _ := failedUpdateHold(t, []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: fresh}}, nil) if g.holdRP.Tier != UpdateTierLocal || !g.holdRP.ProvenAt.Equal(fresh) { t.Errorf("an app with only its own recovery unit must update against it; hold = %+v", g.holdRP) } if hasGuardCall(g, "BackupNow") { t.Error("a fresh own unit needs no backup first — Tier 2 must not be required anywhere") } } func TestR475_I_TheOffsiteCopyAloneCarriesTheUpdate(t *testing.T) { fresh := slice4T0.Add(-3 * time.Hour) g, _ := failedUpdateHold(t, []UpdateRestorePoint{{Tier: UpdateTierOffsite, ProvenAt: fresh}}, nil) if g.holdRP.Tier != UpdateTierOffsite || !g.holdRP.ProvenAt.Equal(fresh) || hasGuardCall(g, "BackupNow") { t.Errorf("an app with only a fresh off-site copy must update against it; hold = %+v calls=%v", g.holdRP, g.callList()) } } func TestR475_K_NoCopyAnywhereIsBackedUpFirstAndTheUpdateProceeds(t *testing.T) { m, dir, g, _ := newSlice4Manager(t) g.points = nil g.pointsAfterBackup = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(-time.Minute)}} if ref := m.UpdatePreflight("nextcloud"); ref != nil { t.Fatalf("an app with no copy but a working backup must NOT be refused, got %q (%s)", ref.Message, ref.Reason) } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if st.UpdatePhase != UpdatePhaseDone { t.Fatalf("the backup made a Tier-1 copy, so the update completes; phase=%q err=%q", st.UpdatePhase, st.UpdateError) } if !hasGuardCall(g, "BackupNow") { t.Error("with no copy anywhere the job must back up FIRST") } if got := pinOf(t, dir); got != "nextcloud:34.0.1-apache" { t.Errorf("pin = %q", got) } } func TestR475_K_NoCopyAndTheBackupFailsMovesNothing(t *testing.T) { m, dir, g, c := newSlice4Manager(t) g.points = nil g.backupErr = errors.New("nincs elég szabad hely") if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if want := fmt.Sprintf(MsgUpdateBackupFailFmt, g.backupErr); st.UpdateError != want { t.Errorf("UpdateError = %q, want %q", st.UpdateError, want) } if pinOf(t, dir) != "nextcloud:31.0.14-apache" || len(c.list()) != 0 { t.Error("nothing may move when the backup-first failed") } } // L — nothing anywhere and no way to back up is refused (TestSlice4_C_… pins the refusal itself). The // other half pins that the refusal is not wider than that: a copy that EXISTS still carries it. func TestR475_L_AnExistingCopyStillCarriesAnAppThatCannotBeBackedUp(t *testing.T) { m, _, g, _ := newSlice4Manager(t) g.cannotBackUp = true g.points = []UpdateRestorePoint{{Tier: UpdateTierOffsite, ProvenAt: slice4T0.Add(-time.Hour)}} if ref := m.UpdatePreflight("nextcloud"); ref != nil { t.Fatalf("a fresh off-site copy must carry the update even when no backup can be taken now, got %q", ref.Message) } g.points = nil if ref := m.UpdatePreflight("nextcloud"); ref == nil || ref.Reason != "no_backup" { t.Fatalf("positive control: with no copy the same app must be refused, got %v", ref) } } // M — a stale copy on ANY tier is stale. Each case would pass under a Tier-2-only age check except the // first, which is the control proving the fixture's clock and limit are real. func TestR475_M_TheAgeRuleAppliesToTheChosenTier(t *testing.T) { stale, fresh, justNow := slice4T0.Add(-30*time.Hour), slice4T0.Add(-time.Hour), slice4T0.Add(-time.Minute) cases := []struct { name string points []UpdateRestorePoint afterBackup []UpdateRestorePoint wantBackup bool wantTier int }{ {"control: a stale second-drive copy is backed up first", []UpdateRestorePoint{{Tier: UpdateTierSecondDrive, ProvenAt: stale}}, []UpdateRestorePoint{{Tier: UpdateTierSecondDrive, ProvenAt: justNow}}, true, UpdateTierSecondDrive}, {"a stale own unit is backed up first", []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: stale}}, []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: justNow}}, true, UpdateTierLocal}, {"a stale off-site copy is backed up first", []UpdateRestorePoint{{Tier: UpdateTierOffsite, ProvenAt: stale}}, []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: justNow}, {Tier: UpdateTierOffsite, ProvenAt: stale}}, true, UpdateTierLocal}, {"a stale second drive does not block a fresh own unit", []UpdateRestorePoint{{Tier: UpdateTierSecondDrive, ProvenAt: stale}, {Tier: UpdateTierLocal, ProvenAt: fresh}}, nil, false, UpdateTierLocal}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g, st := failedUpdateHold(t, tc.points, tc.afterBackup) if got := hasGuardCall(g, "BackupNow"); got != tc.wantBackup { t.Errorf("backed up first = %v, want %v (calls %v)", got, tc.wantBackup, g.callList()) } if g.holdRP.Tier != tc.wantTier { t.Errorf("the chosen copy is tier %d, want %d (hold %+v, err %q)", g.holdRP.Tier, tc.wantTier, g.holdRP, st.UpdateError) } if age := slice4T0.Sub(g.holdRP.ProvenAt); age > 24*time.Hour { t.Errorf("the chosen copy is %s old — past the age limit", age) } }) } } func TestR475_M_TheLimitIsInclusiveOnOneClock(t *testing.T) { fresh := freshRestorePoint(slice4T0, 24*time.Hour) for _, tier := range []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite} { if !fresh(UpdateRestorePoint{Tier: tier, ProvenAt: slice4T0.Add(-24 * time.Hour)}) { t.Errorf("tier %d: exactly the limit old is still fresh", tier) } if fresh(UpdateRestorePoint{Tier: tier, ProvenAt: slice4T0.Add(-24*time.Hour - time.Second)}) { t.Errorf("tier %d: a second past the limit is stale", tier) } if fresh(UpdateRestorePoint{Tier: tier}) { t.Errorf("tier %d: a copy with no proven time is never fresh", tier) } } }