v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s

The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 14:19:31 +02:00
parent eb6aa58aa7
commit 48f3336956
142 changed files with 2199 additions and 318 deletions
+23
View File
@@ -13,8 +13,10 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Manager orchestrates app-data backups: database dumps and Docker-volume tars.
@@ -1375,3 +1377,24 @@ func dbNames(dbs []DiscoveredDB) string {
}
return strings.Join(names, ", ")
}
// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1).
//
// A background run has no request and no reader, so it writes in the box's language at the moment it
// writes. A household that switches sees last night's note in the old language until the next run
// rewrites it — the operator's ruling, and the reason the reader never translates stored text.
func (m *Manager) boxLang() string {
if m.settings == nil {
return i18n.Default
}
return m.settings.GetLanguage()
}
// note renders a saved note in the box's language.
func (m *Manager) note(key string, args ...interface{}) string {
return util.Text(m.boxLang(), key, args...)
}
// noteErr renders an error into a saved note in the box's language: its bundle message when it
// carries one (release B), its own text otherwise.
func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
+29 -19
View File
@@ -93,7 +93,7 @@ var ErrOffsiteQuota = errors.New("offsite quota exceeded")
// of showing the previous run's verdict under a „started" message.
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
const offboxWholeUnitGapKey = "note.offsite.whole_unit_gap"
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
@@ -226,20 +226,24 @@ func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string {
if m != nil && m.settings != nil {
t = m.settings.GetOffboxTarget()
}
return offsiteFailureMessage(t, err, dur)
return offsiteFailureMessage(t, err, dur, m.boxLang())
}
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string {
// offsiteFailureMessage builds the note that is SAVED in OffboxTarget.LastError and read on the page
// days later, so it is written in the box's language at write time (release C, R-557 option 1).
// The failure CLASS is still decided by ClassifyOffsiteFailure from a kind, never from these words
// (R-553) — which is why translating them is safe.
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration, lang string) 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",
OffsiteFailQuota: util.Text(lang, "note.offsite.fail_quota"),
OffsiteFailOrphaned: util.Text(lang, "note.offsite.fail_orphaned"),
OffsiteFailNoRepo: util.Text(lang, "note.offsite.fail_no_repo"),
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
}[ClassifyOffsiteFailure(err)]
if head == "" {
head = "A távoli mentés nem sikerült"
head = util.Text(lang, "note.offsite.fail_head")
}
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
}
@@ -988,6 +992,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
if runErr == nil {
snapshots = m.offboxRecordStats(ctx, base, env)
}
// THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE.
//
// UpdateOffboxStatus holds the settings WRITE lock while it runs `fn`, and boxLang() reads the
// language through the settings READ lock. sync.RWMutex is not reentrant, so calling m.note()
// inside this callback DEADLOCKS — and it deadlocks holding the settings lock, which then wedges
// everything else that touches settings.json on that box. Written this way once and caught by the
// suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed.
lang := m.boxLang()
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.LastRun = time.Now().UTC().Format(time.RFC3339)
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
@@ -1047,7 +1059,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
notify = cp
}
for _, a := range runResult.missingUnprotected {
notify[a] = append(notify[a], offboxWholeUnitGap)
notify[a] = append(notify[a], util.Text(lang, offboxWholeUnitGapKey))
}
}
m.offboxGapNotify(notify)
@@ -1074,13 +1086,11 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// available backup and were left out", which names a problem with no next step and reads
// the same whether the customer must act or simply wait.
if len(runResult.missingUnprotected) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
warns = append(warns, util.Text(lang, "note.offsite.no_local_unit",
strings.Join(runResult.missingUnprotected, ", ")))
}
if len(runResult.missingNotDeployed) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
warns = append(warns, util.Text(lang, "note.offsite.not_installed",
strings.Join(runResult.missingNotDeployed, ", ")))
}
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
@@ -1100,14 +1110,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
blockedApps = append(blockedApps, n)
}
if len(blockedApps) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
warns = append(warns, util.Text(lang, "note.offsite.quota_partial",
len(blockedApps), strings.Join(blockedApps, ", ")))
}
if sharesBlocked {
warns = append(warns, sharesBlockedWarning())
}
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
if qw := offboxQuotaWarning(o); qw != "" {
if qw := offboxQuotaWarning(o, lang); qw != "" {
warns = append(warns, qw)
}
o.LastWarning = strings.Join(warns, " ")
@@ -1739,7 +1749,7 @@ func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool)
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
// already over — over-quota is the run-refusal error, not a warning).
func offboxQuotaWarning(t *settings.OffboxTarget) string {
func offboxQuotaWarning(t *settings.OffboxTarget, lang string) string {
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
return ""
}
@@ -1748,7 +1758,7 @@ func offboxQuotaWarning(t *settings.OffboxTarget) string {
if pct < 80 || t.RepoSizeBytes >= limit {
return ""
}
return fmt.Sprintf("A távoli mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
return util.Text(lang, "note.offsite.quota_usage", int(pct), int(t.RepoSizeBytes/offboxGiB), t.QuotaGB)
}
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).
@@ -172,7 +172,7 @@ func (s safetyDumpSet) First() string {
// failed restore on a machine that may be unwell, and the honest claim available here is presence.
// A zero-length file is reported as MISSING — a 0-byte dump restores nothing, and calling it present
// is the same false reassurance one step smaller.
func undoCopyPhrase(set safetyDumpSet) string {
func (m *Manager) undoCopyPhrase(set safetyDumpSet) string {
var present, absent []string
for _, f := range set.Files {
if f.Path == "" {
@@ -186,16 +186,15 @@ func undoCopyPhrase(set safetyDumpSet) string {
}
switch {
case len(present) > 0 && len(absent) == 0:
return "a korábbi állapot mentése megvan: " + strings.Join(present, ", ")
return m.note("note.undo.present", strings.Join(present, ", "))
case len(present) > 0:
// Partial: name both halves. An app with two databases whose undo is half there is a
// different situation from either whole one, and support must not have to guess which.
return "a korábbi állapot mentése RÉSZBEN van meg — megvan: " + strings.Join(present, ", ") +
"; HIÁNYZIK: " + strings.Join(absent, ", ")
return m.note("note.undo.partial", strings.Join(present, ", "), strings.Join(absent, ", "))
case len(absent) > 0:
return "a korábbi állapot mentését NEM találjuk a helyén (" + strings.Join(absent, ", ") + ")"
return m.note("note.undo.absent", strings.Join(absent, ", "))
default:
return "a korábbi állapotról nem készült menthető másolat"
return m.note("note.undo.none")
}
}
@@ -341,7 +340,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
// restore hold names nothing, because the restore it refers to already consumed the copy.
if h.Reason == settings.HoldReasonUpdateFailed {
copyDate := "legutóbbi"
copyDate := m.note("note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
@@ -358,8 +357,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
when = t.Format("2006-01-02 15:04")
}
return true, fmt.Sprintf("a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. "+
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", stack, when)
return true, m.note("note.reconstitute.held", stack, when)
}
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
@@ -810,7 +808,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See
// undoCopyPhrase — this sentence used to assert the file existed in exactly the
// branch where a missing file is one of the two causes.
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, undoCopyPhrase(safetySet))
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet))
}
res.RolledBack = true
if sErr := restartStack(); sErr != nil {
+135 -3
View File
@@ -2,10 +2,18 @@ package backup
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
@@ -65,7 +73,7 @@ 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)
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute, "hu")
if !strings.Contains(msg, "ismeretlen okból") {
t.Errorf("the unknown case does not admit it is unknown: %q", msg)
}
@@ -82,7 +90,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
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)
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second, "hu")
for _, forbidden := range []string{
"sftp:",
"your-storagebox.de",
@@ -102,7 +110,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
// 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)
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second, "hu")
if !strings.Contains(msg, "nem érhető el") {
t.Errorf("the transport cause is not named: %q", msg)
}
@@ -124,3 +132,127 @@ func TestSanitiseOffsiteError_IsBounded(t *testing.T) {
t.Error("a nil error produced text")
}
}
// newNoteManager builds a Manager whose saved-note helpers work: release C writes a saved note in the
// BOX's language, so a Manager with no settings would render every note as its key. Hungarian here,
// because these tests assert the sentence a Hungarian household reads — which is the parity half.
func newNoteManager(t *testing.T) *Manager {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = t.TempDir()
return NewManager(cfg, sett, lg)
}
// backupNoteHU is the Hungarian text of a saved-note key, for a test that used to compare against a
// constant. The comparison is unchanged in meaning: it still pins the sentence, now measured against
// the bundle rather than restated beside it.
func backupNoteHU(t *testing.T, key string) string {
t.Helper()
return newNoteManager(t).note(key)
}
// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate.
//
// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site
// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when
// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is
// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop
// being recognised and would keep telling a household that nothing is covered.
//
// **Delete this test when R-570 closes** — it is named for the row on purpose.
//
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
func TestR570SentenceStaysHungarian(t *testing.T) {
const sentence = "Sikeres — nincs mentésre jelölt alkalmazás"
src, err := os.ReadFile("offbox.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), `"`+sentence+`"`) {
t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+
"since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+
"those words. Translating it strands them. Close R-570 first.", sentence)
}
// And the sentence must not have quietly acquired a bundle key either: a key would render
// Hungarian today and something else the day someone adds a translation.
for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} {
if strings.Contains(string(src), k) {
t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k)
}
}
}
// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test,
// not a precaution.
//
// `UpdateOffboxStatus` and its siblings hold the settings WRITE lock while they run their callback.
// `boxLang()` reads the language through the settings READ lock. sync.RWMutex is not reentrant, so a
// note rendered inside such a callback DEADLOCKS — and it deadlocks while holding the settings lock,
// which then wedges every other thing on that box that touches settings.json. The first draft of
// release C did exactly that, in the off-site run's final status write, and the only symptom was the
// test suite timing out at 25 minutes.
//
// The fix is to resolve the language BEFORE entering the callback. This test reads the source for the
// shape, because the failure is a hang and a hang is not something a unit test can assert on
// comfortably; the behaviour half is the suite finishing at all.
//
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
// names the file and the line, in a second, instead of the suite hanging for 25 minutes.
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
callback := regexp.MustCompile(`\.Update\w*\(func\(`)
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
checked, callbacks := 0, 0
var bad []string
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
checked++
depth := 0
inside := false
for i, line := range strings.Split(string(src), "\n") {
if !inside && callback.MatchString(line) {
depth = strings.Count(line, "{") - strings.Count(line, "}")
if depth > 0 {
inside, callbacks = true, callbacks+1
}
continue
}
if !inside {
continue
}
if note.MatchString(line) {
bad = append(bad, f+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line))
}
depth += strings.Count(line, "{") - strings.Count(line, "}")
if depth <= 0 {
inside = false
}
}
}
// The instrument must be shown to be looking at something.
if checked == 0 || callbacks == 0 {
t.Fatalf("examined %d files and found %d settings callbacks — the pattern no longer matches the code", checked, callbacks)
}
if len(bad) > 0 {
t.Errorf("a saved-note helper is called inside a settings callback, which holds the WRITE lock "+
"while boxLang() wants the READ lock — sync.RWMutex is not reentrant, so this DEADLOCKS and "+
"wedges settings.json for everything else on the box. Resolve the language before the "+
"callback (%d):\n %s", len(bad), strings.Join(bad, "\n "))
}
t.Logf("examined %d files, %d settings callbacks", checked, callbacks)
}
@@ -86,7 +86,7 @@ func TestR383_AbsentUndoCopyIsNotClaimedToExist(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := undoCopyPhrase(tc.set)
got := newNoteManager(t).undoCopyPhrase(tc.set)
for _, want := range tc.mustContain {
if !strings.Contains(got, want) {
t.Errorf("phrase %q does not contain %q", got, want)
@@ -119,9 +119,16 @@ func TestR383_TheDoubleFailureMessageIsWiredToTheBuilder(t *testing.T) {
}
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
// v0.254.0 (R-557 release C): undoCopyPhrase became a METHOD, because a saved note is
// rendered in the box's language and that needs the Manager. A method call is a
// SelectorExpr, not an Ident — so it is matched here as well as below, and the test keeps
// asserting what it always asserted rather than passing because the shape moved.
if fn.Sel.Name == "holdAppAfterFailedRollback" {
holdCalled = true
}
if fn.Sel.Name == "undoCopyPhrase" {
phraseCalled = true
}
case *ast.Ident:
if fn.Name == "undoCopyPhrase" {
phraseCalled = true
@@ -216,9 +216,12 @@ func TestR403_SkipIsRecordedForTheSurface(t *testing.T) {
if !strings.Contains(cd.LastWarning, "hi") { // ASCII fragment of "hiányos" (R-364)
t.Errorf("LastWarning does not carry the preserved-copy notice: %q", cd.LastWarning)
}
if cd.LastWarning != tier2UnitPreservedWarning &&
!strings.Contains(cd.LastWarning, tier2UnitPreservedWarning) {
t.Errorf("LastWarning is not the named constant: %q", cd.LastWarning)
// v0.254.0 (R-557 release C): the notice is SAVED in the box's language, so the comparison is
// against the bundle text for its key rather than against a Go constant. Same claim, measured one
// step further out — a reworded sentence still fails, and so does a note written from a different key.
if want := m.note(tier2UnitPreservedKey); cd.LastWarning != want &&
!strings.Contains(cd.LastWarning, want) {
t.Errorf("LastWarning is not the named notice: %q", cd.LastWarning)
}
// And the coverage the restore surface reads agrees, from the ARTIFACT rather than the record.
cov, err := m.Tier2RestoreCoverage("app")
@@ -48,12 +48,12 @@ func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) {
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)
msg := offsiteFailureMessage(tgt, translated, 12*time.Second, "hu")
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") {
if m := offsiteFailureMessage(tgt, unknown, time.Second, "hu"); 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)
}
}
+6 -4
View File
@@ -18,14 +18,16 @@ import (
// Shape: one JSON file in the controller's state directory (DataDir, beside settings.json), written
// atomically (atomicWrite: tmp + rename) at BOTH ends of an op. At startup a record still marked
// running is, by construction, a restore nothing is running any more: it becomes a terminal failure
// (Interrupted, RestoreInterruptedMessage) and a per-app notice that stays until that app's next
// (Interrupted, RestoreInterruptedKey) and a per-app notice that stays until that app's next
// restore. LoadRestoreRecord reports the conversion ONCE, so the caller raises restore_interrupted once.
//
// No path set (tests that build a bare Manager, a box with backup disabled) = the old in-memory
// behaviour, silently: persistence is a property of the wired controller, not of every Manager.
// RestoreInterruptedMessage is what the household reads when the box stopped mid-restore.
const RestoreInterruptedMessage = "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra."
// RestoreInterruptedKey names what the household reads when the box stopped mid-restore. It is
// SAVED in the restore record and read on the page afterwards, so it is rendered in the box's
// language at the moment it is written (release C, R-557).
const RestoreInterruptedKey = "note.restore.interrupted"
// restoreRecordFile is the on-disk shape.
type restoreRecordFile struct {
@@ -76,7 +78,7 @@ func (m *Manager) LoadRestoreRecord() *RestoreOpResult {
}
res := RestoreOpResult{
Op: rec.Op, Stack: rec.Stack, OK: false,
Message: RestoreInterruptedMessage, FinishedAt: time.Now(), Interrupted: true,
Message: m.note(RestoreInterruptedKey), FinishedAt: time.Now(), Interrupted: true,
}
m.opLast = &res
if m.opInterrupted == nil {
+17 -14
View File
@@ -126,7 +126,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: "kézi választás",
Reason: m.note("note.tier2.reason_manual"),
}, nil
}
}
@@ -149,7 +149,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: "másik adatmeghajtó",
Reason: m.note("note.tier2.reason_other_drive"),
}, nil
}
}
@@ -171,10 +171,10 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
Label: "belső SSD (rendszer)",
Label: m.note("note.tier2.label_internal_ssd"),
IsSystemDrive: true,
StateOnly: true,
Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
Reason: m.note("note.tier2.reason_no_second"),
}, nil
}
@@ -321,14 +321,14 @@ func (m *Manager) RunTier2(stackName string) error {
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
if err != nil {
reason := tier2NoTargetReason(err)
reason := m.tier2NoTargetReason(err)
m.recordTier2NoTarget(stackName, reason)
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van")
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
return nil
}
@@ -345,7 +345,7 @@ func (m *Manager) RunTier2(stackName string) error {
}
legs = kept
if droppedOptional {
warns = append(warns, "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.")
warns = append(warns, m.note("note.tier2.ssd_partial"))
}
}
@@ -383,7 +383,7 @@ func (m *Manager) RunTier2(stackName string) error {
// compose/app.yaml carries portable secrets and nothing from inside it is logged here.
m.logger.Printf("[WARN] [backup] Tier 2 %s: unit leg SKIPPED — the recovery unit on the source drive lists no database dumps and no volume tars, while the existing copy at %s does. The copy was PRESERVED rather than replaced with an empty one (R-403). The other legs continue.",
stackName, destUnit)
warns = append(warns, tier2UnitPreservedWarning)
warns = append(warns, m.note(tier2UnitPreservedKey))
} else if err := mirror(unitDir, destUnit); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
@@ -549,7 +549,7 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
if err != nil {
info.NoTarget = true
info.NoTargetReason = tier2NoTargetReason(err)
info.NoTargetReason = m.tier2NoTargetReason(err)
return info
}
info.EffectiveLabel = target.Label
@@ -668,14 +668,17 @@ func (m *Manager) recordTier2NoTarget(stackName, reason string) {
})
}
func tier2NoTargetReason(err error) string {
// tier2NoTargetReason is SAVED (CrossDriveConfig.LastError / Tier2Info.NoTargetReason) and read on the
// per-app card later, so it is written in the box's language at write time (release C, R-557). The
// branch is on a SENTINEL, never on these words (R-553) — which is why translating them is safe.
func (m *Manager) tier2NoTargetReason(err error) string {
switch {
case errors.Is(err, errSSDNoHeadroom):
return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges"
return m.note("note.tier2.no_space_ssd")
case errors.Is(err, errNoOffDiskTarget):
return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
return m.note("note.tier2.no_other_drive")
default:
return err.Error()
return m.noteErr(err)
}
}
@@ -700,7 +703,7 @@ func rsyncMirror(src, dst string) error {
// tier2UnitPreservedWarning is the customer-facing half of the R-403 skip. It rides in
// CrossDriveBackup.LastWarning, which the per-app card already renders, so the refusal reaches the
// SURFACE and not only the log — the shape recordTier2NoTarget established.
const tier2UnitPreservedWarning = "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés."
const tier2UnitPreservedKey = "note.tier2.unit_preserved"
// unitPackageDate returns the `created_at` of a recovery unit's manifest — WHEN the package in that
// directory was captured. "" when the manifest is absent or unparseable, which the surface must read
+1 -1
View File
@@ -170,7 +170,7 @@ func (m *Manager) RunSharesTier2() error {
}
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
if err != nil {
why := tier2NoTargetReason(err)
why := m.tier2NoTargetReason(err)
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
continue