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
+31 -1
View File
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
---
### 18. Dashboard language (i18n) (v0.247.0–v0.254.0)
### 18. Dashboard language (i18n) (v0.247.0–v0.256.0)
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
@@ -3820,6 +3820,36 @@ controller/
## Configuration
#### 18.5 The e-mails the hub sends (v0.256.0, slice 3)
**The hub writes a household's e-mails in the household's language** from hub v0.118.0. It learns
which language from the box: every report carries `"language"` (since v0.247.0), and the hub prefers
the last reported one over the operator's creation-time default. Nothing here needs a setting.
**The box sends about a third of those sentences itself**, and the hub cannot translate one — it
arrives as finished text naming a drive, an app or a number. So `POST /api/v1/event` carries
**`message_customer`**: the same sentence in the household's language, beside the unchanged Hungarian
`message`.
- `message` is **always Hungarian**. It is what the operator is mailed, what the hub logs and what
`notification_log` records; a household switching language moves nothing an operator reads.
- A **Hungarian household sends no second copy at all** (`omitempty`), so its payload is byte-for-byte
what every box has always sent.
- 19 producers render both from ONE bundle key (`event.*`): health ×3, controller started/updated/
update-failed, storage disconnected/reconnected, backup target absent/restored, app deployed/
deploy-started/deploy-failed/removed, crossdrive ×2, disaster recovery ×2, db-dump completed.
- An **untranslatable tail** (a docker error, a validator's sentence) is appended to both renderings.
- **Not converted, deliberately:** the operator-tier types (the hub keeps them off the customer
channel) and the producers whose sentence is composed in another package and reaches the notifier
already finished. Those still send Hungarian only, so an English household can still see one
Hungarian line in some mails. Tracked as a row.
**A new box starts in the language the operator picked.** The hub renders `customer.language` into
`controller.yaml`; `GetLanguage` prefers the household's stored choice, then that, then Hungarian.
The config value is **never written into `settings.json`** — persisting it would record a choice the
household never made.
### Controller config (`controller.yaml`)
Single YAML file per customer, infrastructure-only. Does **not** contain app-specific config.
+5
View File
@@ -341,6 +341,11 @@ func main() {
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
}
sett.SetDebug(cfg.Logging.Level == "debug")
// v0.256.0 (R-558): the language the hub says this box should START in. Consulted by
// GetLanguage ONLY when the household has never chosen — an explicit choice in settings.json
// always wins, so a config pull cannot unseat it. A config change restarts the controller, so
// binding it once here is enough. Pinned by TestConfigLanguageIsWiredInMain.
sett.SetConfigLanguage(cfg.Customer.Language)
// --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
+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
File diff suppressed because it is too large Load Diff