From bd9f4f7b7458d38c01e5cb16cc7f5bb0e0258928 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 25 Oct 2026 09:44:56 +0100 Subject: [PATCH] =?UTF-8?q?R-1271=20(P2):=20=E2=80=9Euse=20my=20kept=20dat?= =?UTF-8?q?a=E2=80=9D=20never=20takes=20a=20copy=20dated=20in=20the=20futu?= =?UTF-8?q?re=20=E2=80=94=20a=20copy=20more=20than=2010=20minutes=20past?= =?UTF-8?q?=20the=20box's=20clock=20never=20counts=20as=20the=20newest=20(?= =?UTF-8?q?the=20newest=20true=20date=20wins,=20tie=20local);=20alone=20it?= =?UTF-8?q?=20is=20still=20offered,=20said=20and=20not=20recommended.=20Re?= =?UTF-8?q?d-proved.=20Commit=20only.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 ++++ controller/internal/api/kept_install.go | 10 ++- .../internal/api/r1271_future_copy_test.go | 86 +++++++++++++++++++ controller/internal/backup/kept_load.go | 78 ++++++++++++++--- controller/internal/backup/kept_pick_test.go | 66 ++++++++++++++ controller/internal/i18n/locales/en.json | 4 +- controller/internal/i18n/locales/hu.json | 4 +- controller/internal/web/kept_handlers.go | 15 +++- controller/internal/web/templates/deploy.html | 6 +- .../web/testdata/i18n_parity/deploy_new.html | 6 +- .../i18n_parity/deploy_new_blocked_r1125.html | 6 +- .../deploy_new_memory_blocked_r1125.html | 6 +- controller/scripts/i18n_go_keys.json | 4 +- 13 files changed, 278 insertions(+), 32 deletions(-) create mode 100644 controller/internal/api/r1271_future_copy_test.go create mode 100644 controller/internal/backup/kept_pick_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 250a37e3..e0c1a366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,25 @@ brief 2026-10-24, Part 4 (commit only; the release rides the next normal train). `…OnlyWhenTheAgentWaitsAndAnAppRuns`, `…QBLimitRuleSkipsAHeldLimit`, `…TheTwoAppsReadAndWriteTheirLimit`, `TestLineTestRunOutlastsTheClientTimeout`, `TestStartUpwatchRecoversTheHoldFirst`, `TestUpwatchHoldGetsBothAgentCalls`. +### „Use my kept data" never takes a copy dated in the future (R-1271, P2) — 2026-10-25, commit only + +- **The cause, read from the code:** the catalog sweep's Tester4 off-site store held the 2026-10-20 rehearsal box's + copies, dated 2026-10-28 (that box's clock was set ahead). `KeptNewer` took the off-site copy whenever its date was + after the local one's; that date was the other box's snapshot time, uncapped (this box had no data time of its own + for it, `offsiteDataTime`). The `07` §6.5 „another drive" check (`KeptCopyAt`) compares the unit's `HDD_PATH` — a + mount path with the same name on every box — so it is a path check, and it stays one (an old box's data on a new box + is what off-site is for). Immich's 20 minutes of 404 were routing: the copy brought the old box's subdomain + (`photos`); the app was healthy at `photos.`. +- **Now (`keptPick`, `KeptNewerAt`):** a copy dated more than `KeptClockSkew` (10 minutes) past this box's clock is + future-dated and never the newest; the newest copy with a true date wins (tie → local, own unit before the second + drive). A future-dated copy alone is still offered: the install question carries `use_future` and a sentence („…a + dátuma (…) a jövőben van… Csak akkor válaszd, ha biztosan ez a ti mentésetek."), and draws „use" as the plain button, + not the primary one; the kept-data list adds „(a dátuma a jövőben van…)". The question names the copy as before. +- Tests (each seen failing with the future check off): `TestR1271_AFutureOffsiteCopyNeverBeatsTheOwnCopy`, + `…AFutureCopyAloneIsOfferedButMarked`, `…TheClockSkewAllowance`, api `…AFutureCopyAloneIsSaidAndNotRecommended`; + controls `…AnOlderOffsiteCopyAloneIsChosenAsBefore`, `…ANewerTrueOffsiteCopyStillWins`. The three install-page parity + fixtures carry the two changed script lines (nothing else moved). + ### A restore downloads first (D48) — 2026-10-25, commit only Operator ruling D48 (2026-10-25 08:38, `09` §3 decision 257): every restore path resolves and pulls every image its diff --git a/controller/internal/api/kept_install.go b/controller/internal/api/kept_install.go index 01a8e389..90c73030 100644 --- a/controller/internal/api/kept_install.go +++ b/controller/internal/api/kept_install.go @@ -20,6 +20,10 @@ type keptChoiceData struct { UseDesc string `json:"use_desc"` // "" when use is off UseOff string `json:"use_off"` // "" when use is offered UseOffered bool `json:"use_offered"` // a database copy exists for these files + // UseFuture / UseWarn (R-1271): the only copy is dated in the future — offered, NOT recommended (the page draws + // „use" as the plain button), and the sentence says why. + UseFuture bool `json:"use_future,omitempty"` + UseWarn string `json:"use_warn,omitempty"` FreshLabel string `json:"fresh_label"` FreshDesc string `json:"fresh_desc"` NotBacked string `json:"not_backed_up"` @@ -71,13 +75,17 @@ func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, nam } if cp.ok { data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c)) + if cp.c.Future { + data.UseFuture = true + data.UseWarn = r.msgLang(lang, "kept.choice.use.future", localMinute(cp.c.Time)) + } } else { data.UseOff = r.msgLang(lang, "kept.choice.use_off") } switch choice { case "": - r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok) + r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v, future-dated: %v)", name, old, size, cp.ok, cp.c.Future) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data}) return true case stacks.KeptChoiceUse: diff --git a/controller/internal/api/r1271_future_copy_test.go b/controller/internal/api/r1271_future_copy_test.go new file mode 100644 index 00000000..a5efd5ce --- /dev/null +++ b/controller/internal/api/r1271_future_copy_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-1271 (brief 2026-10-25 Part 4) — a future-dated off-site copy, the ONLY copy of the kept files: still offered (it +// may be the household's), but the question says its date is in the future and does not recommend it. Same rig as +// TestR691 (the restic runner seam; nothing reaches restic, ssh or Docker). +// COMPANION RED-PROOF (REPORT.md): with keptPick ignoring the future date, use_future stays false and this fails. +func TestR1271_AFutureCopyAloneIsSaidAndNotRecommended(t *testing.T) { + dir := t.TempDir() + drive := filepath.Join(dir, "drive") + cfg := &config.Config{} + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.ComposeCommand = "docker compose" + app := filepath.Join(cfg.Paths.StacksDir, "cloudapp") + for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + _ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644) + _ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644) + _ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644) + m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom", + RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"}); err != nil { + t.Fatal(err) + } + bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0)) + if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil { + t.Fatal(err) + } + snapAt := time.Now().Add(4 * 24 * time.Hour) // the rehearsal box's clock: four days ahead + bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { + for _, a := range args { + if a == "snapshots" { + return json.Marshal([]map[string]interface{}{{"short_id": "abba81ba", "time": snapAt, + "tags": []string{"cloudapp"}, "paths": []string{drive + "/backups/primary/cloudapp", drive + "/appdata/cloudapp"}}}) + } + } + return nil, nil + }) + r := &Router{stackMgr: m, backupMgr: bm, logger: log.New(io.Discard, "", 0)} + for lang, want := range map[string]string{"hu": "a jövőben van", "en": "is in the future"} { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang="+lang, nil) + if !r.keptDataAtInstall(w, req, "cloudapp", drive, "") || w.Code != http.StatusConflict { + t.Fatalf("%s: the choice was not asked (code %d)", lang, w.Code) + } + var body map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &body) + data, _ := body["data"].(map[string]interface{}) + warn, _ := data["use_warn"].(string) + if data["use_offered"] != true || data["use_future"] != true || !strings.Contains(warn, want) { + t.Fatalf("%s: a future-dated only copy is offered, marked and said: offered=%v future=%v warn=%q", lang, data["use_offered"], data["use_future"], warn) + } + } +} diff --git a/controller/internal/backup/kept_load.go b/controller/internal/backup/kept_load.go index c7b66bdd..580e690c 100644 --- a/controller/internal/backup/kept_load.go +++ b/controller/internal/backup/kept_load.go @@ -44,6 +44,9 @@ type KeptCopy struct { // SnapshotID and UnitPath name the off-site copy (Tier 3 only): the snapshot and the unit's path in it. SnapshotID string UnitPath string + // Future (R-1271): the copy's date lies past this box's clock (+ KeptClockSkew) — chosen only when it is the only + // copy, and the question then says so and does not recommend it. + Future bool } // KeptTierOffsite is KeptCopy.Tier for the off-site copy. @@ -112,11 +115,10 @@ func (m *Manager) KeptDBCopy(app, drive string) (KeptCopy, bool) { if app == "" || m.isStackDeployed(app) { return KeptCopy{}, false } - var best KeptCopy - found := false + var cands []KeptCopy consider := func(c KeptCopy, ok bool) { - if ok && (!found || c.Time.After(best.Time)) { - best, found = c, true + if ok { + cands = append(cands, c) } } if u, ok := m.RemovedAppUnitFor(app); ok { @@ -127,7 +129,7 @@ func (m *Manager) KeptDBCopy(app, drive string) (KeptCopy, bool) { for _, d := range m.Tier2MirrorDirsForApp(app) { consider(m.KeptCopyAt(tier2UnitDir(d), drive, 2)) } - return best, found + return keptPick(time.Now(), cands...) // R-1271: the newest TRUE date; own unit before the second drive on a tie } // KeptOffsiteCopies is the off-site copy kept files of each app could be loaded from: the app's newest @@ -174,21 +176,71 @@ func (m *Manager) KeptOffsiteCopy(ctx context.Context, app string) (KeptCopy, bo return c, ok } -// KeptNewer chooses between a local copy and the off-site copy: the off-site one only when it is NEWER or -// the only one (a tie goes local — no download). -func KeptNewer(local KeptCopy, lok bool, off KeptCopy, ook bool) (KeptCopy, bool) { - if ook && (!lok || off.Time.After(local.Time)) { - return off, true +// KeptNewerAt chooses between a local copy and the off-site copy at now: the off-site one only when it is NEWER by a +// true date, or the only one (a tie goes local — no download). R-1271: a future-dated copy never counts as newer. +func KeptNewerAt(now time.Time, local KeptCopy, lok bool, off KeptCopy, ook bool) (KeptCopy, bool) { + var cands []KeptCopy + if lok { + cands = append(cands, local) } - return local, lok + if ook { + cands = append(cands, off) + } + return keptPick(now, cands...) } // KeptBestCopy is the copy „Use my kept data" and Load use for app's kept files on drive: the newest -// usable local copy (KeptDBCopy), or the off-site copy when it is newer or the only one. +// usable local copy (KeptDBCopy), or the off-site copy when it is newer by a true date or the only one. func (m *Manager) KeptBestCopy(ctx context.Context, app, drive string) (KeptCopy, bool) { local, lok := m.KeptDBCopy(app, drive) off, ook := m.KeptOffsiteCopy(ctx, app) - return KeptNewer(local, lok, off, ook) + return KeptNewerAt(time.Now(), local, lok, off, ook) +} + +// ── R-1271 (brief 2026-10-25 Part 4): a copy dated in the future is never „the newest" ──────────────────────────── +// +// MEASURED (catalog sweep 2026-10-24, Tester4: Paperless-ngx, Nextcloud, Immich): the off-site store held copies of the +// 2026-10-20 rehearsal box, whose clock had been set to 2026-10-28. The off-site copy's date is the snapshot time that +// OTHER box wrote — this box recorded no data time to cap it (offsiteDataTime) — so „newer than every local copy" took +// it over the family's own copy of the same evening, and „Use my kept data" loaded it. The `07` §6.5 „taken of another +// drive" check (KeptCopyAt) compares the unit's HDD_PATH, a mount PATH with the same name on every box: a copy of +// another box's /mnt/felhom-drives/hdd_1 matches this box's. It is a path check, not a physical identity, and it stays +// one — loading the old box's data on a new box is what off-site exists for. +// +// THE RULE. A copy dated more than KeptClockSkew past now is FUTURE-DATED and never counts as the newest: the newest +// copy with a true date wins (a tie goes to the earlier candidate — local before off-site, own unit before the second +// drive). A future-dated copy is chosen only when no other exists; it is then marked Future, and the question says its +// date is in the future and does not recommend it. Pinned by kept_pick_test.go. + +// KeptClockSkew is how far a copy's date may lie past this box's clock and still count as a true date: NTP keeps a box +// within a second; ten minutes covers a box whose clock synced late after a power cut. +const KeptClockSkew = 10 * time.Minute + +// KeptFuture reports whether c's date lies more than KeptClockSkew past now. +func KeptFuture(c KeptCopy, now time.Time) bool { return c.Time.After(now.Add(KeptClockSkew)) } + +// keptPick: the newest candidate with a true date (the first one on a tie); only when every candidate is +// future-dated, the first one, marked Future. +func keptPick(now time.Time, cands ...KeptCopy) (KeptCopy, bool) { + var best KeptCopy + found := false + for _, c := range cands { + if KeptFuture(c, now) { + continue + } + if !found || c.Time.After(best.Time) { + best, found = c, true + } + } + if found { + return best, true + } + if len(cands) == 0 { + return KeptCopy{}, false + } + c := cands[0] + c.Future = true + return c, true } // Refusals of an off-site load, born as bundle keys. Each is raised BEFORE prepare and before anything diff --git a/controller/internal/backup/kept_pick_test.go b/controller/internal/backup/kept_pick_test.go new file mode 100644 index 00000000..c874fabe --- /dev/null +++ b/controller/internal/backup/kept_pick_test.go @@ -0,0 +1,66 @@ +package backup + +import ( + "testing" + "time" +) + +// R-1271 (brief 2026-10-25 Part 4) — a copy dated in the future is never „the newest". The measured case (catalog sweep +// 2026-10-24): the rehearsal box's off-site copy dated 2026-10-28 07:39 beat the family's own copy of 2026-10-24 19:17 +// on a box whose clock said 2026-10-24. +// COMPANION RED-PROOF (REPORT.md): with the old KeptNewer (off-site when its date is after the local one's), +// TestR1271_AFutureOffsiteCopyNeverBeatsTheOwnCopy fails — the off-site copy is chosen. + +var r1271Now = time.Date(2026, 10, 24, 19, 22, 0, 0, time.UTC) + +func TestR1271_AFutureOffsiteCopyNeverBeatsTheOwnCopy(t *testing.T) { + local := KeptCopy{UnitDir: "/u", Tier: 1, Time: time.Date(2026, 10, 24, 19, 17, 0, 0, time.UTC)} + off := KeptCopy{Tier: KeptTierOffsite, Time: time.Date(2026, 10, 28, 6, 39, 0, 0, time.UTC), SnapshotID: "abba81ba"} + c, ok := KeptNewerAt(r1271Now, local, true, off, true) + if !ok || c.Tier != 1 || c.Future { + t.Fatalf("the household's own copy must win over a future-dated off-site one, got %+v", c) + } +} + +func TestR1271_AFutureCopyAloneIsOfferedButMarked(t *testing.T) { + off := KeptCopy{Tier: KeptTierOffsite, Time: time.Date(2026, 10, 28, 6, 39, 0, 0, time.UTC), SnapshotID: "abba81ba"} + c, ok := KeptNewerAt(r1271Now, KeptCopy{}, false, off, true) + if !ok || c.Tier != KeptTierOffsite || !c.Future { + t.Fatalf("a future-dated copy alone is offered and marked Future (not recommended), got %+v ok=%v", c, ok) + } +} + +func TestR1271_AnOlderOffsiteCopyAloneIsChosenAsBefore(t *testing.T) { + off := KeptCopy{Tier: KeptTierOffsite, Time: time.Date(2026, 10, 20, 4, 15, 0, 0, time.UTC), SnapshotID: "s1"} + c, ok := KeptNewerAt(r1271Now, KeptCopy{}, false, off, true) + if !ok || c.Tier != KeptTierOffsite || c.Future { + t.Fatalf("a normal older off-site copy alone is chosen as before, got %+v", c) + } +} + +// The other direction of the old rule still holds: a NEWER off-site copy with a true date beats an older local one. +func TestR1271_ANewerTrueOffsiteCopyStillWins(t *testing.T) { + local := KeptCopy{UnitDir: "/u", Tier: 1, Time: time.Date(2026, 10, 20, 4, 0, 0, 0, time.UTC)} + off := KeptCopy{Tier: KeptTierOffsite, Time: time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC), SnapshotID: "s2"} + if c, _ := KeptNewerAt(r1271Now, local, true, off, true); c.Tier != KeptTierOffsite { + t.Fatalf("a newer off-site copy with a true date wins, got %+v", c) + } + // A tie goes local (no download). + off.Time = local.Time + if c, _ := KeptNewerAt(r1271Now, local, true, off, true); c.Tier != 1 { + t.Fatalf("a tie goes local, got %+v", c) + } +} + +// The skew allowance: a few minutes ahead is a true date; beyond KeptClockSkew it is not. +func TestR1271_TheClockSkewAllowance(t *testing.T) { + local := KeptCopy{UnitDir: "/u", Tier: 1, Time: r1271Now.Add(-time.Hour)} + near := KeptCopy{Tier: KeptTierOffsite, Time: r1271Now.Add(KeptClockSkew - time.Minute), SnapshotID: "n"} + if c, _ := KeptNewerAt(r1271Now, local, true, near, true); c.Tier != KeptTierOffsite { + t.Fatalf("a copy within the skew is a true date and newer, got %+v", c) + } + far := KeptCopy{Tier: KeptTierOffsite, Time: r1271Now.Add(KeptClockSkew + time.Minute), SnapshotID: "f"} + if c, _ := KeptNewerAt(r1271Now, local, true, far, true); c.Tier != 1 { + t.Fatalf("a copy beyond the skew is future-dated and never the newest, got %+v", c) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 255121e9..76262d2c 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2864,5 +2864,7 @@ "err.backup.restore_not_started": "%s: we loaded the data back, but the app has not started yet. Wait a few minutes, then open it again. If it stays like this, tell us.", "note.restore.late_started": "%s has started: the restore is done.", "err.backup.restore_image_gone": "The version of this backup (%s) cannot be downloaded: its maker no longer offers it. We did not touch the app. Choose another backup.", - "err.backup.restore_image_unreachable": "The version of this backup (%s) cannot be downloaded right now, so we did not touch the app. Try again later, or choose another backup." + "err.backup.restore_image_unreachable": "The version of this backup (%s) cannot be downloaded right now, so we did not touch the app. Try again later, or choose another backup.", + "kept.backup.future_note": "(its date is in the future — the clock of the box that made it may have been wrong)", + "kept.choice.use.future": "Note: the date of this backup (%s) is in the future. The clock of the box that made it may have been wrong. Choose it only if you are sure it is your backup." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 9c8214f6..309f248f 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2852,5 +2852,7 @@ "err.backup.restore_not_started": "A(z) %s: az adatokat visszatöltöttük, de az alkalmazás még nem indult el. Várj pár percet, majd nyisd meg újra. Ha így marad, szólj nekünk.", "note.restore.late_started": "A(z) %s elindult: a visszaállítás kész.", "err.backup.restore_image_gone": "Ennek a mentésnek a verzióját (%s) nem lehet letölteni: a készítője már nem kínálja. Az alkalmazáshoz nem nyúltunk. Válassz egy másik mentést.", - "err.backup.restore_image_unreachable": "Ennek a mentésnek a verzióját (%s) most nem lehet letölteni, ezért az alkalmazáshoz nem nyúltunk. Próbáld újra később, vagy válassz egy másik mentést." + "err.backup.restore_image_unreachable": "Ennek a mentésnek a verzióját (%s) most nem lehet letölteni, ezért az alkalmazáshoz nem nyúltunk. Próbáld újra később, vagy válassz egy másik mentést.", + "kept.backup.future_note": "(a dátuma a jövőben van — a mentést készítő doboz órája rosszul járhatott)", + "kept.choice.use.future": "Figyelem: ennek a mentésnek a dátuma (%s) a jövőben van. A mentést készítő doboz órája rosszul járhatott. Csak akkor válaszd, ha biztosan ez a ti mentésetek." } diff --git a/controller/internal/web/kept_handlers.go b/controller/internal/web/kept_handlers.go index 9b8396bb..f1bebd1e 100644 --- a/controller/internal/web/kept_handlers.go +++ b/controller/internal/web/kept_handlers.go @@ -80,18 +80,27 @@ func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]b // A dated folder's own unit is the copy taken with those files. if it.UnitDir != "" { if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok { - return s.msgLang(lang, "kept.backup.with", fmtLocalTime(lang, c.Time)), c, true + name := s.msgLang(lang, "kept.backup.with", fmtLocalTime(lang, c.Time)) + if backup.KeptFuture(c, time.Now()) { // R-1271: say it, never hide it + c.Future = true + name += " " + s.msgLang(lang, "kept.backup.future_note") + } + return name, c, true } } } else { local, lok = s.backupMgr.KeptDBCopy(it.App, it.Drive) } oc, ook := off[it.App] - c, ok := backup.KeptNewer(local, lok, oc, ook && it.App != "") + c, ok := backup.KeptNewerAt(time.Now(), local, lok, oc, ook && it.App != "") if !ok { return none, backup.KeptCopy{}, false } - return s.msgLang(lang, backup.KeptCopyKey(c.Tier), fmtLocalTime(lang, c.Time)), c, true + name := s.msgLang(lang, backup.KeptCopyKey(c.Tier), fmtLocalTime(lang, c.Time)) + if c.Future { // R-1271: the only copy, dated in the future — offered, and said + name += " " + s.msgLang(lang, "kept.backup.future_note") + } + return name, c, true } // keptOffsite asks the off-site repository ONCE for every listed app. diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 56fcf0d2..9e9eaaeb 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -147,9 +147,9 @@ function showKeptChoice(d, form) { function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } box.appendChild(el('h3', 'kept-choice-title', d.title)); box.appendChild(el('p', 'kept-choice-body', d.body)); - function option(label, desc, choice, enabled, extra) { + function option(label, desc, choice, enabled, extra, plain) { var o = el('div', 'kept-choice-option'); - var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + var b = el('button', enabled && !plain ? 'btn btn-primary' : 'btn btn-outline', label); b.type = 'button'; b.disabled = !enabled; b.setAttribute('data-kept-choice', choice); @@ -159,7 +159,7 @@ function showKeptChoice(d, form) { if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); return o; } - box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, d.use_warn || '', !!d.use_future)); box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); box.hidden = false; box.scrollIntoView({behavior: 'smooth'}); diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index d22831c4..4d732be6 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -501,9 +501,9 @@ function showKeptChoice(d, form) { function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } box.appendChild(el('h3', 'kept-choice-title', d.title)); box.appendChild(el('p', 'kept-choice-body', d.body)); - function option(label, desc, choice, enabled, extra) { + function option(label, desc, choice, enabled, extra, plain) { var o = el('div', 'kept-choice-option'); - var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + var b = el('button', enabled && !plain ? 'btn btn-primary' : 'btn btn-outline', label); b.type = 'button'; b.disabled = !enabled; b.setAttribute('data-kept-choice', choice); @@ -513,7 +513,7 @@ function showKeptChoice(d, form) { if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); return o; } - box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, d.use_warn || '', !!d.use_future)); box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); box.hidden = false; box.scrollIntoView({behavior: 'smooth'}); diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked_r1125.html b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked_r1125.html index f2eccb2f..a0f507ac 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked_r1125.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked_r1125.html @@ -457,9 +457,9 @@ function showKeptChoice(d, form) { function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } box.appendChild(el('h3', 'kept-choice-title', d.title)); box.appendChild(el('p', 'kept-choice-body', d.body)); - function option(label, desc, choice, enabled, extra) { + function option(label, desc, choice, enabled, extra, plain) { var o = el('div', 'kept-choice-option'); - var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + var b = el('button', enabled && !plain ? 'btn btn-primary' : 'btn btn-outline', label); b.type = 'button'; b.disabled = !enabled; b.setAttribute('data-kept-choice', choice); @@ -469,7 +469,7 @@ function showKeptChoice(d, form) { if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); return o; } - box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, d.use_warn || '', !!d.use_future)); box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); box.hidden = false; box.scrollIntoView({behavior: 'smooth'}); diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked_r1125.html b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked_r1125.html index 1bd57dc4..73e4ab9c 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked_r1125.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked_r1125.html @@ -479,9 +479,9 @@ function showKeptChoice(d, form) { function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; } box.appendChild(el('h3', 'kept-choice-title', d.title)); box.appendChild(el('p', 'kept-choice-body', d.body)); - function option(label, desc, choice, enabled, extra) { + function option(label, desc, choice, enabled, extra, plain) { var o = el('div', 'kept-choice-option'); - var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label); + var b = el('button', enabled && !plain ? 'btn btn-primary' : 'btn btn-outline', label); b.type = 'button'; b.disabled = !enabled; b.setAttribute('data-kept-choice', choice); @@ -491,7 +491,7 @@ function showKeptChoice(d, form) { if (extra) o.appendChild(el('p', 'kept-choice-note', extra)); return o; } - box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, '')); + box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, d.use_warn || '', !!d.use_future)); box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up)); box.hidden = false; box.scrollIntoView({behavior: 'smooth'}); diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 013b2656..8e41369c 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -326,7 +326,9 @@ "err.backup.restore_not_started": "BORN AS A KEY (R-1270, brief 2026-10-25 Part 3) -- a restore whose data is back but whose app did not start in time is never a success; its sentence, and the late start that turns it into one. Pinned by internal/backup/restore_verdict_test.go TestR1270_*.", "note.restore.late_started": "BORN AS A KEY (R-1270, brief 2026-10-25 Part 3) -- a restore whose data is back but whose app did not start in time is never a success; its sentence, and the late start that turns it into one. Pinned by internal/backup/restore_verdict_test.go TestR1270_*.", "err.backup.restore_image_gone": "BORN AS A KEY (D48, operator ruling 2026-10-25; 09 decision 257) -- a restore downloads first: the refusal when an image of the backup's version cannot be downloaded (deleted by its maker, or the registry busy or unreachable); the app untouched. Pinned by internal/backup/restore_pull_test.go TestD48_*.", - "err.backup.restore_image_unreachable": "BORN AS A KEY (D48, operator ruling 2026-10-25; 09 decision 257) -- a restore downloads first: the refusal when an image of the backup's version cannot be downloaded (deleted by its maker, or the registry busy or unreachable); the app untouched. Pinned by internal/backup/restore_pull_test.go TestD48_*." + "err.backup.restore_image_unreachable": "BORN AS A KEY (D48, operator ruling 2026-10-25; 09 decision 257) -- a restore downloads first: the refusal when an image of the backup's version cannot be downloaded (deleted by its maker, or the registry busy or unreachable); the app untouched. Pinned by internal/backup/restore_pull_test.go TestD48_*.", + "kept.backup.future_note": "BORN AS A KEY (R-1271, brief 2026-10-25 Part 4) -- a copy dated in the future is never the newest; when it is the only copy the kept-data question and the kept list say its date is in the future. Pinned by internal/backup/kept_pick_test.go and internal/api/r1271_future_copy_test.go.", + "kept.choice.use.future": "BORN AS A KEY (R-1271, brief 2026-10-25 Part 4) -- a copy dated in the future is never the newest; when it is the only copy the kept-data question and the kept list say its date is in the future. Pinned by internal/backup/kept_pick_test.go and internal/api/r1271_future_copy_test.go." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",