v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s

MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

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 16:54:20 +02:00
parent 8fb2f9ef9d
commit bef39598d0
16 changed files with 1601 additions and 1068 deletions
+10 -4
View File
@@ -142,10 +142,16 @@ type SystemConfig struct {
}
type CustomerConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Domain string `yaml:"domain"`
Email string `yaml:"email"`
ID string `yaml:"id"`
Name string `yaml:"name"`
Domain string `yaml:"domain"`
Email string `yaml:"email"`
// Language (v0.256.0, R-558) is the language the OPERATOR chose when creating this customer, as
// rendered by the hub into controller.yaml. It is the language this box STARTS in and nothing
// more: the moment the household picks one on the dashboard, settings.json holds their choice
// and this value is never consulted again. A config pull therefore cannot overwrite a
// household's choice. Empty (an older hub, or a hand-written config) means Hungarian.
Language string `yaml:"language"`
TelegramChatID string `yaml:"telegram_chat_id"`
}
+20 -1
View File
@@ -2267,5 +2267,24 @@
"note.tier2_unit_confirm_date_unproven_fmt": " The copy is from: %s – that is the time of the last backup attempt; we cannot prove it succeeded.",
"note.tier2_unit_confirm_contrast": " The “Restore files” button beside it only fills in the missing files and overwrites nothing. The app stops while either one runs.",
"note.tier2_unit_stale_clause": " WARNING: this copy's data package is older than the latest backup — the package on the main drive was incomplete, so the complete one was kept. The restore uses the package named above.",
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept."
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept.",
"event.health_critical": "System health is critical (was: %s)",
"event.health_degraded": "System health has got worse (was: %s)",
"event.health_recovered": "System health is back to normal: %s (was: %s)",
"event.db_dump_completed": "Database backup finished",
"event.controller_updated": "Controller updated: %s → %s",
"event.controller_update_failed": "Controller update failed: %s → %s",
"event.controller_started": "Controller started (%s)",
"event.storage_disconnected": "Drive disconnected unexpectedly: %s",
"event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)",
"event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)",
"event.storage_reconnected": "Drive reconnected: %s",
"event.app_deployed": "App installed: %s",
"event.app_deploy_started": "App install started: %s",
"event.app_deploy_failed": "App install did not succeed: %s",
"event.app_removed": "App removed: %s",
"event.crossdrive_completed": "Second backup copy finished: %s",
"event.crossdrive_failed": "Second backup copy failed: %s",
"event.disaster_recovery_started": "Disaster recovery started (%d app(s))",
"event.disaster_recovery_completed": "Disaster recovery finished (%d succeeded, %d failed)"
}
+20 -1
View File
@@ -2256,5 +2256,24 @@
"note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.",
"note.tier2_unit_confirm_contrast": " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll.",
"note.tier2_unit_stale_clause": " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja.",
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük.",
"event.health_critical": "Rendszer állapot kritikus (volt: %s)",
"event.health_degraded": "Rendszer állapot romlott (volt: %s)",
"event.health_recovered": "Rendszer állapot helyreállt: %s (volt: %s)",
"event.db_dump_completed": "Adatbázis mentés elkészült",
"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)",
"event.storage_disconnected": "Meghajtó váratlanul leválasztva: %s",
"event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)",
"event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)",
"event.storage_reconnected": "Meghajtó újra csatlakoztatva: %s",
"event.app_deployed": "Alkalmazás telepítve: %s",
"event.app_deploy_started": "Alkalmazás telepítése elindult: %s",
"event.app_deploy_failed": "Alkalmazás telepítése nem sikerült: %s",
"event.app_removed": "Alkalmazás eltávolítva: %s",
"event.crossdrive_completed": "Másodlagos mentés elkészült: %s",
"event.crossdrive_failed": "Másodlagos mentés sikertelen: %s",
"event.disaster_recovery_started": "Katasztrófa helyreállítás elindítva (%d alkalmazás)",
"event.disaster_recovery_completed": "Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)"
}
+4 -4
View File
@@ -11,7 +11,7 @@ import (
func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var events []string
n.pushFn = func(eventType, _, msg string, _ interface{}) {
n.pushFn = func(eventType, _, msg, _ string, _ interface{}) {
if eventType == "app_start_failed" {
events = append(events, msg)
}
@@ -45,7 +45,7 @@ func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(eventType, _, _ string, _ interface{}) {
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" {
count++
}
@@ -67,7 +67,7 @@ func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(eventType, _, _ string, _ interface{}) {
n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" {
count++
}
@@ -96,7 +96,7 @@ func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int
n.pushFn = func(string, string, string, interface{}) { count++ }
n.pushFn = func(string, string, string, string, interface{}) { count++ }
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
if count != 0 {
@@ -38,7 +38,7 @@ func hubSeverityNotifies(sev string) bool {
func TestNotifyDiskHealthDegraded_SeverityRoutes(t *testing.T) {
n := &Notifier{}
var gotSev string
n.pushFn = func(eventType, severity, message string, details interface{}) { gotSev = severity }
n.pushFn = func(eventType, severity, message, _ string, details interface{}) { gotSev = severity }
n.NotifyDiskHealthDegraded(DiskAlert{Label: "sdb", Kind: DiskAlertWarn,
Attributes: []string{"függőben lévő szektorok"}})
@@ -70,7 +70,7 @@ func TestNotifyDiskHealthDegraded_WarnShape(t *testing.T) {
n := &Notifier{}
var gotType, gotMsg string
var gotDetails interface{}
n.pushFn = func(eventType, severity, message string, details interface{}) {
n.pushFn = func(eventType, severity, message, _ string, details interface{}) {
gotType, gotMsg, gotDetails = eventType, message, details
}
@@ -100,7 +100,9 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
n := &Notifier{}
var gotMsg string
var gotDetails interface{}
n.pushFn = func(eventType, severity, message string, details interface{}) { gotMsg, gotDetails = message, details }
n.pushFn = func(eventType, severity, message, _ string, details interface{}) {
gotMsg, gotDetails = message, details
}
cases := []struct {
name string
@@ -154,7 +156,7 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
func TestNotifyDiskHealthDegraded_CopyDiscipline(t *testing.T) {
n := &Notifier{}
var gotMsg string
n.pushFn = func(eventType, severity, message string, details interface{}) { gotMsg = message }
n.pushFn = func(eventType, severity, message, _ string, details interface{}) { gotMsg = message }
for _, k := range []DiskAlertKind{DiskAlertWarn, DiskAlertFailSelfReported, DiskAlertFailSectors, DiskAlertFailTemperature, DiskAlertFailWorsened} {
n.NotifyDiskHealthDegraded(DiskAlert{Label: "TESTDISK", Kind: k, Sectors: 8, TemperatureC: 61})
if !strings.Contains(gotMsg, "TESTDISK") {
@@ -0,0 +1,169 @@
package notify
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Slice 3 Part B (R-558) — the box sends its own sentence twice, and the Hungarian one never moves.
//
// The hub cannot translate a sentence the box composed: it arrives as finished text naming a drive,
// an app or a number. These tests are the whole of the controller's half of that.
func notifierInLang(t *testing.T, lang string) *Notifier {
t.Helper()
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Notifier{settings: sett, logger: log.New(io.Discard, "", 0)}
}
// converted lists every producer this release converted to a bundle key, with a call that exercises
// it. It is the table the two tests below share, so a producer cannot be covered by one and missed
// by the other.
func convertedProducers() []struct {
name string
call func(*Notifier)
} {
return []struct {
name string
call func(*Notifier)
}{
// NotifyHealthChange compares against the PREVIOUS status it saw, so each case primes it
// with a first call (which records and returns) and then transitions.
{"health_critical", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("ok", nil, nil)
n.NotifyHealthChange("fail", nil, nil)
}},
{"health_degraded", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("ok", nil, nil)
n.NotifyHealthChange("warn", nil, nil)
}},
{"health_recovered", func(n *Notifier) {
n.enabled = true
n.NotifyHealthChange("fail", nil, nil)
n.NotifyHealthChange("ok", nil, nil)
}},
{"db_dump_completed", func(n *Notifier) { n.NotifyDBDumpCompleted(DBDumpDetails{}) }},
{"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) }},
{"storage_disconnected", func(n *Notifier) { n.NotifyStorageDisconnected("Kulso HDD", nil) }},
{"storage_reconnected", func(n *Notifier) { n.NotifyStorageReconnected("Kulso HDD") }},
{"backup_target_absent", func(n *Notifier) { n.NotifyBackupTargetAbsent("Kulso HDD", "/mnt/hdd_1") }},
{"backup_target_restored", func(n *Notifier) { n.NotifyBackupTargetRestored("Kulso HDD", "/mnt/hdd_1") }},
{"app_deployed", func(n *Notifier) { n.NotifyAppDeployed("privatebin", "PrivateBin") }},
{"app_deploy_started", func(n *Notifier) { n.NotifyAppDeployStarted("privatebin", "PrivateBin") }},
{"app_deploy_failed", func(n *Notifier) { n.NotifyAppDeployFailed("privatebin", "PrivateBin", "") }},
{"app_removed", func(n *Notifier) { n.NotifyAppRemoved("privatebin", "PrivateBin") }},
{"crossdrive_completed", func(n *Notifier) { n.NotifyCrossDriveCompleted(CrossDriveDetails{StackName: "privatebin"}) }},
{"crossdrive_failed", func(n *Notifier) { n.NotifyCrossDriveFailed(CrossDriveDetails{StackName: "privatebin"}) }},
{"disaster_recovery_started", func(n *Notifier) { n.NotifyDRStarted(3) }},
{"disaster_recovery_completed", func(n *Notifier) { n.NotifyDRCompleted(7, 2) }},
}
}
// An ENGLISH household gets an English second sentence, and the Hungarian one is untouched.
func TestNotifierSendsMessageCustomerForAnEnglishHousehold(t *testing.T) {
for _, tc := range convertedProducers() {
t.Run(tc.name, func(t *testing.T) {
n := notifierInLang(t, "en")
var hu, en string
var seen int
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) {
hu, en, seen = message, messageCustomer, seen+1
}
tc.call(n)
if seen == 0 {
t.Fatal("the producer pushed nothing — nothing below was compared")
}
if en == "" {
t.Fatalf("no household sentence was sent; the English household's mail would carry "+
"the Hungarian %q", hu)
}
if en == hu {
t.Errorf("the household sentence is identical to the Hungarian one: %q", en)
}
// A rendering fault is silent in a mail body — %!s(MISSING) is a real thing customers
// have been sent by projects that did not check this.
for _, bad := range []string{"%!", "MISSING", "EXTRA", "%s", "%d"} {
if strings.Contains(en, bad) || strings.Contains(hu, bad) {
t.Errorf("a sentence carries a formatting fault (%s):\n hu %q\n en %q", bad, hu, en)
}
}
})
}
}
// A HUNGARIAN household sends NO second sentence at all.
//
// That is not an optimisation: it keeps the payload of every box in the fleet byte-for-byte what it
// is today, so the hub's fallback path — the one every existing box uses — stays the path that is
// actually exercised in production rather than becoming a branch nobody takes.
func TestHungarianHouseholdSendsNoSecondSentence(t *testing.T) {
for _, tc := range convertedProducers() {
t.Run(tc.name, func(t *testing.T) {
n := notifierInLang(t, "hu")
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
tc.call(n)
if en != "" {
t.Errorf("a Hungarian household sent a second sentence %q — the payload is no longer "+
"what every box in the fleet sends today", en)
}
if hu == "" {
t.Error("the Hungarian sentence is empty")
}
})
}
}
// A notifier with NO settings at all (setup mode, and several older tests) is Hungarian rather than
// a panic. The nil-prefs panic this project already fixed once is the same shape.
func TestNotifierWithoutSettingsIsHungarian(t *testing.T) {
n := &Notifier{logger: log.New(io.Discard, "", 0)}
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
n.NotifyAppDeployed("privatebin", "PrivateBin")
if en != "" {
t.Errorf("a notifier with no settings sent a household sentence: %q", en)
}
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
if hu != b.Msgf("hu", "event.app_deployed", "PrivateBin") {
t.Errorf("the Hungarian sentence is not the bundle's: %q", hu)
}
}
// The untranslatable tail is appended to BOTH renderings — an English household gets an English
// sentence with a foreign tail, never no sentence at all.
func TestUntranslatableTailIsKeptInBothLanguages(t *testing.T) {
n := notifierInLang(t, "en")
var hu, en string
n.pushFn = func(_, _, message, messageCustomer string, _ interface{}) { hu, en = message, messageCustomer }
n.NotifyAppDeployFailed("privatebin", "PrivateBin", "exit status 1")
for _, s := range []string{hu, en} {
if !strings.HasSuffix(s, " — exit status 1") {
t.Errorf("the untranslatable tail is missing from %q", s)
}
}
if !strings.Contains(en, "PrivateBin") || en == hu {
t.Errorf("the English sentence is wrong: %q (hu %q)", en, hu)
}
}
+131 -57
View File
@@ -13,6 +13,8 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Notifier sends structured events to the hub via /api/v1/event.
@@ -51,7 +53,11 @@ type Notifier struct {
// pushFn is a test seam for the transition-emitting notifiers (fix-3). nil → the real async
// PushEvent; tests inject a synchronous recorder.
pushFn func(eventType, severity, message string, details interface{})
// v0.256.0 (R-558): messageCustomer is IN the seam, not beside it. A seam that cannot see a new
// field is a seam that cannot test it — the same lesson this file already records one comment
// down, where PushEvent had to become the seam because emit alone made a whole class of
// producer invisible.
pushFn func(eventType, severity, message, messageCustomer string, details interface{})
// Event history ring buffer (debug page)
historyMu sync.RWMutex
@@ -164,17 +170,92 @@ type CrossDriveDetails struct {
// eventRequest is the JSON payload sent to /api/v1/event.
type eventRequest struct {
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
Details json.RawMessage `json:"details,omitempty"`
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
// MessageCustomer (v0.256.0, R-558) is the SAME sentence in the household's language.
//
// `omitempty` is load-bearing: a Hungarian household sends no second copy at all, so its event
// payload is byte-for-byte what it has always been, and the hub's fallback path — the one every
// box in the fleet uses today — is the one that keeps being exercised in production rather than
// becoming a branch nobody takes.
//
// Message stays Hungarian ALWAYS. It is what the operator is mailed, what the hub logs and what
// notification_log records; nothing an operator reads may move because a household switched.
MessageCustomer string `json:"message_customer,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
}
// PushEvent sends a structured event to the hub's /api/v1/event endpoint.
// Non-blocking (goroutine). Retries twice with 3s backoff.
// details may be nil (omitted from JSON) or a struct that marshals to JSON.
// pushEventMsg renders ONE bundle key twice — Hungarian for the wire, the household's language
// beside it — and pushes both (R-558).
//
// Producers call this instead of composing a Hungarian sentence, because the hub CANNOT translate a
// sentence the box composed: it arrives as finished text naming a drive, an app or a number. The
// only place both languages can be produced is here, where the key and its arguments still exist.
//
// The Hungarian is rendered from the SAME key, so it cannot drift from the English: there is one
// sentence with two spellings, not two sentences. That the Hungarian is byte-identical to the
// literal it replaced is pinned by TestEventMessageWireTextIsFrozen and by the Go parity gate.
func (n *Notifier) pushEventMsg(eventType, severity, key string, details interface{}, args ...interface{}) {
b, err := i18n.Shared()
if err != nil {
// The bundle is embedded, so this is a broken build rather than a runtime condition. Push
// the key itself rather than an empty sentence: an event with a visible key reaches the
// operator and gets fixed; an event with an empty message is a silent hole.
n.logger.Printf("[ERROR] pushEventMsg: bundle unavailable for %s: %v", key, err)
n.pushEventBoth(eventType, severity, key, "", details)
return
}
hungarian := b.Msgf(i18n.Default, key, args...)
// A Hungarian household sends nothing extra — see MessageCustomer's comment.
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = b.Msgf(lang, key, args...)
}
n.pushEventBoth(eventType, severity, hungarian, household, details)
}
// pushEventMsgSuffix is pushEventMsg with a tail that CANNOT be translated — a docker error, a
// validator's sentence, something that arrived as finished text. It is appended to both renderings,
// so an English household gets an English sentence with a foreign tail rather than no sentence.
func (n *Notifier) pushEventMsgSuffix(eventType, severity, key, suffix string, details interface{}, args ...interface{}) {
b, err := i18n.Shared()
if err != nil {
n.logger.Printf("[ERROR] pushEventMsgSuffix: bundle unavailable for %s: %v", key, err)
n.pushEventBoth(eventType, severity, key+suffix, "", details)
return
}
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = b.Msgf(lang, key, args...) + suffix
}
n.pushEventBoth(eventType, severity, b.Msgf(i18n.Default, key, args...)+suffix, household, details)
}
// boxLang is the household's language, nil-safe. A notifier built without settings (several tests,
// and the setup-mode path) is Hungarian rather than a panic.
func (n *Notifier) boxLang() string {
if n.settings == nil {
return i18n.Default
}
return n.settings.GetLanguage()
}
// PushEvent sends a structured event to the hub's /api/v1/event endpoint, Hungarian only.
//
// Kept for producers whose sentence is composed somewhere else and reaches them already finished —
// and for the OPERATOR-tier types, which are Hungarian (or English) by design and have no household
// half. A customer-facing producer should use pushEventMsg.
func (n *Notifier) PushEvent(eventType, severity, message string, details interface{}) {
n.pushEventBoth(eventType, severity, message, "", details)
}
func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer string, details interface{}) {
// The test seam sits HERE, not only in emit (v0.252.0, R-557). Most producers call PushEvent
// directly, so a test that set pushFn saw nothing from them and the difference was invisible: a
// producer that pushes nothing and a producer whose message is empty both leave a recorder empty.
@@ -182,7 +263,7 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
// only do that if every producer is reachable through one seam. In production pushFn is nil and
// this line does nothing.
if n.pushFn != nil {
n.pushFn(eventType, severity, message, details)
n.pushFn(eventType, severity, message, messageCustomer, details)
return
}
if !n.enabled {
@@ -200,11 +281,12 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
}
payload := eventRequest{
CustomerID: n.customerID,
EventType: eventType,
Severity: severity,
Message: message,
Details: detailsJSON,
CustomerID: n.customerID,
EventType: eventType,
Severity: severity,
Message: message,
MessageCustomer: messageCustomer,
Details: detailsJSON,
}
jsonData, err := json.Marshal(payload)
@@ -293,16 +375,13 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
if newRank > prevRank {
// Degradation
if status == "fail" {
n.PushEvent("health_critical", "error",
fmt.Sprintf("Rendszer állapot kritikus (volt: %s)", prev), details)
n.pushEventMsg("health_critical", "error", "event.health_critical", details, prev)
} else if status == "warn" {
n.PushEvent("health_degraded", "warning",
fmt.Sprintf("Rendszer állapot romlott (volt: %s)", prev), details)
n.pushEventMsg("health_degraded", "warning", "event.health_degraded", details, prev)
}
} else {
// Recovery
n.PushEvent("health_recovered", "info",
fmt.Sprintf("Rendszer állapot helyreállt: %s (volt: %s)", status, prev), details)
n.pushEventMsg("health_recovered", "info", "event.health_recovered", details, status, prev)
}
}
@@ -402,7 +481,7 @@ func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) {
// NotifyDBDumpCompleted sends a DB dump success event.
func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) {
n.PushEvent("db_dump_completed", "info", "Adatbázis mentés elkészült", details)
n.pushEventMsg("db_dump_completed", "info", "event.db_dump_completed", details)
}
// NotifyIntegrityFailed sends a backup integrity check failure event.
@@ -435,29 +514,27 @@ func (n *Notifier) NotifyIntegrityOK(message string) {
// NotifyControllerUpdated sends a controller update event.
func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool) {
severity := "info"
msg := fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer)
key := "event.controller_updated"
details := UpdateDetails{FromVersion: fromVer, ToVersion: toVer}
if !success {
severity = "error"
msg = fmt.Sprintf("Controller frissítés sikertelen: %s → %s", fromVer, toVer)
key = "event.controller_update_failed"
}
n.PushEvent("controller_updated", severity, msg, details)
n.pushEventMsg("controller_updated", severity, key, details, fromVer, toVer)
}
// NotifyControllerStarted sends a controller startup event.
// details may include self-test summary (e.g., {"selftest_pass": 8, "selftest_warn": 1, "selftest_fail": 0}).
func (n *Notifier) NotifyControllerStarted(version string, details map[string]interface{}) {
n.PushEvent("controller_started", "info",
fmt.Sprintf("Controller elindult (%s)", version), details)
n.pushEventMsg("controller_started", "info", "event.controller_started", details, version)
}
// NotifyStorageDisconnected sends a drive disconnection event.
func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string) {
msg := fmt.Sprintf("Meghajtó váratlanul leválasztva: %s", label)
n.PushEvent("storage_disconnected", "error", msg, StorageDetails{
n.pushEventMsg("storage_disconnected", "error", "event.storage_disconnected", StorageDetails{
Label: label,
StoppedApps: stoppedApps,
})
}, label)
}
// NotifyBackupTargetAbsent (E-2) reports that the drive holding the WHOLE-GUEST backup is gone.
@@ -468,23 +545,21 @@ func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string)
// at all: the tier stays DUE (targetStoragePresent checks name presence, never reachability), so the
// only evidence was its own failure at the next due cycle, up to ~24 h away on the daily local tier.
func (n *Notifier) NotifyBackupTargetAbsent(label, target string) {
n.PushEvent("backup_target_absent", "error",
fmt.Sprintf("A rendszermentés meghajtója nem érhető el: %s (%s)", label, target),
StorageDetails{Label: label})
n.pushEventMsg("backup_target_absent", "error", "event.backup_target_absent",
StorageDetails{Label: label}, label, target)
}
// NotifyBackupTargetRestored is the paired recovery. info severity — the existing recovery pattern;
// severityNotifies is deliberately NOT widened.
func (n *Notifier) NotifyBackupTargetRestored(label, target string) {
n.PushEvent("backup_target_restored", "info",
fmt.Sprintf("A rendszermentés meghajtója újra elérhető: %s (%s)", label, target),
StorageDetails{Label: label})
n.pushEventMsg("backup_target_restored", "info", "event.backup_target_restored",
StorageDetails{Label: label}, label, target)
}
// NotifyStorageReconnected sends a drive reconnection event.
func (n *Notifier) NotifyStorageReconnected(label string) {
n.PushEvent("storage_reconnected", "info",
fmt.Sprintf("Meghajtó újra csatlakoztatva: %s", label), StorageDetails{Label: label})
n.pushEventMsg("storage_reconnected", "info", "event.storage_reconnected",
StorageDetails{Label: label}, label)
}
// AgentChannelDetails carries the classified reason for a controller→agent channel-down event.
@@ -532,9 +607,8 @@ type EndpointDriftDetails struct {
// so an install that was interrupted five seconds later still stood on the hub's timeline as
// „Alkalmazás telepítve" forever — measured 2026-09-16 with mealie, which ended `not_deployed`.
func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
n.PushEvent("app_deployed", "info",
fmt.Sprintf("Alkalmazás telepítve: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_deployed", "info", "event.app_deployed",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyAppDeployStarted records the ACCEPTANCE — the fact `app_deployed` used to assert. It keeps
@@ -543,21 +617,22 @@ func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
// NOTE: the hub validates event_type against allowedEventTypes and 400s an unknown one, so this type
// MUST exist there too (hub handler.go) or the event is silently inert.
func (n *Notifier) NotifyAppDeployStarted(stackName, displayName string) {
n.PushEvent("app_deploy_started", "info",
fmt.Sprintf("Alkalmazás telepítése elindult: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_deploy_started", "info", "event.app_deploy_started",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyAppDeployFailed closes the pair. severity=warning, not info: an install the customer started
// and that did not finish is a thing someone should see, and the silent version of this is exactly
// what left a completed-install record for an app that was never installed.
func (n *Notifier) NotifyAppDeployFailed(stackName, displayName, reason string) {
msg := fmt.Sprintf("Alkalmazás telepítése nem sikerült: %s", displayName)
// The reason is appended to BOTH renderings rather than folded into the key: it arrives as
// finished text (a docker error, a validator's sentence) that this function cannot translate.
suffix := ""
if reason != "" {
msg += " — " + reason
suffix = " — " + reason
}
n.PushEvent("app_deploy_failed", "warning", msg,
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsgSuffix("app_deploy_failed", "warning", "event.app_deploy_failed", suffix,
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// AppRunState is one deployed app's running state for the fix-3 start-failure notifier: Down=true
@@ -725,9 +800,12 @@ func (n *Notifier) NotifyDiskHealthDegraded(a DiskAlert) {
}
// emit sends an event through the test seam if set, else the real async PushEvent.
//
// Hungarian only: its one producer (the disk-health alerts) still composes a finished sentence.
// It passes no household copy, which is what an un-converted producer looks like.
func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
if n.pushFn != nil {
n.pushFn(eventType, severity, message, details)
n.pushFn(eventType, severity, message, "", details)
return
}
n.PushEvent(eventType, severity, message, details)
@@ -735,27 +813,23 @@ func (n *Notifier) emit(eventType, severity, message string, details interface{}
// NotifyAppRemoved sends an app removal event.
func (n *Notifier) NotifyAppRemoved(stackName, displayName string) {
n.PushEvent("app_removed", "info",
fmt.Sprintf("Alkalmazás eltávolítva: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_removed", "info", "event.app_removed",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyCrossDriveCompleted sends a cross-drive backup success event.
func (n *Notifier) NotifyCrossDriveCompleted(details CrossDriveDetails) {
n.PushEvent("crossdrive_completed", "info",
fmt.Sprintf("Másodlagos mentés elkészült: %s", details.StackName), details)
n.pushEventMsg("crossdrive_completed", "info", "event.crossdrive_completed", details, details.StackName)
}
// NotifyCrossDriveFailed sends a cross-drive backup failure event.
func (n *Notifier) NotifyCrossDriveFailed(details CrossDriveDetails) {
n.PushEvent("crossdrive_failed", "error",
fmt.Sprintf("Másodlagos mentés sikertelen: %s", details.StackName), details)
n.pushEventMsg("crossdrive_failed", "error", "event.crossdrive_failed", details, details.StackName)
}
// NotifyDRStarted sends a disaster recovery start event.
func (n *Notifier) NotifyDRStarted(appCount int) {
n.PushEvent("disaster_recovery_started", "warning",
fmt.Sprintf("Katasztrófa helyreállítás elindítva (%d alkalmazás)", appCount), nil)
n.pushEventMsg("disaster_recovery_started", "warning", "event.disaster_recovery_started", nil, appCount)
}
// NotifyDRCompleted sends a disaster recovery completion event.
@@ -764,8 +838,8 @@ func (n *Notifier) NotifyDRCompleted(successCount, failCount int) {
if failCount > 0 {
severity = "warning"
}
n.PushEvent("disaster_recovery_completed", severity,
fmt.Sprintf("Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", successCount, failCount), nil)
n.pushEventMsg("disaster_recovery_completed", severity, "event.disaster_recovery_completed", nil,
successCount, failCount)
}
// ── Preferences sync ─────────────────────────────────────────────────
@@ -49,6 +49,14 @@ var hubSeverityVocabulary = map[string]bool{
var severityArg = map[string]int{
"emit": 1, // (eventType, severity, message, details)
"PushEvent": 1, // (eventType, severity, message, details)
// v0.256.0 (R-558): the key-based producers. Registered here in the SAME commit that introduced
// them — the walk finds severities by FUNCTION NAME, so a new push helper that is not listed is
// a set of call sites this contract silently stops checking. It caught its own omission: the
// conversion moved two dynamic sites off PushEvent and the register immediately reported them as
// no longer existing, which is the "a stale register entry is also a failure" half doing its job.
"pushEventMsg": 1, // (eventType, severity, key, details, args...)
"pushEventMsgSuffix": 1, // (eventType, severity, key, suffix, details, args...)
"pushEventBoth": 1, // (eventType, severity, message, messageCustomer, details)
}
// knownDynamicSeveritySites are the call sites that pass a VARIABLE rather than a literal, so this
@@ -57,7 +65,13 @@ var severityArg = map[string]int{
// honest limit of an AST check is that it cannot follow a variable, and an unlisted limit is not a
// limit, it is a hole.
var knownDynamicSeveritySites = map[string]string{
"internal/notify/notifier.go:emit": `pass-through of its own severity parameter to PushEvent — the value is checked at emit's CALLERS, above`,
"internal/notify/notifier.go:emit": `pass-through of its own severity parameter to PushEvent — the value is checked at emit's CALLERS, above`,
// v0.256.0 (R-558): three more pass-throughs, the same shape as emit. Each forwards its OWN
// severity parameter to pushEventBoth without inspecting it, so the value is checked at their
// callers — which is where the walk's 36 literals are found.
"internal/notify/notifier.go:PushEvent": `pass-through of its own severity parameter to pushEventBoth — checked at PushEvent's callers`,
"internal/notify/notifier.go:pushEventMsg": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsg's callers`,
"internal/notify/notifier.go:pushEventMsgSuffix": `pass-through of its own severity parameter to pushEventBoth — checked at pushEventMsgSuffix's callers`,
"internal/notify/notifier.go:NotifyControllerUpdated": `local var: "info", or "error" when the update failed`,
"internal/notify/notifier.go:NotifyDRCompleted": `local var: "info", or "warning" when failCount > 0`,
"internal/notify/notifier.go:NotifyAgentChannelDown": `internal/channelhealth's classifier — every severity literal in checker.go is "warning" or "error"`,
@@ -79,7 +79,7 @@ func TestEventMessageWireTextIsFrozen(t *testing.T) {
var got string
var seen int
n := &Notifier{}
n.pushFn = func(_, _, message string, _ interface{}) {
n.pushFn = func(_, _, message, _ string, _ interface{}) {
got, seen = message, seen+1
}
tc.call(n)
@@ -102,7 +102,7 @@ func TestEventMessageWireTextIsFrozen(t *testing.T) {
func TestEventMessagesCarryNoBundleKey(t *testing.T) {
var msgs []string
n := &Notifier{}
n.pushFn = func(_, _, message string, _ interface{}) { msgs = append(msgs, message) }
n.pushFn = func(_, _, message, _ string, _ interface{}) { msgs = append(msgs, message) }
n.NotifyAppDeployed("privatebin", "PrivateBin")
n.NotifyAppRemoved("privatebin", "PrivateBin")
@@ -0,0 +1,109 @@
package settings
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// The bootstrap rule (R-558): a box starts in the language the hub says, until the household picks.
func newSettings(t *testing.T) *Settings {
t.Helper()
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
func TestLanguageBootstrapFromConfig(t *testing.T) {
// 3. Nothing anywhere → Hungarian.
s := newSettings(t)
if got := s.GetLanguage(); got != "hu" {
t.Errorf("no choice, no config = %q, want hu", got)
}
// 2. The config's language, for a box nobody has told yet.
s.SetConfigLanguage("en")
if got := s.GetLanguage(); got != "en" {
t.Errorf("config en, no choice = %q, want en — a box created as English starts Hungarian", got)
}
// 1. An explicit choice WINS, in both directions.
if err := s.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
if got := s.GetLanguage(); got != "hu" {
t.Errorf("chose hu with config en = %q, want hu — the household's choice lost", got)
}
s2 := newSettings(t)
s2.SetConfigLanguage("hu")
if err := s2.SetLanguage("en"); err != nil {
t.Fatal(err)
}
if got := s2.GetLanguage(); got != "en" {
t.Errorf("chose en with config hu = %q, want en", got)
}
// An unsupported config value is ignored, not stored and not rendered.
s3 := newSettings(t)
s3.SetConfigLanguage("klingon")
if got := s3.GetLanguage(); got != "hu" {
t.Errorf("unsupported config language = %q, want hu", got)
}
}
// The config default must NEVER be written to settings.json. If it were, a box would record a
// choice the household never made, and the next config pull could no longer be distinguished from
// a deliberate switch.
func TestConfigLanguageIsNotPersisted(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
s, err := Load(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
s.SetConfigLanguage("en")
// Force a save through an unrelated setter, the way any ordinary use would.
if err := s.SetBackupWindowStart("01:00"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"language"`) {
t.Errorf("the config default was persisted into settings.json — it is now indistinguishable "+
"from a household's choice:\n%s", raw)
}
// And a REAL choice IS persisted, or the control proves nothing.
if err := s.SetLanguage("en"); err != nil {
t.Fatal(err)
}
raw, _ = os.ReadFile(path)
if !strings.Contains(string(raw), `"language"`) {
t.Error("a real choice was NOT persisted — the negative control above is meaningless")
}
}
// SEAM-WIRING PIN (the class with four instances in this project): the setter exists, but is it
// CALLED? `cmd/` is gitignored, so ripgrep skips main.go and a reviewer's search finds nothing —
// which is exactly how a seam gets built and never wired here.
func TestConfigLanguageIsWiredInMain(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go"))
if err != nil {
t.Fatalf("cannot read main.go to check the wiring: %v", err)
}
src := string(raw)
if !strings.Contains(src, "sett.SetConfigLanguage(cfg.Customer.Language)") {
t.Error("main.go never calls SetConfigLanguage — the bootstrap default is an unwired seam, " +
"and every box would start Hungarian whatever the hub said")
}
// It must come from the CONFIG, not from a literal someone pasted to make this pass.
if strings.Contains(src, `SetConfigLanguage("`) {
t.Error("main.go passes a literal to SetConfigLanguage — it must pass cfg.Customer.Language")
}
}
+38 -3
View File
@@ -27,6 +27,13 @@ type Settings struct {
// surfaced to the dashboard as a persistent banner. Not persisted.
LoadWarning string `json:"-"`
// configLanguage (v0.256.0, R-558) is `customer.language` from controller.yaml — the language
// the hub says this box should START in. It is consulted ONLY when the household has never
// chosen: GetLanguage prefers the stored choice, always. Not persisted, and deliberately not a
// field of the JSON: it belongs to the config, which is re-pulled, and writing it into
// settings.json would turn a default into a choice the household never made.
configLanguage string `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
@@ -819,12 +826,40 @@ func (s *Settings) SetBackupWindowStart(start string) error {
return s.save()
}
// GetLanguage returns the household's dashboard language, normalised: anything unset or unknown is
// Hungarian (i18n.Default).
// SetConfigLanguage records the language controller.yaml says this box should start in.
//
// Called once at startup and again after each config pull. It never touches s.Language: a
// re-delivered config must not be able to change what a household chose.
func (s *Settings) SetConfigLanguage(lang string) {
s.mu.Lock()
defer s.mu.Unlock()
s.configLanguage = lang
}
// GetLanguage returns the household's dashboard language, normalised.
//
// THE ORDER, and it is the whole of the bootstrap rule (R-558):
//
// 1. What the HOUSEHOLD chose, from settings.json. An explicit choice always wins, and it wins
// forever — a later config pull cannot unseat it.
// 2. What the hub said to start in (`customer.language`), for a box nobody has told yet. This is
// what makes "create the customer as English" produce an English box on first boot.
// 3. Hungarian.
//
// Steps 1 and 2 are DIFFERENT KINDS OF FACT and the difference is the reason this is not one field:
// an empty s.Language means "never chosen", which is not the same as "chose Hungarian". Collapsing
// them would make an English household's box silently revert to Hungarian the first time anyone
// looked at a config default.
func (s *Settings) GetLanguage() string {
s.mu.RLock()
defer s.mu.RUnlock()
return i18n.Normalize(s.Language)
if i18n.IsSupported(s.Language) {
return s.Language
}
if i18n.IsSupported(s.configLanguage) {
return s.configLanguage
}
return i18n.Default
}
// SetLanguage stores the household's dashboard language and saves. Only a supported language is