R-553: four decisions stop reading their own Hungarian words (sites 1-4)
Every Hungarian sentence is byte-identical; each decision now reads a signal set where the message is
made. util.KindErrorf builds the same bytes fmt.Errorf did while carrying a sentinel for errors.Is.
- Deploy status (api/router.go): deployStatusFor() by kind — stacks.ErrAlreadyDeployed (409),
ErrRequiredField / ErrPathMissing / ErrNotEnoughMemory (400). The „kötelező" / „memória" /
"does not exist" / "already deployed" text chain is gone.
- Off-site failure class (backup/offbox.go): ErrOffsiteQuota replaces the „tárhelykeretet" match. The
restic/ssh signatures stay text matches on purpose — that output is not ours and is not translated.
- Alert placement (web/alerts.go): monitor.HealthReport carries WarningKinds parallel to Warnings;
the "not on a separate drive" warning is inline by KIND. The hub report is untouched (builder.go
copies Status/Issues/Warnings only) — pinned by a wire test.
- Stale off-site note (web/handlers.go): settings LastWarningKind + backup.OffboxWarnNoAppsSelected.
The text test survives ONLY for kind == "" (a box whose last run predates 0.251.0) and is removed
when R-570 closes; slice 2 must not translate that producer before then.
Tests (all red-proofed by restoring the pre-fix predicate — see the audit's redproofs.txt):
TestR553_Deploy_DecisionSurvivesWordingChange, TestR553_DeployHandlerUsesTheKind,
TestR553_DeployProducersCarryKindAndKeepTheirWords (through the real DeployStack),
TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange, TestR553_OffboxRunRecordsTheKind,
TestR553_StorageWarningsCarryKindsAndKeepTheirWords, TestR553_DiskWarningPlacementSurvivesWordingChange,
TestR553_HubReportWarningsAreUnchangedOnTheWire, TestR553_StaleNote*, TestR553_WarningKindIsPersistedAndCopied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// Off-box (NAS) backup target — Part B. An ENCRYPTED restic repo reached over SFTP (no kernel mount;
|
||||
@@ -77,6 +78,15 @@ func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, p
|
||||
// the orphan card instead of the raw restic error.
|
||||
var ErrOffboxOrphaned = fmt.Errorf("offbox repo orphaned: exists but keyed under a previous, no-longer-available passphrase")
|
||||
|
||||
// OffboxWarnNoAppsSelected is the kind of the zero-selection notice (R-553): the run succeeded but
|
||||
// nothing was selected to copy. The Távoli mentés page replaces that line once the household HAS
|
||||
// selected apps — it used to find it by searching the sentence for „nincs mentésre jelölt alkalmazás".
|
||||
const OffboxWarnNoAppsSelected = "no-apps-selected"
|
||||
|
||||
// ErrOffsiteQuota marks a run refused because the repository is at or over its quota (R-553). The
|
||||
// customer's Hungarian sentence is unchanged; this sentinel is what ClassifyOffsiteFailure reads.
|
||||
var ErrOffsiteQuota = errors.New("offsite quota exceeded")
|
||||
|
||||
// ErrOffboxRunInFlight is returned to the MANUAL caller only, when the single-flight dropped the
|
||||
// request because a run was already going (R-234). It is not a failure of anything — the run in
|
||||
// flight is doing the work — but it IS a request that did nothing, and the page must say so instead
|
||||
@@ -181,10 +191,14 @@ func ClassifyOffsiteFailure(err error) OffsiteFailureClass {
|
||||
if errors.Is(err, ErrOffboxOrphaned) {
|
||||
return OffsiteFailOrphaned
|
||||
}
|
||||
// R-553 — the quota refusal is OURS, so it is told apart by its sentinel, not by the Hungarian
|
||||
// word „tárhelykeretet" it happens to contain today. The signatures below stay text matches on
|
||||
// purpose: they are restic's and ssh's own English output, which we neither write nor translate.
|
||||
if errors.Is(err, ErrOffsiteQuota) {
|
||||
return OffsiteFailQuota
|
||||
}
|
||||
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"),
|
||||
@@ -599,7 +613,7 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
|
||||
if cur := m.settings.GetOffboxTarget(); cur != nil {
|
||||
tgt.EscrowState = cur.EscrowState
|
||||
tgt.LastRun, tgt.LastStatus, tgt.LastError = cur.LastRun, cur.LastStatus, cur.LastError
|
||||
tgt.LastDuration, tgt.LastWarning = cur.LastDuration, cur.LastWarning
|
||||
tgt.LastDuration, tgt.LastWarning, tgt.LastWarningKind = cur.LastDuration, cur.LastWarning, cur.LastWarningKind
|
||||
// R-100: carry the staleness anchor across a hub re-apply, for the same reason as the rest of
|
||||
// this block — a re-apply is not a new tier. Dropping it would reset an established tier to
|
||||
// "never succeeded" every time the hub re-pushes the descriptor.
|
||||
@@ -918,7 +932,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// run refuses.
|
||||
m.offboxPruneOnly(ctx, base, env)
|
||||
m.offboxRecordStats(ctx, base, env) // the prune may have brought the size back down — refresh
|
||||
runErr = fmt.Errorf("A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", usedGB, quota)
|
||||
runErr = util.KindErrorf(ErrOffsiteQuota, "A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", usedGB, quota)
|
||||
} else {
|
||||
// R-43/R-44 (v0.148.0) — THE COHERENCE PRE-PHASE. Refresh the DB/volume dumps and the recovery
|
||||
// units BEFORE capturing, so the snapshot restic is about to write is an internally coherent
|
||||
@@ -987,10 +1001,12 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
o.LastStatus = "error"
|
||||
o.LastError = ""
|
||||
o.LastWarning = ""
|
||||
o.LastWarningKind = ""
|
||||
} else if runErr != nil {
|
||||
o.LastStatus = "error"
|
||||
o.LastError = runErr.Error()
|
||||
o.LastWarning = ""
|
||||
o.LastWarningKind = ""
|
||||
} else {
|
||||
// R-203 — THE VERDICT. A run that could not capture a directory the app declares MANDATORY
|
||||
// is not a successful run. Until v0.197.0 it reported `ok` with a warning beside it, and a
|
||||
@@ -1044,6 +1060,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
o.StatsKnown = true // R-225: measured, even if the answer is zero
|
||||
o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it
|
||||
var warns []string
|
||||
warnKind := ""
|
||||
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
|
||||
// its emptiness instead of a bare success — the customer thinks offsite runs, but nothing
|
||||
// is covered until at least one app is toggled.
|
||||
@@ -1051,6 +1068,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// must not be told "nothing is selected".
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence
|
||||
}
|
||||
// R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no
|
||||
// available backup and were left out", which names a problem with no next step and reads
|
||||
@@ -1093,6 +1111,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
warns = append(warns, qw)
|
||||
}
|
||||
o.LastWarning = strings.Join(warns, " ")
|
||||
o.LastWarningKind = warnKind
|
||||
}
|
||||
}); perr != nil {
|
||||
m.logger.Printf("[WARN] [offbox] status persist (final) failed: %v", perr)
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// the real demo-hp target shape — the values the sanitiser must remove literally
|
||||
@@ -31,7 +33,10 @@ func TestClassifyOffsiteFailure_EachCauseIsDistinct(t *testing.T) {
|
||||
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},
|
||||
// R-553 (v0.251.0): the quota refusal is built with its KIND at the producer, exactly as the run
|
||||
// builds it — the classifier no longer recognises this sentence by its Hungarian words, and that
|
||||
// is the point (localisation slice 2 translates them).
|
||||
{"quota gate", util.KindErrorf(ErrOffsiteQuota, "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},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// R-553 — the off-site failure classifier must keep telling a quota over-run apart after the sentence
|
||||
// is translated. It used to look for the Hungarian word „tárhelykeretet" inside the error; slice 2
|
||||
// translates that sentence, and the customer would then be told the copy failed „ismeretlen okból"
|
||||
// — unknown cause — for the one failure with a clear, actionable cause.
|
||||
//
|
||||
// RED-PROOF (REPORT): put `case strings.Contains(s, "tárhelykeretet")` back and delete the
|
||||
// errors.Is arm → the translated row falls to OffsiteFailUnknown and this fails.
|
||||
func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want OffsiteFailureClass
|
||||
}{
|
||||
{"quota refusal, Hungarian as shipped", util.KindErrorf(ErrOffsiteQuota,
|
||||
"A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", 51, 50), OffsiteFailQuota},
|
||||
{"quota refusal, TRANSLATED", util.KindErrorf(ErrOffsiteQuota,
|
||||
"The remote backup is over its storage quota (%d/%d GB) — delete old backups or ask for more space.", 51, 50), OffsiteFailQuota},
|
||||
{"quota refusal wrapped by a caller", util.KindErrorf(ErrOffsiteQuota, "over quota"), OffsiteFailQuota},
|
||||
// Negative controls: output we do NOT write stays matched by its text, on purpose.
|
||||
{"restic: no repository", errors.New("Fatal: unable to open config file: Stat: file does not exist"), OffsiteFailNoRepo},
|
||||
{"ssh: unreachable", errors.New("dial tcp 10.0.0.9:22: connect: connection refused"), OffsiteFailTransport},
|
||||
{"nothing to copy", errors.New("off-box backup produced no snapshots: 2 app(s) toggled"), OffsiteFailNoUnits},
|
||||
{"unknown stays unknown", errors.New("something else entirely"), OffsiteFailUnknown},
|
||||
{"no error", nil, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyOffsiteFailure(c.err); got != c.want {
|
||||
t.Errorf("%s: ClassifyOffsiteFailure = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The consequence, not only the mechanism: the head line the customer reads on /backups/remote is the
|
||||
// quota one for a TRANSLATED quota error. (offsiteFailureMessage is the only caller of the classifier.)
|
||||
func TestR553_OffsiteQuota_HeadLineSurvivesWordingChange(t *testing.T) {
|
||||
tgt := &settings.OffboxTarget{Host: "nas.local", User: "felhom", RepoPath: "/srv/repo"}
|
||||
translated := util.KindErrorf(ErrOffsiteQuota, "The remote backup is over its storage quota (51/50 GB).")
|
||||
msg := offsiteFailureMessage(tgt, translated, 12*time.Second)
|
||||
if !strings.HasPrefix(msg, "A távoli mentés nem fért el a tárhelykereten belül") {
|
||||
t.Errorf("a translated quota failure is reported with the wrong cause line: %q", msg)
|
||||
}
|
||||
unknown := errors.New("The remote backup is over its storage quota (51/50 GB).")
|
||||
if m := offsiteFailureMessage(tgt, unknown, time.Second); strings.HasPrefix(m, "A távoli mentés nem fért el") {
|
||||
t.Errorf("an error WITHOUT the kind must not be guessed into the quota class from its words: %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
// The producer keeps its Hungarian sentence byte-for-byte while carrying the kind.
|
||||
func TestR553_QuotaProducerKeepsItsWords(t *testing.T) {
|
||||
err := util.KindErrorf(ErrOffsiteQuota,
|
||||
"A távoli mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", 51, 50)
|
||||
want := "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."
|
||||
if err.Error() != want {
|
||||
t.Errorf("message CHANGED:\n got %q\nwant %q", err.Error(), want)
|
||||
}
|
||||
if !errors.Is(err, ErrOffsiteQuota) {
|
||||
t.Error("the quota refusal carries no kind")
|
||||
}
|
||||
}
|
||||
|
||||
// The zero-selection run must RECORD its kind beside the sentence, or the page falls back to reading
|
||||
// the words for ever. A real off-site run needs restic and an SSH target, so this is pinned at the
|
||||
// source — the defect it guards is a producer written the old way, and the display half is covered by
|
||||
// TestR553_StaleNoteDecisionSurvivesWordingChange in internal/web.
|
||||
func TestR553_OffboxRunRecordsTheKind(t *testing.T) {
|
||||
src, err := os.ReadFile("offbox.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
i := strings.Index(body, `warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")`)
|
||||
if i < 0 {
|
||||
t.Fatal("the zero-selection sentence is gone from the run — this test no longer reads what it thinks it reads")
|
||||
}
|
||||
if !strings.Contains(body[i:i+400], "warnKind = OffboxWarnNoAppsSelected") {
|
||||
t.Error("the zero-selection run records its sentence but not its KIND — the Távoli mentés page " +
|
||||
"is left reading Hungarian words, which localisation slice 2 will change (R-553)")
|
||||
}
|
||||
if !strings.Contains(body, "o.LastWarningKind = warnKind") {
|
||||
t.Error("the recorded kind is never persisted, so the page sees nothing after a restart")
|
||||
}
|
||||
for _, clear := range []string{`o.LastWarning = ""
|
||||
o.LastWarningKind = ""`} {
|
||||
if !strings.Contains(body, clear) {
|
||||
t.Error("a run that clears LastWarning must clear its kind too, or a stale kind outlives its text")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user