From 3f048e042b780a8d38e6beeaefd7ab44a943dfdc Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 28 Jul 2026 16:36:47 +0200 Subject: [PATCH] R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction. --- CHANGELOG.md | 47 +++++ controller/cmd/controller/main.go | 4 +- controller/internal/backup/offbox.go | 120 ++++++++++++ .../internal/backup/offsite_diag_test.go | 121 ++++++++++++ controller/internal/backup/tier2.go | 132 ++++++++----- .../internal/backup/tier2_anchor_test.go | 174 ++++++++++++++++++ controller/internal/settings/settings.go | 23 ++- controller/internal/web/funcmap.go | 12 ++ controller/internal/web/handlers.go | 35 +++- .../internal/web/templates/backups_apps.html | 35 +++- controller/internal/web/tier2_honesty_test.go | 169 +++++++++++++++++ controller/internal/web/tier2_legacy.go | 29 +++ 12 files changed, 829 insertions(+), 72 deletions(-) create mode 100644 controller/internal/backup/offsite_diag_test.go create mode 100644 controller/internal/backup/tier2_anchor_test.go create mode 100644 controller/internal/web/tier2_honesty_test.go create mode 100644 controller/internal/web/tier2_legacy.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fcf5b..23c1649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ ## Changelog +### v0.182.0 — R-101 + F-DIAG: the customer must not be told a failed backup is a copy (2026-07-28) + +**R-101.** `Tier2LastRun` is the ATTEMPT clock — `recordTier2Failure` writes it too — and it was +rendered as „Legutóbbi másolat" in the **restore confirm dialog**. That is misinformation at a decision +point, not an alarm bug: the restore it guards fills in MISSING files without touching existing ones, +so a customer whose Tier-2 had been failing was told a copy existed from last night, restored, and +silently received **older** files while believing they were recent. + +`CrossDriveBackup` gains `LastSuccess` (same rule and shape as the offsite anchor) plus +`SuccessTracked`, which distinguishes "this row predates the anchor" from "this row has one and it is +empty". Without that marker the two are indistinguishable and **all 7 Tier-2 rows on the fleet** would +have flipped to „Még nincs sikeres másolat" on deploy. Legacy rows are migrated truthfully on first +touch: a row whose last known state was `ok` adopts that time; a row whose last state was `error` seeds +nothing, because the old data evidences no success. + +Shipped strings: „Legutóbbi sikeres másolat: {dátum}" · „…Figyelem: a legutóbbi mentési kísérlet nem +sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek." · „Utolsó sikeres: {relatív}" · +„Még nincs sikeres másolat" with the restore replaced by „Még nincs sikeres másolat, amiből vissza +lehetne állítani." The dialog also stops printing a raw UTC RFC3339 stamp — new `fmtTimeStr` renders +Budapest-local `2026-07-25 03:30`. + +**Part 2 — the copy-site hazard, and it was in the path.** The three `record*` helpers each built a +WHOLE `CrossDriveBackup` literal with a helper re-applying exactly two fields; everything else was +zeroed on every status write. Adding `LastSuccess` to that shape would have had `recordTier2Failure` +**clear** it — the mirror image of the defect, firing on the first failure. Replaced with +`tier2Update`, which copies the existing row and overlays the outcome: **safe by construction**, a new +field carries over unless deliberately overwritten. Sweep: `SetTier2Preference` mutates in place +(safe); `SetCrossDriveConfig(name, nil)` is a deliberate delete. + +**F-DIAG.** The offsite failure notification was `"…: " + err.Error()` — one string for every cause AND +a raw passthrough. `ClassifyOffsiteFailure` now returns quota / orphaned / no_repo / no_units / +transport / **unknown** (unclassifiable says so rather than being folded into a neighbour), each with +its own Hungarian message. + +**The secrets half caught a bug in my own first attempt.** The initial sanitiser regex-matched +`sftp:…` and `user@host` and looked complete; its own test caught it leaking on +`ssh: connect to host port 23: Connection refused`, a bare hostname in neither shape. It now +redacts the target's **actual** host/user/repo-path literally, with the regex kept only as a backstop — +guessing at what a secret looks like fails exactly where it matters. + +Red-proofs, all observed failing: dialog back on the attempt clock → `the dialog does not name the last +SUCCESSFUL copy`; gate the restore on `LastRun` → `a tier that has NEVER succeeded still offers a +restore`; make the caution unconditional → `a HEALTHY tier shows the failed-attempt caution`; clear the +anchor on failure → `a FAILED run wiped the success anchor`; raw sanitiser → `the repo reference reached +the message ("sftp:" leaked)`. + + ### v0.181.0 — R-100: record the last SUCCESS, not just the last attempt (2026-07-28) The producer half of R-100. `OffboxTarget` gains **`LastSuccess`** (RFC3339), carried to the hub on the diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 4ff5c5c..0539043 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -654,8 +654,10 @@ func main() { // error) alerts the operator via the allowlisted backup_failed event. Daily after Tier 2. backupMgr.SetOffboxNotify(func(dur time.Duration, snapshots int, err error) { if err != nil { + // F-DIAG: a distinct message per cause, and the detail SANITISED — a raw err.Error() + // carries the sftp:user@host:/path repo reference off the box. notifier.NotifyBackupFailed("Off-box (NAS) mentés sikertelen", - "a NAS-ra mentés hibázott ("+dur.Round(time.Second).String()+"): "+err.Error()) + backupMgr.OffsiteFailureMessage(err, dur)) } }) // 3a: the pre-push enlargement gate blocked an app's userdata push (config+DB still saved). Edge- diff --git a/controller/internal/backup/offbox.go b/controller/internal/backup/offbox.go index 884a00d..681e205 100644 --- a/controller/internal/backup/offbox.go +++ b/controller/internal/backup/offbox.go @@ -92,6 +92,126 @@ func classifyResticProbe(out []byte, err error) string { } } +// ── F-DIAG: four causes, four messages, and no secrets ────────────────────────────────────────── +// +// The offsite failure notification was a raw passthrough: +// +// "a NAS-ra mentés hibázott (): " + err.Error() +// +// One string for every cause, so an operator could not tell a full disk from a dead network without +// reading logs — AND a raw restic/ssh error carries the repo URL, which is built as +// `sftp:@:` (offboxBaseArgs). That breaks this project's keys-not-values rule at the +// one place the text leaves the box. +// +// OffsiteFailureClass names the causes that are genuinely DISTINGUISHABLE where the error is produced. +// Nothing is invented: each maps to a signal the code already has. +type OffsiteFailureClass string + +const ( + OffsiteFailQuota OffsiteFailureClass = "quota" // the pre-run soft-quota gate refused (offbox.go quota state) + OffsiteFailOrphaned OffsiteFailureClass = "orphaned" // ErrOffboxOrphaned — repo keyed under a lost passphrase + OffsiteFailNoRepo OffsiteFailureClass = "no_repo" // classifyResticProbe "norepo" — nothing at the location + OffsiteFailNoUnits OffsiteFailureClass = "no_units" // apps toggled but no recovery unit found on any drive + OffsiteFailTransport OffsiteFailureClass = "transport" // network / SFTP auth / host key / timeout + OffsiteFailUnknown OffsiteFailureClass = "unknown" // genuinely unclassified — say so rather than guess +) + +// offsiteRepoURLRe matches the `sftp:user@host:/path` repo reference restic echoes back in its errors. +// It is the BACKSTOP, not the primary defence — see sanitiseOffsiteErrorFor. +var offsiteRepoURLRe = regexp.MustCompile(`sftp:[^\s"']+`) + +// sanitiseOffsiteErrorFor strips anything that could carry a secret or a customer-identifying location +// out of an error before it reaches a message, an event or a report. +// +// IT REDACTS THE KNOWN TARGET VALUES, not a guessed pattern. The first version of this function +// regex-matched `sftp:…` and `user@host` and looked complete; its own test caught it leaking on +// `ssh: connect to host port 23: Connection refused`, which contains a BARE hostname in neither +// shape. Guessing at what a secret looks like fails exactly where it matters — the target's host, user +// and repo path are known here, so they are removed literally and the regex stays only as a backstop +// for forms built before the target is loaded. +// +// Whole-token replacement, not masking: a partially-masked host still identifies the customer, and +// "it looked masked" is how a leak survives review. +func sanitiseOffsiteErrorFor(t *settings.OffboxTarget, err error) string { + if err == nil { + return "" + } + out := offsiteRepoURLRe.ReplaceAllString(err.Error(), "") + if t != nil { + // Longest first, so the repo path is not half-eaten by the host replacement. + for _, v := range []string{t.RepoPath, t.Host, t.User} { + if len(strings.TrimSpace(v)) >= 3 { + out = strings.ReplaceAll(out, v, "") + } + } + } + if len(out) > 300 { + out = out[:300] + "…" + } + return out +} + +// ClassifyOffsiteFailure maps a run error to its cause. +// +// Order matters: the explicit sentinels first, then the text signatures. A cause that cannot be told +// apart here returns OffsiteFailUnknown rather than being folded into a neighbour — inventing a +// precision the code does not have is how a confident-but-wrong diagnosis ships. +func ClassifyOffsiteFailure(err error) OffsiteFailureClass { + if err == nil { + return "" + } + if errors.Is(err, ErrOffboxOrphaned) { + return OffsiteFailOrphaned + } + s := strings.ToLower(err.Error()) + switch { + case strings.Contains(s, "tárhelykeretet"): + return OffsiteFailQuota + case strings.Contains(s, "produced no snapshots"): + return OffsiteFailNoUnits + case strings.Contains(s, "unable to open config file"), + strings.Contains(s, "is there a repository at the following location"): + return OffsiteFailNoRepo + case strings.Contains(s, "connection refused"), strings.Contains(s, "connection reset"), + strings.Contains(s, "no route to host"), strings.Contains(s, "i/o timeout"), + strings.Contains(s, "timed out"), strings.Contains(s, "permission denied"), + strings.Contains(s, "host key"), strings.Contains(s, "handshake"), + strings.Contains(s, "could not resolve"), strings.Contains(s, "network is unreachable"): + return OffsiteFailTransport + default: + return OffsiteFailUnknown + } +} + +// OffsiteFailureMessage returns the operator-facing Hungarian message for a run failure: a distinct +// cause line plus the SANITISED detail. The detail is kept because an operator needs something to act +// on; it is sanitised because this text leaves the box. +// +// A method, not a function, so it can reach the target and redact its ACTUAL host/user/path rather +// than pattern-matching at what those might look like. +func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string { + var t *settings.OffboxTarget + if m != nil && m.settings != nil { + t = m.settings.GetOffboxTarget() + } + return offsiteFailureMessage(t, err, dur) +} + +func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string { + head := map[OffsiteFailureClass]string{ + OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül", + OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült", + OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár", + OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése", + OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)", + OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült", + }[ClassifyOffsiteFailure(err)] + if head == "" { + head = "A távoli mentés nem sikerült" + } + return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err)) +} + func defaultOffboxSSH(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) { if port == 0 { port = 22 diff --git a/controller/internal/backup/offsite_diag_test.go b/controller/internal/backup/offsite_diag_test.go new file mode 100644 index 0000000..af82b82 --- /dev/null +++ b/controller/internal/backup/offsite_diag_test.go @@ -0,0 +1,121 @@ +package backup + +import ( + "fmt" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// the real demo-hp target shape — the values the sanitiser must remove literally +func diagTarget() *settings.OffboxTarget { + return &settings.OffboxTarget{ + Host: "u629488-sub3.your-storagebox.de", User: "u629488-sub3", + RepoPath: "/home/felhom-repo", Port: 23, + } +} + +// F-DIAG — four causes collapsed into one string, and that string was a RAW error passthrough. +// +// Two separate defects in one line of code: +// - an operator could not tell a full quota from a dead network without reading logs; +// - `err.Error()` from restic/ssh carries the repo reference `sftp:@:`, so the +// notification carried a customer-identifying location (and potentially a credential) off the box, +// breaking the keys-not-values rule at the one place the text leaves the machine. + +func TestClassifyOffsiteFailure_EachCauseIsDistinct(t *testing.T) { + cases := []struct { + name string + err error + want OffsiteFailureClass + }{ + {"quota gate", fmt.Errorf("A távoli mentés túllépte a tárhelykeretet (51/50 GB) — törölj régi mentéseket vagy kérj nagyobb keretet."), OffsiteFailQuota}, + {"orphaned repo", fmt.Errorf("probe: %w", ErrOffboxOrphaned), OffsiteFailOrphaned}, + {"no repo", fmt.Errorf("restic: unable to open config file: Stat: file does not exist\nIs there a repository at the following location?"), OffsiteFailNoRepo}, + {"no units", fmt.Errorf("off-box backup produced no snapshots: 3 app(s) toggled but no recovery unit was found on any connected drive (missing: a, b, c)"), OffsiteFailNoUnits}, + {"transport refused", fmt.Errorf("dial tcp 1.2.3.4:23: connect: connection refused"), OffsiteFailTransport}, + {"transport timeout", fmt.Errorf("ssh: handshake failed: i/o timeout"), OffsiteFailTransport}, + {"transport auth", fmt.Errorf("ssh: permission denied (publickey)"), OffsiteFailTransport}, + {"unclassified", fmt.Errorf("restic: some future error nobody has seen"), OffsiteFailUnknown}, + } + seen := map[OffsiteFailureClass]bool{} + for _, c := range cases { + got := ClassifyOffsiteFailure(c.err) + if got != c.want { + t.Errorf("%s: class = %q, want %q", c.name, got, c.want) + } + seen[got] = true + } + // The whole point of F-DIAG: the causes must not collapse. + if len(seen) < 5 { + t.Errorf("only %d distinct classes across %d causes — the causes are still collapsing", len(seen), len(cases)) + } +} + +// An unclassifiable error must say so rather than being folded into a neighbour. Inventing a precision +// the code does not have is how a confident-but-wrong diagnosis ships. +func TestClassifyOffsiteFailure_UnknownIsHonest(t *testing.T) { + if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown { + t.Errorf("an unclassifiable error was folded into %q instead of being reported as unknown", got) + } + msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute) + if !strings.Contains(msg, "ismeretlen okból") { + t.Errorf("the unknown case does not admit it is unknown: %q", msg) + } +} + +// THE SECRETS TEST. The repo reference must never survive into a message. +// +// RED-PROOF: make sanitiseOffsiteError return err.Error() unchanged → this fails with +// "the repo reference reached the message". +func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) { + leaky := []error{ + fmt.Errorf(`Fatal: unable to open repository at sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo: connection refused`), + fmt.Errorf(`ssh: connect to host u629488-sub3.your-storagebox.de port 23: Connection refused`), + fmt.Errorf(`restic: repo "sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo" locked`), + } + for _, e := range leaky { + msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second) + for _, forbidden := range []string{ + "sftp:", + "your-storagebox.de", + "u629488-sub3", + "/home/felhom-repo", + } { + if strings.Contains(msg, forbidden) { + t.Errorf("the repo reference reached the message (%q leaked):\n %s", forbidden, msg) + } + } + if !strings.Contains(msg, "") { + t.Errorf("the redaction placeholder is absent — the detail may have been dropped silently instead of sanitised:\n %s", msg) + } + } +} + +// The message must still be ACTIONABLE. Sanitising must not reduce it to a shrug — an operator needs +// the cause line plus enough residual detail to act. +func TestOffsiteFailureMessage_StaysActionable(t *testing.T) { + msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second) + if !strings.Contains(msg, "nem érhető el") { + t.Errorf("the transport cause is not named: %q", msg) + } + if !strings.Contains(msg, "connection refused") { + t.Errorf("all actionable detail was stripped along with the secret: %q", msg) + } + if !strings.Contains(msg, "1m30s") { + t.Errorf("the duration was lost: %q", msg) + } +} + +// A very long error must be bounded — an unbounded restic dump in an email is its own problem. +func TestSanitiseOffsiteError_IsBounded(t *testing.T) { + long := fmt.Errorf("%s", strings.Repeat("x", 5000)) + if got := sanitiseOffsiteErrorFor(diagTarget(), long); len(got) > 320 { + t.Errorf("sanitised error is %d chars — unbounded", len(got)) + } + if sanitiseOffsiteErrorFor(diagTarget(), nil) != "" { + t.Error("a nil error produced text") + } +} diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 3b2e11a..e608bb8 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -530,67 +530,97 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info { // --- status persistence (drives the "2. mentés" UI card) --- -// withTier2Prefs carries the customer-preference fields (UserDisabled/PreferredTarget) from any -// existing config into a freshly-built status struct, so a runner status write never clobbers them. -func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBackup) *settings.CrossDriveBackup { - if m.settings != nil { - if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil { - cfg.UserDisabled = existing.UserDisabled - cfg.PreferredTarget = existing.PreferredTarget - } +// tier2Update applies a run outcome onto a COPY OF THE EXISTING ROW, then persists it. +// +// R-101 Part 2 — SAFE BY CONSTRUCTION, and this replaced a real hazard rather than tidying one. The +// three record* helpers each used to build a WHOLE `CrossDriveBackup` literal, with `withTier2Prefs` +// re-applying exactly two fields (UserDisabled, PreferredTarget). Every other field not named in the +// literal was silently zeroed on every status write. That is fine while the struct is stable and +// catastrophic the moment a field is added: R-101 adds `LastSuccess`, and under the old shape +// `recordTier2Failure` would have CLEARED it — the mirror-image of the defect being fixed, firing on +// the first failure instead of lying dormant. +// +// Starting from the existing row inverts the default: a new field carries over unless a caller +// deliberately overwrites it. The compile-safe form the R-100 review asked for; nothing is preserved +// by a list that can fall out of date. +// +// Callers must therefore CLEAR explicitly what a run invalidates (a stale LastError on success, a +// stale size on failure) — the old behaviour those clears reproduce is preserved exactly. +func (m *Manager) tier2Update(stackName string, mutate func(*settings.CrossDriveBackup)) { + if m.settings == nil { + return + } + var cfg settings.CrossDriveBackup + if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil { + cfg = *existing // value copy — EVERY field carries over by default + } + // One-time migration of a pre-anchor row. Under the old code `LastStatus=="ok"` with a LastRun + // means that run DID succeed, so adopting it as the initial anchor is truthful — and it is what + // keeps the deploy quiet: without it every existing row would flip to "never succeeded" at once + // (all 7 rows on the fleet were pre-anchor). A row whose last known state was an ERROR seeds + // nothing, because nothing in the old data evidences a success. + if !cfg.SuccessTracked { + if cfg.LastStatus == "ok" && cfg.LastRun != "" { + cfg.LastSuccess = cfg.LastRun + } + cfg.SuccessTracked = true + } + mutate(&cfg) + if err := m.settings.SetCrossDriveConfig(stackName, &cfg); err != nil { + m.logger.Printf("[WARN] [backup] Tier 2 status persist for %s failed: %v", stackName, err) } - return cfg } func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, warning string, dur time.Duration) { - if m.settings == nil { - return - } - if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ - Enabled: true, - Method: "rsync", - DestinationPath: target.NamespaceRoot, - Schedule: "daily", - LastRun: time.Now().Format(time.RFC3339), - LastStatus: "ok", - LastWarning: strings.TrimSpace(warning), - LastDuration: dur.Round(time.Second).String(), - LastSizeHuman: humanizeBytes(sizeBytes), - })); err != nil { - m.logger.Printf("[WARN] [backup] Tier 2 status persist (ok) for %s failed: %v", stackName, err) - } + now := time.Now().Format(time.RFC3339) + m.tier2Update(stackName, func(c *settings.CrossDriveBackup) { + c.Enabled = true + c.Method = "rsync" + c.DestinationPath = target.NamespaceRoot + c.Schedule = "daily" + c.LastRun = now + // R-101: the anchor. Only this branch advances it; no failure branch clears it. + c.LastSuccess = now + c.LastStatus = "ok" + c.LastWarning = strings.TrimSpace(warning) + c.LastDuration = dur.Round(time.Second).String() + c.LastSizeHuman = humanizeBytes(sizeBytes) + c.LastError = "" // a success invalidates the previous error + }) } func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) { - if m.settings == nil { - return - } - if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ - Enabled: true, - Method: "rsync", - DestinationPath: target.NamespaceRoot, - Schedule: "daily", - LastRun: time.Now().Format(time.RFC3339), - LastStatus: "error", - LastError: cause.Error(), - })); err != nil { - m.logger.Printf("[WARN] [backup] Tier 2 status persist (error) for %s failed: %v", stackName, err) - } + m.tier2Update(stackName, func(c *settings.CrossDriveBackup) { + c.Enabled = true + c.Method = "rsync" + c.DestinationPath = target.NamespaceRoot + c.Schedule = "daily" + c.LastRun = time.Now().Format(time.RFC3339) // the ATTEMPT clock — advances on failure, by design + c.LastStatus = "error" + c.LastError = cause.Error() + // LastSuccess is deliberately UNTOUCHED: a failure neither advances nor clears the anchor. + // Clearing it would make one bad night read as "no copy has ever succeeded". + c.LastWarning = "" // a warning from the last successful run does not describe this one + c.LastDuration = "" // preserving the old literal's clears exactly + c.LastSizeHuman = "" // ditto — a stale size would describe a copy this run did not make + }) } func (m *Manager) recordTier2NoTarget(stackName, reason string) { - if m.settings == nil { - return - } - if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ - Enabled: false, - Method: "rsync", - Schedule: "daily", - LastStatus: "no_target", - LastError: reason, - })); err != nil { - m.logger.Printf("[WARN] [backup] Tier 2 status persist (no_target) for %s failed: %v", stackName, err) - } + m.tier2Update(stackName, func(c *settings.CrossDriveBackup) { + c.Enabled = false + c.Method = "rsync" + c.Schedule = "daily" + c.DestinationPath = "" + c.LastStatus = "no_target" + c.LastError = reason + c.LastRun = "" + // LastSuccess survives: "there is no destination drive right now" is not evidence that the + // last successful copy never happened. The UI gates on LastRun here, so nothing is rendered. + c.LastWarning = "" + c.LastDuration = "" + c.LastSizeHuman = "" + }) } func tier2NoTargetReason(err error) string { diff --git a/controller/internal/backup/tier2_anchor_test.go b/controller/internal/backup/tier2_anchor_test.go new file mode 100644 index 0000000..1120f4b --- /dev/null +++ b/controller/internal/backup/tier2_anchor_test.go @@ -0,0 +1,174 @@ +package backup + +import ( + "errors" + "io" + "log" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-101 Part 2 — THE COPY-SITE HAZARD, exercised through the REAL record* helpers. +// +// The three record* helpers used to build a WHOLE CrossDriveBackup literal, with a helper re-applying +// exactly two fields. Anything not named in the literal was zeroed on every status write. Adding +// LastSuccess to that shape would have had `recordTier2Failure` CLEAR the anchor — the mirror image of +// the defect being fixed, and firing on the FIRST failure rather than lying dormant. +// +// The R-100 lesson applies: these call the production functions. A test that modelled the copy in a +// closure would stay green through any mutation of the real code. + +func anchorMgr(t *testing.T) *Manager { + t.Helper() + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("settings.Load: %v", err) + } + return &Manager{settings: sett, logger: log.New(io.Discard, "", 0)} +} + +func tgt() *Tier2Target { return &Tier2Target{NamespaceRoot: "/mnt/hdd_1/felhom-data/calibre-web"} } + +// SCENARIO F — a FAILURE must not wipe the anchor. This is the Part 2 proof and it runs the real +// recordTier2Success → recordTier2Failure sequence. +// +// RED-PROOF: make recordTier2Failure build a whole literal again (or add `c.LastSuccess = ""` to its +// mutation) → this fails with "a FAILED run wiped the success anchor". +func TestTier2_FailureDoesNotWipeTheAnchor(t *testing.T) { + m := anchorMgr(t) + + // Seed a row with a DISTINCT, older anchor (a real success on the 25th) already tracked, so the + // anchor and the attempt clock are distinguishable — a success recorded in this same test would + // land in the same second as the failures and prove nothing about which field moved. + const anchor = "2026-07-25T01:30:00Z" + if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", Schedule: "daily", + LastRun: anchor, LastStatus: "ok", LastSuccess: anchor, SuccessTracked: true, + }); err != nil { + t.Fatal(err) + } + + // Five consecutive failing nights, through the real helper. + for i := 0; i < 5; i++ { + m.recordTier2Failure("calibre-web", tgt(), errors.New("rsync: connection reset")) + got := m.settings.GetCrossDriveConfig("calibre-web") + if got.LastSuccess == "" { + t.Fatalf("a FAILED run wiped the success anchor (round %d) — one bad night would read as 'no copy has ever succeeded'", i+1) + } + if got.LastSuccess != anchor { + t.Fatalf("a FAILED run MOVED the anchor to %q (was %q) — that is the R-101 defect in mirror image", got.LastSuccess, anchor) + } + if got.LastStatus != "error" { + t.Errorf("the failure was not recorded (status=%q)", got.LastStatus) + } + // the ATTEMPT clock DOES move — that is what made the old rendering wrong + if got.LastRun == anchor { + t.Errorf("LastRun did not advance on the attempt — it still reads the old success time %q", got.LastRun) + } + } +} + +// A successful run advances the anchor, or a tier would look permanently stale after one good night. +func TestTier2_SuccessAdvancesTheAnchor(t *testing.T) { + m := anchorMgr(t) + if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", Schedule: "daily", + LastRun: "2026-07-25T01:30:00Z", LastStatus: "error", + LastSuccess: "2026-07-25T01:30:00Z", SuccessTracked: true, + }); err != nil { + t.Fatal(err) + } + m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0) + got := m.settings.GetCrossDriveConfig("calibre-web") + if got.LastSuccess == "2026-07-25T01:30:00Z" { + t.Error("a successful run did not advance the anchor") + } + if got.LastSuccess != got.LastRun { + t.Errorf("the anchor and the attempt clock disagree after a success (%q vs %q)", got.LastSuccess, got.LastRun) + } + if got.LastError != "" { + t.Errorf("a success left the previous error in place (%q)", got.LastError) + } +} + +// A customer preference must still survive a status write — the behaviour the old helper existed to +// provide. The new copy-the-row form should give this for free, and this pins that it does. +// +// RED-PROOF: make tier2Update start from a zero-value struct instead of copying the existing row → +// this fails with "a status write wiped the customer's Tier-2 preference". +func TestTier2_StatusWritePreservesCustomerPreference(t *testing.T) { + m := anchorMgr(t) + if err := m.settings.SetTier2Preference("calibre-web", true, "/mnt/hdd_2"); err != nil { + t.Fatal(err) + } + m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) + + got := m.settings.GetCrossDriveConfig("calibre-web") + if !got.UserDisabled || got.PreferredTarget != "/mnt/hdd_2" { + t.Errorf("a status write wiped the customer's Tier-2 preference (UserDisabled=%v PreferredTarget=%q)", + got.UserDisabled, got.PreferredTarget) + } +} + +// A no-target write must not destroy the anchor either: "there is no destination drive right now" is +// not evidence that the last successful copy never happened. +func TestTier2_NoTargetKeepsTheAnchor(t *testing.T) { + m := anchorMgr(t) + m.recordTier2Success("calibre-web", tgt(), 1<<30, "", 0) + anchor := m.settings.GetCrossDriveConfig("calibre-web").LastSuccess + + m.recordTier2NoTarget("calibre-web", "nincs elérhető második meghajtó") + got := m.settings.GetCrossDriveConfig("calibre-web") + if got.LastSuccess != anchor { + t.Errorf("a no_target write lost the anchor (%q, was %q)", got.LastSuccess, anchor) + } + if got.LastRun != "" { + t.Errorf("no_target should clear the attempt clock as before, got %q", got.LastRun) + } +} + +// SCENARIO E (producer half) — a LEGACY row is migrated truthfully on first touch: a row whose last +// known state was a SUCCESS adopts that time as its anchor, so the deploy does not flip every existing +// customer to "never succeeded". +// +// RED-PROOF: delete the `if !cfg.SuccessTracked` seeding block in tier2Update → this fails with +// "a legacy OK row was not migrated". +func TestTier2_LegacyOkRowSeedsItsAnchor(t *testing.T) { + m := anchorMgr(t) + // A pre-R-101 row: status ok, a last_run, no anchor, not tracked. + if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", Schedule: "daily", + LastRun: "2026-07-28T01:30:00Z", LastStatus: "ok", + }); err != nil { + t.Fatal(err) + } + // The next run fails — the first new-code touch of this row. + m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) + + got := m.settings.GetCrossDriveConfig("calibre-web") + if !got.SuccessTracked { + t.Fatal("the row was not marked as tracked") + } + if got.LastSuccess != "2026-07-28T01:30:00Z" { + t.Errorf("a legacy OK row was not migrated — its known-good run should have become the anchor (got %q)", got.LastSuccess) + } +} + +// ...but a legacy row whose last known state was an ERROR seeds NOTHING: the old data contains no +// evidence of a success, and inventing one would be the original defect. +func TestTier2_LegacyErrorRowSeedsNothing(t *testing.T) { + m := anchorMgr(t) + if err := m.settings.SetCrossDriveConfig("calibre-web", &settings.CrossDriveBackup{ + Enabled: true, Method: "rsync", Schedule: "daily", + LastRun: "2026-07-28T01:30:00Z", LastStatus: "error", + }); err != nil { + t.Fatal(err) + } + m.recordTier2Failure("calibre-web", tgt(), errors.New("boom")) + + if got := m.settings.GetCrossDriveConfig("calibre-web"); got.LastSuccess != "" { + t.Errorf("a legacy ERROR row invented an anchor (%q) — that is the defect, not the fix", got.LastSuccess) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 8d15286..a825108 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -232,12 +232,23 @@ type CrossDriveBackup struct { Schedule string `json:"schedule"` // "daily", "weekly", "manual" // Runtime state (updated by backup runner, persisted for display) - LastRun string `json:"last_run,omitempty"` // RFC3339 - LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" - LastError string `json:"last_error,omitempty"` - LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian) - LastDuration string `json:"last_duration,omitempty"` // "2m34s" - LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" + LastRun string `json:"last_run,omitempty"` // RFC3339 + LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" + LastError string `json:"last_error,omitempty"` + // LastSuccess (R-101) is the last COPY that actually succeeded — the only timestamp a customer may + // be shown as evidence that a copy exists. LastRun above is written on failure too + // (recordTier2Failure), so it records an ATTEMPT; presenting it as „Legutóbbi másolat" in the + // restore dialog told a customer a copy existed from last night when last night had failed. + // Same rule and shape as the offsite tier's anchor — see backup.offboxAnchorAfterRun. + LastSuccess string `json:"last_success,omitempty"` // RFC3339 + // SuccessTracked distinguishes "this row predates the anchor" from "this row has an anchor and it + // is empty, i.e. nothing has succeeded". Without it the two are indistinguishable (both are + // LastSuccess=="") and every pre-existing row on the fleet would render as never-succeeded on the + // deploy — all 7 rows on the two demo boxes were in exactly that state. Set by every runner write. + SuccessTracked bool `json:"success_tracked,omitempty"` + LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian) + LastDuration string `json:"last_duration,omitempty"` // "2m34s" + LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" // Customer preference (set from the per-app Tier-2 config panel; PRESERVED across the runner's // status writes). UserDisabled turns Tier 2 off for this app; PreferredTarget pins a chosen diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index ba8ceb5..ebb129a 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -304,6 +304,18 @@ func (s *Server) templateFuncMap() template.FuncMap { return fmt.Sprintf("%d napja", int(d.Hours()/24)) } }, + // fmtTimeStr renders an RFC3339 STRING as an absolute Budapest-local date-time. R-101: the + // Tier-2 restore confirm dialog printed a raw RFC3339 stamp ("2026-07-28T01:30:00Z") at the + // moment the customer decides whether to restore — a UTC machine timestamp is not something a + // customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on + // a status card, but a restore decision deserves the actual date. + "fmtTimeStr": func(s string) string { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return t.In(loc).Format("2006-01-02 15:04") + }, "fmtTime": func(t time.Time) string { if t.IsZero() { return "–" diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 47cde49..86a0900 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1022,15 +1022,24 @@ type AppBackupRow struct { Tier1DBStatus string // "ok", "error", "" — separate DB dump status for warning // Tier 2: Cross-drive backup (configurable for all apps) - Tier2Configured bool - Tier2Dest string // destination label - Tier2Schedule string // "Naponta", "Hetente" - Tier2LastRun string - Tier2LastStatus string // "ok", "error", "running", "" - Tier2LastError string - Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run - Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" - Tier2SizeHuman string + Tier2Configured bool + Tier2Dest string // destination label + Tier2Schedule string // "Naponta", "Hetente" + Tier2LastRun string + Tier2LastStatus string // "ok", "error", "running", "" + // R-101 — the customer may only be shown a timestamp as evidence of a COPY when a copy actually + // succeeded. Tier2LastRun is the ATTEMPT clock (written on failure too), so these three drive the + // display instead: + // Tier2LastSuccess the anchor ("" = none known) + // Tier2SuccessTracked false = this row predates the anchor → render exactly as before, once-logged + // Tier2StaleCopy the newest attempt FAILED while an older success exists → disclose both + Tier2LastSuccess string + Tier2SuccessTracked bool + Tier2StaleCopy bool + Tier2LastError string + Tier2LastWarning string // 3b: capture-gap / state-only notice on an otherwise-ok run + Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—" + Tier2SizeHuman string // Drive disconnected — app's home drive is currently disconnected DriveDisconnected bool @@ -1180,6 +1189,14 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup row.Tier2Schedule = "Naponta" row.Tier2LastRun = cd.LastRun row.Tier2LastStatus = cd.LastStatus + row.Tier2LastSuccess = cd.LastSuccess + row.Tier2SuccessTracked = cd.SuccessTracked + // Disclose, do not hide: an older good copy AND the fact that the newest attempt failed. + // Suppressing the failure to keep the surface calm is a quieter version of the same lie. + row.Tier2StaleCopy = cd.SuccessTracked && cd.LastSuccess != "" && cd.LastStatus == "error" + if !cd.SuccessTracked { + s.noteTier2LegacyOnce(app.StackName) + } row.Tier2LastError = cd.LastError row.Tier2LastWarning = cd.LastWarning row.Tier2SizeHuman = cd.LastSizeHuman diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 90cc93a..8bb1cfe 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -191,8 +191,12 @@ rsync → {{.Tier2Dest}} Cél meghajtó leválasztva - {{if .Tier2LastRun}} + {{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}} Utolsó: {{timeAgoStr .Tier2LastRun}} + {{end}}{{else if .Tier2LastSuccess}} + Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}} + {{else}} + Még nincs sikeres másolat {{end}} {{.BackupContents}}
@@ -202,8 +206,12 @@ rsync → {{.Tier2Dest}} Cél meghajtó inaktív - {{if .Tier2LastRun}} + {{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}} Utolsó: {{timeAgoStr .Tier2LastRun}} + {{end}}{{else if .Tier2LastSuccess}} + Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}} + {{else}} + Még nincs sikeres másolat {{end}} {{.BackupContents}}
@@ -213,23 +221,40 @@ rsync → {{.Tier2Dest}} {{.Tier2Schedule}} - {{if .Tier2LastRun}} + {{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}} Utolsó: {{timeAgoStr .Tier2LastRun}} {{.Tier2StatusBadge}} + {{end}}{{else if .Tier2LastSuccess}} + Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}} + + {{.Tier2StatusBadge}} + + + {{else}} + Még nincs sikeres másolat + {{.Tier2StatusBadge}} + {{end}} {{if .Tier2SizeHuman}}{{.Tier2SizeHuman}}{{end}} {{if .Tier2LastWarning}}{{.Tier2LastWarning}}{{end}} {{.BackupContents}}
- {{if .Tier2LastRun}} + {{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
{{$.CSRFField}} - +
+ {{end}}{{else if .Tier2LastSuccess}} +
{{$.CSRFField}} + + +
+ {{else}} + Még nincs sikeres másolat, amiből vissza lehetne állítani. {{end}} Beállítás
diff --git a/controller/internal/web/tier2_honesty_test.go b/controller/internal/web/tier2_honesty_test.go new file mode 100644 index 0000000..478865c --- /dev/null +++ b/controller/internal/web/tier2_honesty_test.go @@ -0,0 +1,169 @@ +package web + +import ( + "strings" + "testing" +) + +// R-101 — THE CUSTOMER MUST NOT BE TOLD A FAILED BACKUP IS A COPY. +// +// `Tier2LastRun` is the ATTEMPT clock: `recordTier2Failure` writes it too. It was rendered as +// „Legutóbbi másolat" in the restore confirm dialog — the exact moment a customer decides whether to +// restore, guarding a restore that fills in MISSING files without touching existing ones. So a +// customer whose Tier-2 had been failing was told a copy existed from last night, restored, and +// silently received OLDER files while believing they were recent. Misinformation at a decision point. +// +// These tests render the PRODUCTION template tree and assert on the STRING THE CUSTOMER READS. A test +// that asserted the template variable would prove nothing about the wording, which is the defect. + +// tier2Row builds a configured Tier-2 row. Only the anchor fields vary between scenarios. +func tier2Row(lastRun, lastSuccess, status string, tracked, stale bool) AppBackupRow { + return AppBackupRow{ + StackName: "calibre-web", DisplayName: "Calibre-Web", + Tier2Configured: true, Tier2Dest: "hdd_1", Tier2Schedule: "Naponta", + Tier2LastRun: lastRun, Tier2LastStatus: status, + Tier2LastSuccess: lastSuccess, Tier2SuccessTracked: tracked, Tier2StaleCopy: stale, + Tier2StatusBadge: "Sikeres", + } +} + +func renderTier2(t *testing.T, row AppBackupRow) string { + t.Helper() + return renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row})) +} + +// SCENARIO A — the dialog names the last SUCCESSFUL copy, not last night's failed attempt. +// +// RED-PROOF: put `{{fmtTimeStr .Tier2LastRun}}` back into the data-confirm → this fails with +// "the dialog names the FAILED attempt (2026-07-28 03:30) as the latest copy". +func TestTier2Dialog_NamesTheLastSuccessfulCopy(t *testing.T) { + // succeeded 3 days ago; last night's attempt failed + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true)) + + if !strings.Contains(html, "Legutóbbi sikeres másolat: 2026-07-25 03:30") { + t.Fatalf("the dialog does not name the last SUCCESSFUL copy:\n%s", confirmOf(t, html)) + } + if strings.Contains(html, "2026-07-28 03:30") { + t.Errorf("the dialog names the FAILED attempt (2026-07-28 03:30) as the latest copy:\n%s", confirmOf(t, html)) + } + // and it must not still say the bare „Legutóbbi másolat" of the old wording + if strings.Contains(html, "Legutóbbi másolat:") { + t.Errorf("the old bare `Legutóbbi másolat:` wording survives — that is the claim being fixed:\n%s", confirmOf(t, html)) + } +} + +// SCENARIO B — the failed attempt is DISCLOSED at the decision point. Showing only the old success and +// hiding the failure is a quieter lie: the customer would not know the files may be older than usual. +func TestTier2Dialog_DisclosesTheFailedAttempt(t *testing.T) { + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true)) + for _, want := range []string{ + "a legutóbbi mentési kísérlet nem sikerült", + "régebbiek lehetnek", + } { + if !strings.Contains(html, want) { + t.Errorf("the dialog hides that the newest attempt failed (missing %q):\n%s", want, confirmOf(t, html)) + } + } +} + +// SCENARIO C — NEVER SUCCEEDED shows no copy at all. This is the worst case and the easiest to miss: +// today a tier that has attempted and never succeeded still renders a timestamp, so the dialog +// promises a copy that does not exist and the restore returns nothing. +// +// RED-PROOF: gate the restore form on `.Tier2LastRun` again instead of `.Tier2LastSuccess` → this +// fails with "a tier that has NEVER succeeded still offers a restore". +func TestTier2_NeverSucceededOffersNoCopy(t *testing.T) { + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "", "error", true, false)) + + if !strings.Contains(html, "Még nincs sikeres másolat") { + t.Fatalf("a never-succeeded tier does not say so:\n%s", html[:min(len(html), 400)]) + } + if strings.Contains(html, "Fájlok visszaállítása") { + t.Errorf("a tier that has NEVER succeeded still offers a restore — the dialog would promise a copy that does not exist") + } + if !strings.Contains(html, "amiből vissza lehetne állítani") { + t.Errorf("the restore action does not say plainly that there is nothing to restore from") + } + // no timestamp may be presented as a copy + if strings.Contains(html, "Legutóbbi sikeres másolat") || strings.Contains(html, "Utolsó sikeres:") { + t.Errorf("a timestamp is presented despite no successful copy existing") + } +} + +// SCENARIO D — a HEALTHY tier is visually unchanged: no warning, no caution, no tonal shift. If every +// customer's dashboard grows a caution because the wording got defensive, the fix made things worse. +// +// RED-PROOF: make the stale-copy warning unconditional (drop `{{if .Tier2StaleCopy}}`) → this fails +// with "a HEALTHY tier shows the failed-attempt caution". +func TestTier2_HealthyTierIsUnchanged(t *testing.T) { + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-28T01:30:00Z", "ok", true, false)) + + for _, forbidden := range []string{ + "nem sikerült", + "régebbiek lehetnek", + "Még nincs sikeres másolat", + } { + if strings.Contains(html, forbidden) { + t.Errorf("a HEALTHY tier shows the failed-attempt caution (%q):\n%s", forbidden, confirmOf(t, html)) + } + } + if !strings.Contains(html, "Legutóbbi sikeres másolat: 2026-07-28 03:30") { + t.Errorf("a healthy tier lost its copy timestamp:\n%s", confirmOf(t, html)) + } + if !strings.Contains(html, "Fájlok visszaállítása") { + t.Error("a healthy tier lost its restore action") + } +} + +// SCENARIO E — a LEGACY row (written before the anchor existed) renders EXACTLY as it did before. +// Every one of the 7 Tier-2 rows on the fleet was in this state at deploy; rendering +// „Még nincs sikeres másolat" for them would have told every customer at once that their backups do +// not exist. +func TestTier2_LegacyRowRendersAsBefore(t *testing.T) { + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "", "ok", false /* not tracked */, false)) + + if strings.Contains(html, "Még nincs sikeres másolat") { + t.Fatalf("a legacy row was rendered as never-succeeded — this would frighten every existing customer at once") + } + if !strings.Contains(html, "Utolsó: ") { + t.Errorf("the legacy row lost today's rendering:\n%s", confirmOf(t, html)) + } + if !strings.Contains(html, "Fájlok visszaállítása") { + t.Error("the legacy row lost its restore action — behaviour must be unchanged for it") + } +} + +// The dialog must be human-readable. A raw RFC3339 stamp ("2026-07-25T01:30:00Z") is a machine +// timestamp in UTC, shown to a Hungarian customer deciding whether to restore. +func TestTier2Dialog_TimestampIsHumanReadable(t *testing.T) { + html := renderTier2(t, tier2Row("2026-07-28T01:30:00Z", "2026-07-25T01:30:00Z", "error", true, true)) + if strings.Contains(html, "2026-07-25T01:30:00Z") { + t.Errorf("the dialog prints a raw RFC3339 UTC stamp:\n%s", confirmOf(t, html)) + } + if !strings.Contains(html, "2026-07-25 03:30") { // Budapest local + t.Errorf("the dialog does not render a Budapest-local date-time:\n%s", confirmOf(t, html)) + } +} + +// confirmOf extracts the data-confirm attribute for error messages, so a failure shows the string the +// customer would actually read rather than a wall of HTML. +func confirmOf(t *testing.T, html string) string { + t.Helper() + i := strings.Index(html, "data-confirm=\"") + if i < 0 { + return "(no data-confirm rendered)" + } + rest := html[i+len("data-confirm=\""):] + j := strings.Index(rest, "\"") + if j < 0 { + return "(unterminated data-confirm)" + } + return rest[:j] +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/controller/internal/web/tier2_legacy.go b/controller/internal/web/tier2_legacy.go new file mode 100644 index 0000000..04e75b5 --- /dev/null +++ b/controller/internal/web/tier2_legacy.go @@ -0,0 +1,29 @@ +package web + +import "sync" + +// R-101: a Tier-2 row written before the success anchor existed renders exactly as it did before — +// „Utolsó: " from the ATTEMPT clock. That is the mandated degrade direction: showing +// „Még nincs sikeres másolat" for a row that merely predates the field would have told all seven rows +// on the fleet, at once, that their backups do not exist. +// +// It is logged ONCE per stack because it is a steady state until that app's next run (nightly), not an +// event — but it is logged at all, so a fleet quietly rendering on the old anchor is visible rather +// than assumed. Same shape as the hub's R-100 legacy degrade. +var tier2LegacyMu sync.Mutex +var tier2LegacyWarned map[string]bool + +func (s *Server) noteTier2LegacyOnce(stackName string) { + tier2LegacyMu.Lock() + defer tier2LegacyMu.Unlock() + if tier2LegacyWarned == nil { + tier2LegacyWarned = map[string]bool{} + } + if tier2LegacyWarned[stackName] { + return + } + tier2LegacyWarned[stackName] = true + if s.logger != nil { + s.logger.Printf("[INFO] [tier2] %s: no success anchor yet (row predates R-101) — showing the last ATTEMPT time until this app's next run writes one", stackName) + } +}