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:
2026-09-17 20:58:45 +02:00
parent e236dca486
commit c00fed6db3
19 changed files with 816 additions and 55 deletions
+23 -4
View File
@@ -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)