v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s
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:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user