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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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) }},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user