diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index f7924ac..416f959 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,40 @@ +## v0.81.0 — E-2: the absent backup target gets its own signal (2026-07-29) + +**Hub half of E-2, and it ships FIRST by necessity:** an event type the hub does not allowlist makes +`POST /event` return 400 and the event vanishes (the R-97a failure). The controller cannot emit +`backup_target_absent` until this is live. + +E-2's Phase 0 established that an absent backup target has **no prompt signal today**. The drive-gate +path stops apps and logs a WARN but emits nothing — `NotifyStorageDisconnected` is defined and never +called anywhere in the controller (verified against the gitignored-`cmd/` trap with a positive +control). A drive that is *only* a backup target has no apps to stop, so it is entirely silent. The +sole signal is the tier's own failure at its next due cycle, i.e. **up to ~24 h** on the daily local +tier — the R-100 shape, where a real fault is visible only after a deadline elapses. + +Added to BOTH registers, because each half fails differently and the second failure is the quiet one: + +- `allowedEventTypes` (`internal/api/handler.go`) — without it the event is lost at the door; +- `customerMessages` (`internal/notify/templates.go`) — without it the event IS delivered, but the + customer receives the controller's raw operator English instead of Hungarian, and nothing looks + broken. + +`backup_target_absent` is deliberately **not** folded into `storage_disconnected`. That one says "a +drive went away and some apps may have stopped"; this one says "the thing that makes your backup +survive a disk failure is gone" — a different customer action and a different operator urgency. + +Hungarian copy states the CONSEQUENCE, not just the fact: +> „A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el." + +`backup_target_restored` is the paired recovery (`info` severity — the existing recovery pattern; +`severityNotifies` is untouched and NOT widened). + +**Tests + red-proofs.** `api.TestBackupTargetEventTypesAreAllowlisted` and +`notify.TestBackupTargetCustomerMessagesArePresent` pin the pair; a third test pins that the copy +names what is at risk and what happens, so a future shortening to a bare „Meghajtó hiányzik." cannot +pass. All three red-proofed with the mutation VERIFIED to have landed first — the initial attempt +silently no-op'd (gofmt had realigned the map to three spaces) and the test "passed", which would +have been a false proof. + ## v0.80.0 — R-100: staleness counts from the last SUCCESS (2026-07-28) `OffsiteChecker.isStale` counted from `last_run`, which the controller writes **unconditionally** at the diff --git a/hub/internal/api/backup_target_event_test.go b/hub/internal/api/backup_target_event_test.go new file mode 100644 index 0000000..e62384b --- /dev/null +++ b/hub/internal/api/backup_target_event_test.go @@ -0,0 +1,25 @@ +package api + +import "testing" + +// E-2 — the absent-backup-target signal needs BOTH registers, not one. +// +// Phase 0 of E-2 established that an absent backup target has no prompt signal today: the drive-gate +// path stops apps and logs, but emits nothing, and the tier's own failure only lands at its next due +// cycle (up to ~24 h on the daily local tier). `backup_target_absent` is that missing signal. +// +// A new event type is a PAIR, and each half fails differently: +// - missing from allowedEventTypes → POST /event returns 400 and the event VANISHES (R-97a); +// - missing from customerMessages → it is delivered, but the customer gets the controller's raw +// English operator string instead of Hungarian. +// +// The second failure is the quiet one — delivery "works", so nothing looks broken. Both are pinned +// here, in the same test, because fixing one and not the other is the realistic mistake. +func TestBackupTargetEventTypesAreAllowlisted(t *testing.T) { + for _, et := range []string{"backup_target_absent", "backup_target_restored"} { + if !allowedEventTypes[et] { + t.Fatalf("%s must be in allowedEventTypes, or POST /event 400s and the absent backup "+ + "target goes silent until the tier misses its deadline", et) + } + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 9ee92f9..f41b0bc 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -1595,8 +1595,16 @@ var allowedEventTypes = map[string]bool{ "offbox_repo_reset": true, "storage_disconnected": true, "storage_reconnected": true, - "disk_warning": true, - "disk_critical": true, + // controller v0.184.0 (E-2) — the assigned whole-guest backup TARGET drive is absent. Distinct + // from storage_disconnected on purpose: that one says "a drive went away and some apps may have + // stopped"; this one says "the thing that makes a backup survive a disk failure is gone", which + // is a different action for the customer and a different urgency for the operator. Before this + // the only signal was the tier's own failure at its next due cycle — up to ~24 h on the daily + // local tier — i.e. the R-100 shape: a real fault visible only after a deadline elapsed. + "backup_target_absent": true, + "backup_target_restored": true, + "disk_warning": true, + "disk_critical": true, // controller v0.169.0 — per-disk SMART degradation (Rendben→Figyelmeztetés/Hiba). The controller // sends a dynamic Hungarian message (disk label + the triggering attribute names), so — like // offbox_enlarge_blocked — there is deliberately NO customerMessages entry (which would discard the diff --git a/hub/internal/notify/backup_target_message_test.go b/hub/internal/notify/backup_target_message_test.go new file mode 100644 index 0000000..3346d87 --- /dev/null +++ b/hub/internal/notify/backup_target_message_test.go @@ -0,0 +1,38 @@ +package notify + +import ( + "strings" + "testing" +) + +// E-2 — the Hungarian half of the backup_target_absent pair. +// +// Companion to api.TestBackupTargetEventTypesAreAllowlisted, which pins the allowlist half. They are +// deliberately two tests in two packages because the registers live in two packages, and the failure +// modes differ: an allowlist miss makes POST /event 400 (loud, the event is lost); a customerMessages +// miss delivers the event but falls back to the controller's raw operator English +// (templates.go FormatCustomerEmail: hunMessage == "" → message). The customer would receive +// "Backup target drive absent: ..." in English and nothing would look broken. +func TestBackupTargetCustomerMessagesArePresent(t *testing.T) { + for _, et := range []string{"backup_target_absent", "backup_target_restored"} { + msg, ok := customerMessages[et] + if !ok || strings.TrimSpace(msg) == "" { + t.Fatalf("customerMessages[%q] is missing — the event would be delivered to the customer "+ + "in the controller's operator English instead of Hungarian", et) + } + } +} + +// The absent message must name the CONSEQUENCE, not just the fact. "A drive is missing" is a fact the +// customer can do nothing with; "the whole-system backup will not be made until you reconnect it" is +// the thing that makes them act. This pins the distinction the copy was written for — a future +// shortening to a bare "Meghajtó hiányzik." would pass the presence test above and lose the point. +func TestBackupTargetAbsentMessageNamesTheConsequence(t *testing.T) { + msg := customerMessages["backup_target_absent"] + if !strings.Contains(msg, "rendszermentés") { + t.Errorf("backup_target_absent must name WHAT is at risk (the rendszermentés), got: %q", msg) + } + if !strings.Contains(strings.ToLower(msg), "nem készül") { + t.Errorf("backup_target_absent must state the consequence (the backup will not be made), got: %q", msg) + } +} diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index 9c225e1..5716105 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -59,14 +59,14 @@ var customerMessages = map[string]string{ // Customer-claim arc (v0.50.0) "claim_lockout": "Túl sok hibás beállító/visszaállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.", // Backup events - "backup_completed": "A biztonsági mentés sikeresen elkészült.", - "backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.", - "db_dump_completed": "Az adatbázis mentés sikeresen elkészült.", - "db_dump_failed": "Az adatbázis mentés sikertelen!", - "backup_integrity_ok": "A mentés integritás ellenőrzés sikeres.", - "backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!", - "crossdrive_completed": "A másodlagos mentés sikeresen elkészült.", - "crossdrive_failed": "A másodlagos mentés sikertelen!", + "backup_completed": "A biztonsági mentés sikeresen elkészült.", + "backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.", + "db_dump_completed": "Az adatbázis mentés sikeresen elkészült.", + "db_dump_failed": "Az adatbázis mentés sikertelen!", + "backup_integrity_ok": "A mentés integritás ellenőrzés sikeres.", + "backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!", + "crossdrive_completed": "A másodlagos mentés sikeresen elkészült.", + "crossdrive_failed": "A másodlagos mentés sikertelen!", // Offsite-repo continuity (controller v0.142.0) "offbox_repo_orphaned": "A távoli mentési tároló elárvult: a benne lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Új mentés a tároló visszaállításáig nem készül — nyisd meg a Távoli mentés oldalt.", "offbox_repo_reset": "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", @@ -87,6 +87,12 @@ var customerMessages = map[string]string{ "storage_disconnected": "Egy meghajtó leválasztva — a mentések szünetelhetnek.", "storage_reconnected": "A meghajtó újra csatlakoztatva.", + // E-2 — the drive the whole-system backup is written to is gone. Deliberately NOT folded into + // storage_disconnected: the customer action differs (reconnect THIS drive, or the system backup + // stops surviving a disk failure), and the fallback is honest about what still protects them. + "backup_target_absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.", + "backup_target_restored": "A rendszermentés meghajtója újra elérhető — a mentés folytatódik.", + // Staleness events (Hub-generated) "node_stale": "A szerver nem küldött jelentést az elmúlt időszakban.", "node_down": "A szerver nem elérhető!",