R-1271 (P2): „use my kept data” never takes a copy dated in the future — a copy more than 10 minutes past the box's clock never counts as the newest (the newest true date wins, tie local); alone it is still offered, said and not recommended. Red-proved. Commit only.
gates / gates (push) Successful in 1m33s
gates / gates (push) Successful in 1m33s
This commit is contained in:
@@ -22,6 +22,25 @@ brief 2026-10-24, Part 4 (commit only; the release rides the next normal train).
|
|||||||
`…OnlyWhenTheAgentWaitsAndAnAppRuns`, `…QBLimitRuleSkipsAHeldLimit`, `…TheTwoAppsReadAndWriteTheirLimit`,
|
`…OnlyWhenTheAgentWaitsAndAnAppRuns`, `…QBLimitRuleSkipsAHeldLimit`, `…TheTwoAppsReadAndWriteTheirLimit`,
|
||||||
`TestLineTestRunOutlastsTheClientTimeout`, `TestStartUpwatchRecoversTheHoldFirst`, `TestUpwatchHoldGetsBothAgentCalls`.
|
`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.<domain>`.
|
||||||
|
- **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
|
### 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
|
Operator ruling D48 (2026-10-25 08:38, `09` §3 decision 257): every restore path resolves and pulls every image its
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ type keptChoiceData struct {
|
|||||||
UseDesc string `json:"use_desc"` // "" when use is off
|
UseDesc string `json:"use_desc"` // "" when use is off
|
||||||
UseOff string `json:"use_off"` // "" when use is offered
|
UseOff string `json:"use_off"` // "" when use is offered
|
||||||
UseOffered bool `json:"use_offered"` // a database copy exists for these files
|
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"`
|
FreshLabel string `json:"fresh_label"`
|
||||||
FreshDesc string `json:"fresh_desc"`
|
FreshDesc string `json:"fresh_desc"`
|
||||||
NotBacked string `json:"not_backed_up"`
|
NotBacked string `json:"not_backed_up"`
|
||||||
@@ -71,13 +75,17 @@ func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, nam
|
|||||||
}
|
}
|
||||||
if cp.ok {
|
if cp.ok {
|
||||||
data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c))
|
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 {
|
} else {
|
||||||
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
|
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch choice {
|
switch choice {
|
||||||
case "":
|
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})
|
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data})
|
||||||
return true
|
return true
|
||||||
case stacks.KeptChoiceUse:
|
case stacks.KeptChoiceUse:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 and UnitPath name the off-site copy (Tier 3 only): the snapshot and the unit's path in it.
|
||||||
SnapshotID string
|
SnapshotID string
|
||||||
UnitPath 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.
|
// 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) {
|
if app == "" || m.isStackDeployed(app) {
|
||||||
return KeptCopy{}, false
|
return KeptCopy{}, false
|
||||||
}
|
}
|
||||||
var best KeptCopy
|
var cands []KeptCopy
|
||||||
found := false
|
|
||||||
consider := func(c KeptCopy, ok bool) {
|
consider := func(c KeptCopy, ok bool) {
|
||||||
if ok && (!found || c.Time.After(best.Time)) {
|
if ok {
|
||||||
best, found = c, true
|
cands = append(cands, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if u, ok := m.RemovedAppUnitFor(app); ok {
|
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) {
|
for _, d := range m.Tier2MirrorDirsForApp(app) {
|
||||||
consider(m.KeptCopyAt(tier2UnitDir(d), drive, 2))
|
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
|
// 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
|
return c, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeptNewer chooses between a local copy and the off-site copy: the off-site one only when it is NEWER or
|
// KeptNewerAt chooses between a local copy and the off-site copy at now: the off-site one only when it is NEWER by a
|
||||||
// the only one (a tie goes local — no download).
|
// true date, or the only one (a tie goes local — no download). R-1271: a future-dated copy never counts as newer.
|
||||||
func KeptNewer(local KeptCopy, lok bool, off KeptCopy, ook bool) (KeptCopy, bool) {
|
func KeptNewerAt(now time.Time, local KeptCopy, lok bool, off KeptCopy, ook bool) (KeptCopy, bool) {
|
||||||
if ook && (!lok || off.Time.After(local.Time)) {
|
var cands []KeptCopy
|
||||||
return off, true
|
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
|
// 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) {
|
func (m *Manager) KeptBestCopy(ctx context.Context, app, drive string) (KeptCopy, bool) {
|
||||||
local, lok := m.KeptDBCopy(app, drive)
|
local, lok := m.KeptDBCopy(app, drive)
|
||||||
off, ook := m.KeptOffsiteCopy(ctx, app)
|
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
|
// Refusals of an off-site load, born as bundle keys. Each is raised BEFORE prepare and before anything
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
"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.",
|
"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_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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"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_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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// A dated folder's own unit is the copy taken with those files.
|
||||||
if it.UnitDir != "" {
|
if it.UnitDir != "" {
|
||||||
if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok {
|
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 {
|
} else {
|
||||||
local, lok = s.backupMgr.KeptDBCopy(it.App, it.Drive)
|
local, lok = s.backupMgr.KeptDBCopy(it.App, it.Drive)
|
||||||
}
|
}
|
||||||
oc, ook := off[it.App]
|
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 {
|
if !ok {
|
||||||
return none, backup.KeptCopy{}, false
|
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.
|
// keptOffsite asks the off-site repository ONCE for every listed app.
|
||||||
|
|||||||
@@ -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; }
|
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('h3', 'kept-choice-title', d.title));
|
||||||
box.appendChild(el('p', 'kept-choice-body', d.body));
|
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 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.type = 'button';
|
||||||
b.disabled = !enabled;
|
b.disabled = !enabled;
|
||||||
b.setAttribute('data-kept-choice', choice);
|
b.setAttribute('data-kept-choice', choice);
|
||||||
@@ -159,7 +159,7 @@ function showKeptChoice(d, form) {
|
|||||||
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
||||||
return o;
|
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.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
box.scrollIntoView({behavior: 'smooth'});
|
box.scrollIntoView({behavior: 'smooth'});
|
||||||
|
|||||||
@@ -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; }
|
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('h3', 'kept-choice-title', d.title));
|
||||||
box.appendChild(el('p', 'kept-choice-body', d.body));
|
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 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.type = 'button';
|
||||||
b.disabled = !enabled;
|
b.disabled = !enabled;
|
||||||
b.setAttribute('data-kept-choice', choice);
|
b.setAttribute('data-kept-choice', choice);
|
||||||
@@ -513,7 +513,7 @@ function showKeptChoice(d, form) {
|
|||||||
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
||||||
return o;
|
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.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
box.scrollIntoView({behavior: 'smooth'});
|
box.scrollIntoView({behavior: 'smooth'});
|
||||||
|
|||||||
@@ -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; }
|
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('h3', 'kept-choice-title', d.title));
|
||||||
box.appendChild(el('p', 'kept-choice-body', d.body));
|
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 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.type = 'button';
|
||||||
b.disabled = !enabled;
|
b.disabled = !enabled;
|
||||||
b.setAttribute('data-kept-choice', choice);
|
b.setAttribute('data-kept-choice', choice);
|
||||||
@@ -469,7 +469,7 @@ function showKeptChoice(d, form) {
|
|||||||
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
||||||
return o;
|
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.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
box.scrollIntoView({behavior: 'smooth'});
|
box.scrollIntoView({behavior: 'smooth'});
|
||||||
|
|||||||
+3
-3
@@ -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; }
|
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('h3', 'kept-choice-title', d.title));
|
||||||
box.appendChild(el('p', 'kept-choice-body', d.body));
|
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 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.type = 'button';
|
||||||
b.disabled = !enabled;
|
b.disabled = !enabled;
|
||||||
b.setAttribute('data-kept-choice', choice);
|
b.setAttribute('data-kept-choice', choice);
|
||||||
@@ -491,7 +491,7 @@ function showKeptChoice(d, form) {
|
|||||||
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
|
||||||
return o;
|
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.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
box.scrollIntoView({behavior: 'smooth'});
|
box.scrollIntoView({behavior: 'smooth'});
|
||||||
|
|||||||
@@ -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_*.",
|
"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_*.",
|
"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_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.already_on": "A megosztás már be van kapcsolva.",
|
||||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||||
|
|||||||
Reference in New Issue
Block a user