R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0)

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.
This commit is contained in:
2026-07-28 16:36:47 +02:00
parent 3db8bfb953
commit 3f048e042b
12 changed files with 829 additions and 72 deletions
+3 -1
View File
@@ -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-
+120
View File
@@ -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 (<dur>): " + 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:<user>@<host>:<path>` (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 <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(), "<repo>")
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, "<repo>")
}
}
}
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
@@ -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:<user>@<host>:<path>`, 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, "<repo>") {
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")
}
}
+81 -51
View File
@@ -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 {
@@ -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)
}
}
+17 -6
View File
@@ -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
+12
View File
@@ -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 ""
+26 -9
View File
@@ -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
@@ -191,8 +191,12 @@
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó leválasztva</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
@@ -202,8 +206,12 @@
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="tag tag-warn">Cél meghajtó inaktív</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last" style="opacity:.6">Utolsó: {{timeAgoStr .Tier2LastRun}}</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last" style="opacity:.6">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}</span>
{{else}}
<span class="layer-last" style="opacity:.6">Még nincs sikeres másolat</span>
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
@@ -213,23 +221,40 @@
<span class="layer-method">rsync</span>
<span class="layer-dest">→ {{.Tier2Dest}}</span>
<span class="layer-schedule">{{.Tier2Schedule}}</span>
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<span class="layer-last">Utolsó: {{timeAgoStr .Tier2LastRun}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{end}}{{else if .Tier2LastSuccess}}
<span class="layer-last">Utolsó sikeres: {{timeAgoStr .Tier2LastSuccess}}
<span class="{{if eq .Tier2LastStatus "ok"}}text-ok{{else if eq .Tier2LastStatus "error"}}text-error{{else if eq .Tier2LastStatus "running"}}text-muted{{end}}">
{{.Tier2StatusBadge}}
</span>
</span>
{{else}}
<span class="layer-last">Még nincs sikeres másolat
<span class="{{if eq .Tier2LastStatus "error"}}text-error{{else}}text-muted{{end}}">{{.Tier2StatusBadge}}</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben"><svg class="ico ico-sm"><use href="#i-file-text"/></svg></span>
<div class="layer-actions">
{{if .Tier2LastRun}}
{{if not .Tier2SuccessTracked}}{{if .Tier2LastRun}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{.Tier2LastRun}}">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: {{fmtTimeStr .Tier2LastRun}}">Fájlok visszaállítása</button>
</form>
{{end}}{{else if .Tier2LastSuccess}}
<form method="POST" action="/backup/tier2/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="stack_name" value="{{.StackName}}">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: {{fmtTimeStr .Tier2LastSuccess}}.{{if .Tier2StaleCopy}} 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.{{end}}">Fájlok visszaállítása</button>
</form>
{{else}}
<span class="layer-reason" style="opacity:.85">Még nincs sikeres másolat, amiből vissza lehetne állítani.</span>
{{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
@@ -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
}
+29
View File
@@ -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ó: <idő>" 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)
}
}