From ff1758a21c1c581ba79ea2613819a81a1d33b254 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 6 Oct 2026 01:20:03 +0200 Subject: [PATCH] R-585: the last customer-facing producers follow the household's language backup_integrity_ok / backup_integrity_failed now take facts and push bundle keys (Hungarian bytes unchanged - go-parity, pinned verbatim by TestR585_IntegrityHungarianIsUnchanged). The interrupted-operation alert (backup_failed, customer-enabled by default) used to send the operator's ENGLISH sentence to every household; NotifyInterruptedOperation composes it per language, the English byte-identical to the operator's log line. The now-callerless NotifyBackupFailed is removed. local_api_endpoint_drift is operator-only (no customer toggle) and already English by design - not changed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .../cmd/controller/appstop_wiring_test.go | 8 +- controller/cmd/controller/main.go | 36 ++---- controller/cmd/controller/r359_wiring_test.go | 37 ++++-- controller/internal/backup/appstop_marker.go | 17 +++ controller/internal/i18n/locales/en.json | 11 ++ controller/internal/i18n/locales/hu.json | 11 ++ .../internal/notify/message_customer_test.go | 10 ++ controller/internal/notify/notifier.go | 73 ++++++++++-- controller/internal/notify/r585_test.go | 110 ++++++++++++++++++ controller/scripts/i18n_go_keys.json | 21 ++++ 10 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 controller/internal/notify/r585_test.go diff --git a/controller/cmd/controller/appstop_wiring_test.go b/controller/cmd/controller/appstop_wiring_test.go index 755cda7..f10573d 100644 --- a/controller/cmd/controller/appstop_wiring_test.go +++ b/controller/cmd/controller/appstop_wiring_test.go @@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) { body := mainBody(t) names := callsInMain(t, body) - if indexOfCall(names, "NotifyBackupFailed") < 0 { + if indexOfCall(names, "NotifyInterruptedOperation") < 0 { t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " + "controller died mid-backup and nobody is told (§2.4)") } @@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) { // // R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a // non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive - // gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled + // gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled // by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer // "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult // Alarming(). @@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) { } carries := false for _, name := range callsInMain(t, ifst.Body) { - if name == "NotifyBackupFailed" { + if name == "NotifyInterruptedOperation" { carries = true } } @@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) { if !guardedByAlarming { t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " + "recovery that only REFUSED starts (drive absent) would be reported through " + - "NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " + + "NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " + "failed when the drive gate was simply doing its job (R-174)") } } diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index d90317a..58ba80c 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -695,7 +695,15 @@ func main() { // deliberate hold through it would email the customer "A biztonsági mentés sikertelen!" about an // app nothing is wrong with. The refusal is already logged at WARN with its reason. if appStopRecovery != nil && appStopRecovery.Alarming() { - notifier.NotifyBackupFailed(appStopRecovery.Message(), appStopRecovery.Detail()) + // R-585: facts, not the operator's English sentence — `backup_failed` is customer-enabled by + // default, so this line reaches the household and must follow its language. + notifier.NotifyInterruptedOperation(notify.InterruptedOperation{ + ReasonKey: appStopRecovery.Reason.MessageKey(), + Reason: string(appStopRecovery.Reason), + Restarted: len(appStopRecovery.Restarted), + Failed: len(appStopRecovery.Failed), + Refused: len(appStopRecovery.Refused), + }, appStopRecovery.Detail()) } else if appStopRecovery != nil { logger.Printf("[WARN] [appstop] %s — not alarming: %s", appStopRecovery.Message(), appStopRecovery.Detail()) } @@ -3773,7 +3781,7 @@ func runOffsiteIntegrityCheck(ctx context.Context, mgr *backup.Manager, n *notif // design (08 §6.1). A weekly success e-mail is how people stop reading their alerts. It is // still pushed, because the hub stores it and the event stream is where "was it checked?" is // answered. - n.NotifyIntegrityOK(integrityOKMsg(res)) + n.NotifyIntegrityOK(res.Duration.Round(time.Second).String(), res.ReadDataSubset) // R-585: facts; the bundle composes return res default: mgr.RecordIntegrityVerdict(res) @@ -3783,22 +3791,11 @@ func runOffsiteIntegrityCheck(ctx context.Context, mgr *backup.Manager, n *notif // The customer gets a SENTENCE. restic's own words go to the log, truncated, where the operator // can diagnose without a rebuild. R-379 is the reason that split exists: 615 bytes of raw // database text reached a customer once. - n.NotifyIntegrityFailed(integrityFailedMsg, "restic check reported repository errors") + n.NotifyIntegrityFailed("restic check reported repository errors") // R-585: the sentence is event.backup_integrity_failed return res } } -// R-359 customer-facing strings. Named constants because tests assert them verbatim and because a -// silent edit is how an honest message drifts into a comforting one. -const ( - // integrityFailedMsg names what to do and what NOT to do. "Ne törölj semmit" is load-bearing: a - // customer who believes their backups are broken may try to "start fresh", which destroys the one - // copy that might still be partly recoverable. - integrityFailedMsg = "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot." - - integrityOKBase = "A távoli mentés ellenőrzése rendben lezajlott." -) - // ── R-87 — the nightly off-site PROOF's one caller ───────────────────────────────────────────── // // ONE function, like runOffsiteIntegrityCheck above, so a future debug button cannot drift from the @@ -3859,17 +3856,6 @@ func proofEmptyDetail(res backup.ProofResult) string { return d } -// integrityOKMsg states what was actually checked, so a structure-only pass is never read as a -// full data verification. The depth is a fact the customer's sentence has to carry: "checked" means -// two different things depending on it. -func integrityOKMsg(res backup.IntegrityResult) string { - msg := integrityOKBase + " (" + res.Duration.Round(time.Second).String() - if res.ReadDataSubset != "" { - msg += ", a mentett adatok " + res.ReadDataSubset + "-át újraolvasva" - } - return msg + ")" -} - // updateGuardsAdapter implements stacks.UpdateGuards over the backup manager (slice 4). The stacks // package cannot import backup, so this is the one place the two meet. Nil-safe on b: a box with // backup disabled has no restore point, and the update is refused for that true reason. diff --git a/controller/cmd/controller/r359_wiring_test.go b/controller/cmd/controller/r359_wiring_test.go index 1371bb6..1496456 100644 --- a/controller/cmd/controller/r359_wiring_test.go +++ b/controller/cmd/controller/r359_wiring_test.go @@ -5,6 +5,8 @@ import ( "go/parser" "go/token" "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" ) // R-359 — the scheduled job must be PROVEN WIRED. @@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) { func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) { // The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database - // text reached a customer once. - for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} { - if contains(integrityFailedMsg, bad) { - t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg) - } + // text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are + // held to it — an English household must be told what not to do just as plainly. + b, err := i18n.Shared() + if err != nil { + t.Fatal(err) } - // It must still tell them what to do — and what NOT to do. - for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} { - if !contains(integrityFailedMsg, want) { - t.Errorf("the failure sentence is a dead end; missing %q", want) + for _, tc := range []struct { + lang string + wants []string + }{ + {"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}}, + {"en", []string{"Do not delete anything", "contact us"}}, + } { + msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed") + if !ok || fellBack { + t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack) + } + for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} { + if contains(msg, bad) { + t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg) + } + } + // It must still tell them what to do — and what NOT to do. + for _, want := range tc.wants { + if !contains(msg, want) { + t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want) + } } } } diff --git a/controller/internal/backup/appstop_marker.go b/controller/internal/backup/appstop_marker.go index 3614993..213156d 100644 --- a/controller/internal/backup/appstop_marker.go +++ b/controller/internal/backup/appstop_marker.go @@ -62,6 +62,23 @@ func (r AppStopReason) humanReason() string { } } +// MessageKey is the bundle key that names this reason in the household's language (R-585), or "" for a +// reason this build has no sentence for — the caller then shows the raw reason, as humanReason does. +// Pinned against humanReason by TestR585_InterruptedOperationFollowsTheHousehold (internal/notify): the +// English bundle text must equal what the operator's log line says. +func (r AppStopReason) MessageKey() string { + switch r { + case ReasonVolumeDump: + return "appstop.reason.volume_dump" + case ReasonOffboxReconstitute: + return "appstop.reason.offbox_reconstitute" + case ReasonAppExport: + return "appstop.reason.app_export" + default: + return "" + } +} + // AppStopMarker is the persisted "these apps were stopped by an operation that has not reported // finishing — they are owed a restart" note. type AppStopMarker struct { diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index c291f30..af54c70 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1433,6 +1433,17 @@ "event.db_dump_failed": "Database backup failed", "event.offbox_backup_failed": "Off-box (NAS) backup failed", "event.offbox_enlarge_blocked": "The full remote backup of %s (~%s) would go over the storage quota (%d/%d GB). The configuration and the database are still backed up; contact us for a larger quota.", + "event.backup_integrity_failed": "The check of your remote backup found an error in the store. Some of the backups may be damaged. Do not delete anything, and contact us.", + "event.backup_integrity_ok": "The check of your remote backup finished without errors. (%s)", + "event.backup_integrity_ok_subset": "The check of your remote backup finished without errors. (%s, re-reading %s of the stored data)", + "event.appstop.failed": "%s was interrupted by a controller restart and %d of %d app(s) could NOT be restarted", + "event.appstop.failed_held": "%s was interrupted by a controller restart and %d of %d app(s) could NOT be restarted (a further %d are held by an absent drive and are not counted as failures)", + "event.appstop.held": "%s was interrupted by a controller restart — %d app(s) are left stopped and HELD: their data drive is not available, so the drive gate restarts them when it returns", + "event.appstop.restarted": "%s was interrupted by a controller restart — %d app(s) were left stopped and have been restarted", + "event.appstop.restarted_held": "%s was interrupted by a controller restart — %d app(s) were left stopped and have been restarted; %d more are held by an absent drive", + "appstop.reason.volume_dump": "an app-data backup (volume dump)", + "appstop.reason.offbox_reconstitute": "an off-site restore", + "appstop.reason.app_export": "an app export", "event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)", "event.disaster_recovery_started": "Disaster recovery started (%d app(s))", "event.health_critical": "System health is critical (was: %s)", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 8ad9e2b..8cbc696 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1424,6 +1424,17 @@ "event.db_dump_failed": "Adatbázis mentés sikertelen", "event.offbox_backup_failed": "Off-box (NAS) mentés sikertelen", "event.offbox_enlarge_blocked": "A(z) %s teljes távoli mentése (~%s) túllépné a tárhelykeretet (%d/%d GB). A konfiguráció és az adatbázis továbbra is mentésre kerül; nagyobb kerethez vedd fel velünk a kapcsolatot.", + "event.backup_integrity_failed": "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot.", + "event.backup_integrity_ok": "A távoli mentés ellenőrzése rendben lezajlott. (%s)", + "event.backup_integrity_ok_subset": "A távoli mentés ellenőrzése rendben lezajlott. (%s, a mentett adatok %s-át újraolvasva)", + "event.appstop.failed": "Megszakadt %s, mert a vezérlő újraindult. Nem sikerült újraindítani %d alkalmazást (összesen %d állt le).", + "event.appstop.failed_held": "Megszakadt %s, mert a vezérlő újraindult. Nem sikerült újraindítani %d alkalmazást (összesen %d állt le). További %d alkalmazás azért áll, mert nem érhető el az adatmeghajtója; ezek nem számítanak hibának.", + "event.appstop.held": "Megszakadt %s, mert a vezérlő újraindult. %d alkalmazás leállítva maradt, mert nem érhető el az adatmeghajtója; amikor a meghajtó visszatér, újraindulnak.", + "event.appstop.restarted": "Megszakadt %s, mert a vezérlő újraindult. %d leállva maradt alkalmazás újraindult.", + "event.appstop.restarted_held": "Megszakadt %s, mert a vezérlő újraindult. %d leállva maradt alkalmazás újraindult; további %d a hiányzó adatmeghajtó miatt vár.", + "appstop.reason.volume_dump": "egy alkalmazásadat-mentés (kötetmentés)", + "appstop.reason.offbox_reconstitute": "egy távoli visszaállítás", + "appstop.reason.app_export": "egy alkalmazás-exportálás", "event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", "event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)", "event.health_critical": "Rendszer állapot kritikus (volt: %s)", diff --git a/controller/internal/notify/message_customer_test.go b/controller/internal/notify/message_customer_test.go index 6cbf071..779e672 100644 --- a/controller/internal/notify/message_customer_test.go +++ b/controller/internal/notify/message_customer_test.go @@ -66,6 +66,16 @@ func convertedProducers() []struct { {"db_dump_failed", func(n *Notifier) { n.NotifyDBDumpFailed("pg_dump: exit 1") }}, {"backup_failed_offbox", func(n *Notifier) { n.NotifyOffboxBackupFailed("a NAS nem elerheto") }}, {"offbox_enlarge_blocked", func(n *Notifier) { n.NotifyOffboxEnlargeBlocked("immich", "120 GB", 80, 100) }}, + // R-585: the remaining customer-facing producers (backup_integrity_*, the interrupted operation). + {"backup_integrity_ok", func(n *Notifier) { n.NotifyIntegrityOK("42s", "") }}, + {"backup_integrity_ok_subset", func(n *Notifier) { n.NotifyIntegrityOK("42s", "10%") }}, + {"backup_integrity_failed", func(n *Notifier) { n.NotifyIntegrityFailed("restic check reported repository errors") }}, + {"interrupted_restarted", func(n *Notifier) { + n.NotifyInterruptedOperation(InterruptedOperation{ReasonKey: "appstop.reason.volume_dump", Restarted: 2}, "op=x") + }}, + {"interrupted_failed_held", func(n *Notifier) { + n.NotifyInterruptedOperation(InterruptedOperation{ReasonKey: "appstop.reason.app_export", Restarted: 1, Failed: 1, Refused: 2}, "op=x") + }}, {"controller_updated", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", true) }}, {"controller_update_failed", func(n *Notifier) { n.NotifyControllerUpdated("0.255.0", "0.256.0", false) }}, {"controller_started", func(n *Notifier) { n.NotifyControllerStarted("0.256.0", nil) }}, diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index cf9f630..8085ae4 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -403,9 +403,55 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) } } -// NotifyBackupFailed sends a backup failure event. -func (n *Notifier) NotifyBackupFailed(message, errMsg string) { - n.PushEvent("backup_failed", "error", message, BackupDetails{Error: errMsg}) +// InterruptedOperation is the FACTS of an app-stopping operation a controller restart interrupted +// (backup.AppStopRecovery.Facts): which kind it was, and how many apps were restarted, could not be +// restarted, or are held by an absent drive. Counts only — app names travel in the detail. +type InterruptedOperation struct { + // ReasonKey is the bundle key naming the operation ("appstop.reason.*"); Reason is the raw reason, + // used only when ReasonKey is empty (a reason this build has no sentence for). + ReasonKey string + Reason string + Restarted int + Failed int + Refused int +} + +// NotifyInterruptedOperation reports, as a `backup_failed` event, that an app-stopping operation was cut +// short by a controller restart (R-585). It used to send the OPERATOR's English sentence to every +// household — `backup_failed` is customer-enabled by default — so a Hungarian household's mail carried +// one English line. The sentence is now composed per language: the operation's name is rendered in the +// SAME language as the sentence around it, which is why this does not go through pushEventMsg (whose +// arguments are the same for both languages). detail is the machine tail (names only, never env values). +func (n *Notifier) NotifyInterruptedOperation(op InterruptedOperation, detail string) { + b, err := i18n.Shared() + if err != nil { + n.logger.Printf("[ERROR] NotifyInterruptedOperation: bundle unavailable: %v", err) + n.pushEventBoth("backup_failed", "error", "event.appstop.restarted", "", BackupDetails{Error: detail}) + return + } + render := func(lang string) string { + reason := op.Reason + if op.ReasonKey != "" { + reason = b.Msg(lang, op.ReasonKey) + } + switch { + case op.Failed > 0 && op.Refused > 0: + return b.Msgf(lang, "event.appstop.failed_held", reason, op.Failed, op.Restarted+op.Failed, op.Refused) + case op.Failed > 0: + return b.Msgf(lang, "event.appstop.failed", reason, op.Failed, op.Restarted+op.Failed) + case op.Restarted == 0 && op.Refused > 0: + return b.Msgf(lang, "event.appstop.held", reason, op.Refused) + case op.Refused > 0: + return b.Msgf(lang, "event.appstop.restarted_held", reason, op.Restarted, op.Refused) + default: + return b.Msgf(lang, "event.appstop.restarted", reason, op.Restarted) + } + } + household := "" + if lang := n.boxLang(); lang != i18n.Default { + household = render(lang) + } + n.pushEventBoth("backup_failed", "error", render(i18n.Default), household, BackupDetails{Error: detail}) } // RecoveryUnitFailureDetails is the machine-readable tail of a Tier-1 capture failure. App NAMES and @@ -513,8 +559,13 @@ func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) { } // NotifyIntegrityFailed sends a backup integrity check failure event. -func (n *Notifier) NotifyIntegrityFailed(message, errMsg string) { - n.PushEvent("backup_integrity_failed", "error", message, &BackupDetails{Error: errMsg}) +// +// R-585: the sentence is a bundle key, so it follows the household's language. It names what to do and +// what NOT to do („Ne törölj semmit" is load-bearing — a household that believes its backups are broken +// may "start fresh" and destroy the one copy that might still be partly recoverable); both languages are +// pinned by TestR359_OutcomeMessagesCarryNoMachineDetail. errMsg is the machine cause, never the sentence. +func (n *Notifier) NotifyIntegrityFailed(errMsg string) { + n.pushEventMsg("backup_integrity_failed", "error", "event.backup_integrity_failed", &BackupDetails{Error: errMsg}) } // NotifyOffsiteProofEmpty (R-87) reports that the nightly off-site proof found a backup that is @@ -535,8 +586,16 @@ func (n *Notifier) NotifyOffsiteProofEmpty(message, detail string) { } // NotifyIntegrityOK sends a backup integrity check success event. -func (n *Notifier) NotifyIntegrityOK(message string) { - n.PushEvent("backup_integrity_ok", "info", message, nil) +// +// R-585: it takes the FACTS — how long the check ran, and how much of the stored data it re-read +// (empty for a structure-only pass) — so the sentence follows the household's language. The depth is a +// fact the sentence has to carry: "checked" means two different things depending on it. +func (n *Notifier) NotifyIntegrityOK(duration, readDataSubset string) { + if readDataSubset != "" { + n.pushEventMsg("backup_integrity_ok", "info", "event.backup_integrity_ok_subset", nil, duration, readDataSubset) + return + } + n.pushEventMsg("backup_integrity_ok", "info", "event.backup_integrity_ok", nil, duration) } // NotifyControllerUpdated sends a controller update event. diff --git a/controller/internal/notify/r585_test.go b/controller/internal/notify/r585_test.go new file mode 100644 index 0000000..2310f79 --- /dev/null +++ b/controller/internal/notify/r585_test.go @@ -0,0 +1,110 @@ +package notify + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" +) + +// R-585 — the interrupted-operation alert follows the household's language. +// +// It used to push backup.AppStopRecovery.Message(), the OPERATOR's English sentence, through +// `backup_failed` — a type customer-enabled by default — so a Hungarian household's mail carried one +// English line. Every shape of the recovery is walked here: +// - the English rendering must equal Message() byte for byte (what an English household and the +// operator's log read today is unchanged, and the bundle cannot drift from the log line); +// - the Hungarian rendering must carry no English and name the operation in Hungarian; +// - a Hungarian household sends no second sentence, an English one sends exactly Message(). +func TestR585_InterruptedOperationFollowsTheHousehold(t *testing.T) { + reasons := []backup.AppStopReason{backup.ReasonVolumeDump, backup.ReasonOffboxReconstitute, backup.ReasonAppExport} + shapes := []struct { + name string + restarted, failed, refused []string + }{ + {"restarted", []string{"a", "b"}, nil, nil}, + {"restarted_held", []string{"a"}, nil, []string{"c"}}, + {"failed", []string{"a"}, []string{"b"}, nil}, + {"failed_held", nil, []string{"b"}, []string{"c", "d"}}, + {"held", nil, nil, []string{"c"}}, + } + judged := 0 + for _, reason := range reasons { + if reason.MessageKey() == "" { + t.Fatalf("%s has no bundle key — the household would read the raw reason %q", reason, reason) + } + for _, sh := range shapes { + r := &backup.AppStopRecovery{Reason: reason, Restarted: sh.restarted, Failed: sh.failed, Refused: sh.refused} + op := InterruptedOperation{ + ReasonKey: reason.MessageKey(), Reason: string(reason), + Restarted: len(r.Restarted), Failed: len(r.Failed), Refused: len(r.Refused), + } + for _, lang := range []string{"en", "hu"} { + n := notifierInLang(t, lang) + var typ, hu, household string + n.pushFn = func(et, _, message, messageCustomer string, _ interface{}) { + typ, hu, household = et, message, messageCustomer + } + n.NotifyInterruptedOperation(op, r.Detail()) + judged++ + if typ != "backup_failed" { + t.Errorf("%s/%s: event type %q, want backup_failed (the hub allowlists it)", reason, sh.name, typ) + } + for _, english := range []string{"interrupted", "restart", "app(s)", "drive"} { + if strings.Contains(hu, english) { + t.Errorf("%s/%s: the Hungarian sentence carries English %q: %q", reason, sh.name, english, hu) + } + } + if !strings.Contains(hu, "Megszakadt") { + t.Errorf("%s/%s: the Hungarian sentence is not the bundle's: %q", reason, sh.name, hu) + } + switch lang { + case "en": + if household != r.Message() { + t.Errorf("%s/%s: the English household sentence drifted from the operator's log line\n got %q\n want %q", + reason, sh.name, household, r.Message()) + } + case "hu": + if household != "" { + t.Errorf("%s/%s: a Hungarian household sent a second sentence %q", reason, sh.name, household) + } + } + } + } + } + if judged != len(reasons)*len(shapes)*2 { + t.Fatalf("judged %d renderings, want %d", judged, len(reasons)*len(shapes)*2) + } +} + +// The integrity sentences moved from main.go constants into the bundle. Their Hungarian bytes are the +// ones every box has been sending (the parity rule): pinned verbatim here, the way +// TestEventMessageWireTextIsFrozen pins the others. +func TestR585_IntegrityHungarianIsUnchanged(t *testing.T) { + cases := []struct { + name string + call func(*Notifier) + want string + }{ + {"ok", func(n *Notifier) { n.NotifyIntegrityOK("42s", "") }, + "A távoli mentés ellenőrzése rendben lezajlott. (42s)"}, + {"ok_subset", func(n *Notifier) { n.NotifyIntegrityOK("1m3s", "10%") }, + "A távoli mentés ellenőrzése rendben lezajlott. (1m3s, a mentett adatok 10%-át újraolvasva)"}, + {"failed", func(n *Notifier) { n.NotifyIntegrityFailed("restic check reported repository errors") }, + "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := notifierInLang(t, "hu") + got, seen := "", 0 + n.pushFn = func(_, _, message, _ string, _ interface{}) { got, seen = message, seen+1 } + tc.call(n) + if seen != 1 { + t.Fatalf("pushed %d events, want 1", seen) + } + if got != tc.want { + t.Errorf("the Hungarian wire text changed\n got %q\n want %q", got, tc.want) + } + }) + } +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index 16cc114..090bac3 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -161,6 +161,14 @@ "note.offsite.fail_locked": "BORN AS A KEY (R-104) -- the cause line for a repository lock that survived the self-heal; a NEW sentence, never a Go literal. Pinned in both languages by TestR104_SurvivingLockIsNamed.", "err.backup.restore_drive_gone": "BORN AS A KEY (R-362) -- names the drive a restore could not reach instead of a raw permission error; a NEW sentence, never a Go literal. Pinned in both languages by TestR362_DetachedDriveIsNamed.", "restore.refuse.files.second_drive_whole": "BORN AS A KEY (R-675) -- the unit-restore refusal names the second drive's WHOLE restore (decision 26); a NEW sentence, never a Go literal. Pinned in both languages by TestR675_RefusalNamesTheWholeCopy.", + "event.appstop.failed": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "event.appstop.failed_held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "event.appstop.held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "event.appstop.restarted": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "event.appstop.restarted_held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "appstop.reason.volume_dump": "BORN AS A KEY (R-585) -- the operation's name inside the interrupted-operation alert, rendered in the sentence's own language; a NEW phrase, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "appstop.reason.offbox_reconstitute": "BORN AS A KEY (R-585) -- the operation's name inside the interrupted-operation alert, rendered in the sentence's own language; a NEW phrase, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", + "appstop.reason.app_export": "BORN AS A KEY (R-585) -- the operation's name inside the interrupted-operation alert, rendered in the sentence's own language; a NEW phrase, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", "flash.offbox.mgr_unavailable": "R-256 -- REWORDED on purpose: the old „A mentéskezelő nem …\" named an internal component and no route; it now says what is wrong in plain words and where to go (try again, then support), so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute.", "flash.offbox.mgr_unreachable": "R-256 -- REWORDED on purpose: the old „A mentéskezelő nem …\" named an internal component and no route; it now says what is wrong in plain words and where to go (try again, then support), so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute.", "flash.offbox.not_orphaned": "R-257 -- REWORDED on purpose: the old sentence put an English loanword and the internal state name „elárvult\" in front of the household; it now says why the action does not apply and where to go, so byte parity with the base literal cannot hold. Pinned in both languages by TestR256_R257_OffboxRefusalsNameARoute." @@ -1096,6 +1104,19 @@ "event.db_dump_failed": "Adatbázis mentés sikertelen", "event.offbox_backup_failed": "Off-box (NAS) mentés sikertelen", "event.offbox_enlarge_blocked": "A(z) %s teljes távoli mentése (~%s) túllépné a tárhelykeretet (%d/%d GB). A konfiguráció és az adatbázis továbbra is mentésre kerül; nagyobb kerethez vedd fel velünk a kapcsolatot.", + "event.backup_integrity_failed": "A távoli mentés ellenőrzése hibát talált a tárolóban. A mentések egy része sérült lehet. Ne törölj semmit, és vedd fel velünk a kapcsolatot.", + "event.backup_integrity_ok": [ + "A távoli mentés ellenőrzése rendben lezajlott.", + " (", + ")" + ], + "event.backup_integrity_ok_subset": [ + "A távoli mentés ellenőrzése rendben lezajlott.", + " (", + ", a mentett adatok ", + "-át újraolvasva", + ")" + ], "event.controller_updated": "Controller frissítve: %s → %s", "event.controller_update_failed": "Controller frissítés sikertelen: %s → %s", "event.controller_started": "Controller elindult (%s)",