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:
@@ -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-
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 "–"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user