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
+46
View File
@@ -1,3 +1,49 @@
## v0.256.0 — the box sends its own sentence in the household's language (2026-09-18, R-558 Part B)
**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.
Slice 3's other half. The hub now writes the household's e-mails in their language, but roughly 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` — the same sentence in the household's language,
beside the unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing
extra at all**: its payload stays byte-for-byte what it is today, so the hub's fallback path — the
one the whole fleet uses — keeps being the path production exercises, rather than becoming a branch
nobody takes.
- **19 producers converted** to bundle keys, rendered twice from ONE key: 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. `message` stays Hungarian **always** — it is what the operator is mailed and what
`notification_log` records.
- **The Hungarian is byte-identical, and it is measured twice over**: `TestEventMessageWireTextIsFrozen`
(goldens captured at the slice-2 base commit) and the **Go parity gate**, which now checks all 19
keys against the base-commit literals and passes.
- **`customer.language` bootstraps a new box.** `GetLanguage` prefers the household's stored choice,
then `controller.yaml`'s `customer.language`, then Hungarian. The config value is **never written to
`settings.json`** — persisting it would record a choice the household never made, and a later
config pull could no longer be told apart from a deliberate switch.
- **The untranslatable tail** (a docker error, a validator's sentence) is appended to BOTH renderings,
so an English household gets an English sentence with a foreign tail rather than no sentence.
**Three guards had to learn the change, and one of them caught me:**
- The test seam `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot test
it — the lesson this file already records one release back, when `PushEvent` had to become the seam.
- The R-329 severity contract walks call sites BY FUNCTION NAME. Converting producers moved two
dynamic-severity sites off `PushEvent`, and the register immediately reported them as no longer
existing — the "a stale register entry is also a failure" half doing exactly its job. The three new
push helpers are registered; the walk now checks **36** severity literals, up from 20.
- `TestConfigLanguageIsWiredInMain` reads `main.go` and asserts the setter is CALLED. `cmd/` is
gitignored, so ripgrep skips `main.go` and a reviewer's search finds nothing — which is precisely
how a seam gets built and never wired in this repo.
**A mistake worth recording:** the first pass of the conversion dropped the `displayName` argument
from three producers, which would have mailed customers `Alkalmazás telepítve: %!s(MISSING)`. It was
caught by reading the diff, and is now pinned by a test that refuses any sentence containing `%!`,
`MISSING`, `%s` or `%d` in either language.
## v0.255.0 — The globe on the sign-in-flow pages: styled, and inside the card (2026-09-18, fixes v0.254.0) ## v0.255.0 — The globe on the sign-in-flow pages: styled, and inside the card (2026-09-18, fixes v0.254.0)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved. One Hungarian **MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved. One Hungarian
+42 -56
View File
@@ -1,70 +1,56 @@
# REPORT — the globe on the sign-in-flow pages, fixed (controller v0.255.0) # REPORT — localisation slice 3 Part B: the box sends its sentence in the household's language
**2026-09-18 · base `2e9d402` (v0.254.0) · MinAgent 0.131.0, unchanged · no hub release.** **controller v0.256.0** · base `8fb2f9ef9d5a` · 2026-09-18 · R-558 Part B · MinAgent 0.131.0 (unchanged)
Architecture named: `felhom.eu/documentation/architecture/10-localisation.md` §3 (who picks the
language, and where the switch lives).
## 1. Claims in the prompt that live source disproved — named first ## What shipped
| the prompt said | source says | The hub half (v0.118.0/0.118.1) writes a household's e-mails in their language. This is the other
|---|---| half: about a third of those mails carry a sentence the BOX composed — naming a drive, an app, a
| "only the three shells lack `?v=`" | **FIVE do**: `login`, `claim`, `recovery`, **and both guest share pages** (`launcher_shared`, `launcher_share_password`). The share pages carry no globe, but the stale-stylesheet fault is the same for any CSS change and their visitor is the likeliest to hold an old copy. All five fixed — which is why 3 fixtures outside the named three changed, against the prompt's "zero re-captures outside the three shells". (8 first-boot wizard templates also lack it; out of scope, R-554 deletes them.) | number — and the hub cannot translate one. The box now sends it twice.
| "`.Version` is already in their data" | **Only in two.** `login` (auth.go:343) and `claim` (claim.go:289) set it; `recovery` and both share pages did not. Rather than add it three times, it is set once in `executeTemplateLang` — the choke point every shell renders through — so the next shell cannot miss it. |
| "`login.html` L11 … `style.css` L3683-3684" | Confirmed, line for line. |
## 2. What was wrong, and why no test saw it - **`message_customer`** on the event wire: the same sentence in the household's language, beside the
unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing extra**, so
its payload stays byte-for-byte what every box in the fleet sends today.
- **19 producers converted** to bundle keys, both renderings from ONE key.
- **`customer.language` bootstraps a new box**: stored choice → config → Hungarian. The config value
is never persisted into `settings.json`; doing so would record a choice nobody made.
**The globe rendered unstyled** for anyone whose browser had `style.css` cached from before v0.254.0: ## The Hungarian did not move, measured twice
the shells requested it with no `?v=`, so the cached copy — which has no `.lang-globe` rules — kept
being served. The markup was correct, which is why every existing test passed: **they all read the
HTML, and the fault was in which CSS file the browser fetched.** It took a screenshot to see it.
**And the globe floated outside the card**, pinned to the corner of the viewport, reading as a stray - `TestEventMessageWireTextIsFrozen` — goldens captured at the slice-2 base commit.
browser control rather than part of the page. - **The Go parity gate** — all 19 new keys checked byte-for-byte against the base-commit literals
(`688 slice-2 keys listed, 34 pre-existing, 484 named in Go`, gate OK).
## 3. The fix ## Three guards had to learn the change, and one caught me
- `?v={{.Version}}` on all five shells; `Version` set in `executeTemplateLang`. - **The test seam** `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot
- `.shell-lang` is now `display:flex; justify-content:center; margin-top:1.5rem` — a plain block inside test it — the lesson this repo recorded one release ago when `PushEvent` had to become the seam.
the card. The absolute positioning and the menu-direction override are **deleted**, so the shared - **The R-329 severity contract** walks call sites by FUNCTION NAME. Converting producers moved two
`.lang-globe-menu` rule applies and the dashboard and the shells cannot drift apart. dynamic-severity sites off `PushEvent` and the register reported them as no longer existing — the
- The div moved to the end of each card: under the footer on `login` and `claim`, last in the card on "a stale register entry is also a failure" half doing its job. The three new helpers are registered;
`recovery` (which has no footer paragraph). the walk now checks **36** severity literals, up from 20.
- **`TestConfigLanguageIsWiredInMain`** reads `main.go` and asserts the setter is 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 in this repo. Red-proofed by deleting the call.
## 3b. A third defect, caught by an existing test ## A mistake, named
Putting the version on the guest share pages would have printed the controller build onto a page **a The first pass of the conversion **dropped the `displayName` argument from three producers**
stranger with a capability URL can open**. `TestShareGuest_HeadersTilesNoAdminChrome` already refused (`app_deployed`, `app_deploy_started`, `app_removed`). Shipped, that would have mailed customers
that, and refused it here — a good test earning its keep. Those two pages now take an **opaque `Alkalmazás telepítve: %!s(MISSING)`. Caught by reading my own diff, and now pinned by a test that
per-build tag** instead (`AssetTag`): same cache-busting, no disclosure. refuses any sentence containing `%!`, `MISSING`, `%s` or `%d` in either language.
The fill is one function, `addShellAssetData`, called by `executeTemplateLang` **and** by the parity ## What is NOT converted, and why
harness. Slice 2 release C got the fixture/production split wrong twice; sharing the function is what
makes a third time impossible rather than merely unlikely.
## 4. Tests, red-proofed - **The operator-tier types** (15 of them, listed in the hub's `operatorOnlyEvents`): the operator
reads Hungarian, and these never reach a customer.
- **Producers whose sentence is composed in another package** and reaches the notifier already
finished — `backup_failed`, `db_dump_failed`, `backup_integrity_ok`/`_failed`,
`offbox_enlarge_blocked`, `local_api_endpoint_drift`. They still send Hungarian only, so an English
household can still see one Hungarian line in some mails. **Filed as a row rather than left as
prose.**
- The R-570 sentence, which stays Hungarian until that row closes.
`TestGlobeOnAnonymousShells` gained: the stylesheet carries a **non-empty** `?v=`; the globe is inside ## Green
the card; the globe is below the footer where one exists. Each was red-proofed by restoring the
defect's exact shape — the unversioned link (convicts, naming the stylesheet) and the globe put back
before the card (convicts, "floats outside it").
`testServer` gained `version: "test"`: an empty `?v=` busts a cache exactly once and never again, and `go build` / `go vet` / `go test ./...` clean. All 17 controller gates OK. All 23 decoys behave.
baking that into a fixture would have hidden it. The test now refuses an empty one.
## 5. Parity
15 fixtures re-captured — 1 login, 4 claim, 7 recovery, 3 guest share — and **91 identical, including
every dashboard page**. Four change shapes: the three shells gained the moved globe AND the buster;
the two guest pages gained only the buster, as an opaque tag. The whole diff is the `?v=` attribute and the moved block, nothing else:
`felhom.eu/documentation/audits/i18n-slice2-2026-09-18/D/parity-diff.txt`.
## 6. Green gate
`go build ./... && go vet ./... && go test ./...` green; all controller gates OK.
## 7. Row
**R-579** — the cache-buster gap: found, fixed and closed in this session, with the general form
recorded (a template that loads a versioned asset without the version is invisible to every test that
reads markup).
+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: Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`. `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
@@ -3820,6 +3820,36 @@ controller/
## Configuration ## 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`) ### Controller config (`controller.yaml`)
Single YAML file per customer, infrastructure-only. Does **not** contain app-specific config. 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) logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
} }
sett.SetDebug(cfg.Logging.Level == "debug") 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 --- // --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
+10 -4
View File
@@ -142,10 +142,16 @@ type SystemConfig struct {
} }
type CustomerConfig struct { type CustomerConfig struct {
ID string `yaml:"id"` ID string `yaml:"id"`
Name string `yaml:"name"` Name string `yaml:"name"`
Domain string `yaml:"domain"` Domain string `yaml:"domain"`
Email string `yaml:"email"` 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"` 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_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_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_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_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_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_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) { func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var events []string var events []string
n.pushFn = func(eventType, _, msg string, _ interface{}) { n.pushFn = func(eventType, _, msg, _ string, _ interface{}) {
if eventType == "app_start_failed" { if eventType == "app_start_failed" {
events = append(events, msg) events = append(events, msg)
} }
@@ -45,7 +45,7 @@ func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) { func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int var count int
n.pushFn = func(eventType, _, _ string, _ interface{}) { n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" { if eventType == "app_start_failed" {
count++ count++
} }
@@ -67,7 +67,7 @@ func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) { func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int var count int
n.pushFn = func(eventType, _, _ string, _ interface{}) { n.pushFn = func(eventType, _, _, _ string, _ interface{}) {
if eventType == "app_start_failed" { if eventType == "app_start_failed" {
count++ count++
} }
@@ -96,7 +96,7 @@ func TestNotifyAppStartFailures_StopStartCrashSequence(t *testing.T) {
func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) { func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false) n := New("http://hub", "key", "cust", nil, log.New(io.Discard, "", 0), false)
var count int 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}})
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}}) n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
if count != 0 { if count != 0 {
@@ -38,7 +38,7 @@ func hubSeverityNotifies(sev string) bool {
func TestNotifyDiskHealthDegraded_SeverityRoutes(t *testing.T) { func TestNotifyDiskHealthDegraded_SeverityRoutes(t *testing.T) {
n := &Notifier{} n := &Notifier{}
var gotSev string 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, n.NotifyDiskHealthDegraded(DiskAlert{Label: "sdb", Kind: DiskAlertWarn,
Attributes: []string{"függőben lévő szektorok"}}) Attributes: []string{"függőben lévő szektorok"}})
@@ -70,7 +70,7 @@ func TestNotifyDiskHealthDegraded_WarnShape(t *testing.T) {
n := &Notifier{} n := &Notifier{}
var gotType, gotMsg string var gotType, gotMsg string
var gotDetails interface{} 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 gotType, gotMsg, gotDetails = eventType, message, details
} }
@@ -100,7 +100,9 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
n := &Notifier{} n := &Notifier{}
var gotMsg string var gotMsg string
var gotDetails interface{} 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 { cases := []struct {
name string name string
@@ -154,7 +156,7 @@ func TestNotifyDiskHealthDegraded_FailShapes(t *testing.T) {
func TestNotifyDiskHealthDegraded_CopyDiscipline(t *testing.T) { func TestNotifyDiskHealthDegraded_CopyDiscipline(t *testing.T) {
n := &Notifier{} n := &Notifier{}
var gotMsg string 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} { for _, k := range []DiskAlertKind{DiskAlertWarn, DiskAlertFailSelfReported, DiskAlertFailSectors, DiskAlertFailTemperature, DiskAlertFailWorsened} {
n.NotifyDiskHealthDegraded(DiskAlert{Label: "TESTDISK", Kind: k, Sectors: 8, TemperatureC: 61}) n.NotifyDiskHealthDegraded(DiskAlert{Label: "TESTDISK", Kind: k, Sectors: 8, TemperatureC: 61})
if !strings.Contains(gotMsg, "TESTDISK") { 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" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "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. // 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 // pushFn is a test seam for the transition-emitting notifiers (fix-3). nil → the real async
// PushEvent; tests inject a synchronous recorder. // 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) // Event history ring buffer (debug page)
historyMu sync.RWMutex historyMu sync.RWMutex
@@ -164,17 +170,92 @@ type CrossDriveDetails struct {
// eventRequest is the JSON payload sent to /api/v1/event. // eventRequest is the JSON payload sent to /api/v1/event.
type eventRequest struct { type eventRequest struct {
CustomerID string `json:"customer_id"` CustomerID string `json:"customer_id"`
EventType string `json:"event_type"` EventType string `json:"event_type"`
Severity string `json:"severity"` Severity string `json:"severity"`
Message string `json:"message"` Message string `json:"message"`
Details json.RawMessage `json:"details,omitempty"` // 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. // PushEvent sends a structured event to the hub's /api/v1/event endpoint.
// Non-blocking (goroutine). Retries twice with 3s backoff. // Non-blocking (goroutine). Retries twice with 3s backoff.
// details may be nil (omitted from JSON) or a struct that marshals to JSON. // 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{}) { 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 // 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 // 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. // 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 // only do that if every producer is reachable through one seam. In production pushFn is nil and
// this line does nothing. // this line does nothing.
if n.pushFn != nil { if n.pushFn != nil {
n.pushFn(eventType, severity, message, details) n.pushFn(eventType, severity, message, messageCustomer, details)
return return
} }
if !n.enabled { if !n.enabled {
@@ -200,11 +281,12 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
} }
payload := eventRequest{ payload := eventRequest{
CustomerID: n.customerID, CustomerID: n.customerID,
EventType: eventType, EventType: eventType,
Severity: severity, Severity: severity,
Message: message, Message: message,
Details: detailsJSON, MessageCustomer: messageCustomer,
Details: detailsJSON,
} }
jsonData, err := json.Marshal(payload) jsonData, err := json.Marshal(payload)
@@ -293,16 +375,13 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
if newRank > prevRank { if newRank > prevRank {
// Degradation // Degradation
if status == "fail" { if status == "fail" {
n.PushEvent("health_critical", "error", n.pushEventMsg("health_critical", "error", "event.health_critical", details, prev)
fmt.Sprintf("Rendszer állapot kritikus (volt: %s)", prev), details)
} else if status == "warn" { } else if status == "warn" {
n.PushEvent("health_degraded", "warning", n.pushEventMsg("health_degraded", "warning", "event.health_degraded", details, prev)
fmt.Sprintf("Rendszer állapot romlott (volt: %s)", prev), details)
} }
} else { } else {
// Recovery // Recovery
n.PushEvent("health_recovered", "info", n.pushEventMsg("health_recovered", "info", "event.health_recovered", details, status, prev)
fmt.Sprintf("Rendszer állapot helyreállt: %s (volt: %s)", status, prev), details)
} }
} }
@@ -402,7 +481,7 @@ func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) {
// NotifyDBDumpCompleted sends a DB dump success event. // NotifyDBDumpCompleted sends a DB dump success event.
func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) { 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. // NotifyIntegrityFailed sends a backup integrity check failure event.
@@ -435,29 +514,27 @@ func (n *Notifier) NotifyIntegrityOK(message string) {
// NotifyControllerUpdated sends a controller update event. // NotifyControllerUpdated sends a controller update event.
func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool) { func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool) {
severity := "info" severity := "info"
msg := fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer) key := "event.controller_updated"
details := UpdateDetails{FromVersion: fromVer, ToVersion: toVer} details := UpdateDetails{FromVersion: fromVer, ToVersion: toVer}
if !success { if !success {
severity = "error" 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. // NotifyControllerStarted sends a controller startup event.
// details may include self-test summary (e.g., {"selftest_pass": 8, "selftest_warn": 1, "selftest_fail": 0}). // 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{}) { func (n *Notifier) NotifyControllerStarted(version string, details map[string]interface{}) {
n.PushEvent("controller_started", "info", n.pushEventMsg("controller_started", "info", "event.controller_started", details, version)
fmt.Sprintf("Controller elindult (%s)", version), details)
} }
// NotifyStorageDisconnected sends a drive disconnection event. // NotifyStorageDisconnected sends a drive disconnection event.
func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string) { func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string) {
msg := fmt.Sprintf("Meghajtó váratlanul leválasztva: %s", label) n.pushEventMsg("storage_disconnected", "error", "event.storage_disconnected", StorageDetails{
n.PushEvent("storage_disconnected", "error", msg, StorageDetails{
Label: label, Label: label,
StoppedApps: stoppedApps, StoppedApps: stoppedApps,
}) }, label)
} }
// NotifyBackupTargetAbsent (E-2) reports that the drive holding the WHOLE-GUEST backup is gone. // 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 // 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. // 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) { func (n *Notifier) NotifyBackupTargetAbsent(label, target string) {
n.PushEvent("backup_target_absent", "error", n.pushEventMsg("backup_target_absent", "error", "event.backup_target_absent",
fmt.Sprintf("A rendszermentés meghajtója nem érhető el: %s (%s)", label, target), StorageDetails{Label: label}, label, target)
StorageDetails{Label: label})
} }
// NotifyBackupTargetRestored is the paired recovery. info severity — the existing recovery pattern; // NotifyBackupTargetRestored is the paired recovery. info severity — the existing recovery pattern;
// severityNotifies is deliberately NOT widened. // severityNotifies is deliberately NOT widened.
func (n *Notifier) NotifyBackupTargetRestored(label, target string) { func (n *Notifier) NotifyBackupTargetRestored(label, target string) {
n.PushEvent("backup_target_restored", "info", n.pushEventMsg("backup_target_restored", "info", "event.backup_target_restored",
fmt.Sprintf("A rendszermentés meghajtója újra elérhető: %s (%s)", label, target), StorageDetails{Label: label}, label, target)
StorageDetails{Label: label})
} }
// NotifyStorageReconnected sends a drive reconnection event. // NotifyStorageReconnected sends a drive reconnection event.
func (n *Notifier) NotifyStorageReconnected(label string) { func (n *Notifier) NotifyStorageReconnected(label string) {
n.PushEvent("storage_reconnected", "info", n.pushEventMsg("storage_reconnected", "info", "event.storage_reconnected",
fmt.Sprintf("Meghajtó újra csatlakoztatva: %s", label), StorageDetails{Label: label}) StorageDetails{Label: label}, label)
} }
// AgentChannelDetails carries the classified reason for a controller→agent channel-down event. // 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 // 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`. // „Alkalmazás telepítve" forever — measured 2026-09-16 with mealie, which ended `not_deployed`.
func (n *Notifier) NotifyAppDeployed(stackName, displayName string) { func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
n.PushEvent("app_deployed", "info", n.pushEventMsg("app_deployed", "info", "event.app_deployed",
fmt.Sprintf("Alkalmazás telepítve: %s", displayName), AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
AppDetails{StackName: stackName, DisplayName: displayName})
} }
// NotifyAppDeployStarted records the ACCEPTANCE — the fact `app_deployed` used to assert. It keeps // 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 // 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. // MUST exist there too (hub handler.go) or the event is silently inert.
func (n *Notifier) NotifyAppDeployStarted(stackName, displayName string) { func (n *Notifier) NotifyAppDeployStarted(stackName, displayName string) {
n.PushEvent("app_deploy_started", "info", n.pushEventMsg("app_deploy_started", "info", "event.app_deploy_started",
fmt.Sprintf("Alkalmazás telepítése elindult: %s", displayName), AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
AppDetails{StackName: stackName, DisplayName: displayName})
} }
// NotifyAppDeployFailed closes the pair. severity=warning, not info: an install the customer started // 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 // 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. // what left a completed-install record for an app that was never installed.
func (n *Notifier) NotifyAppDeployFailed(stackName, displayName, reason string) { 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 != "" { if reason != "" {
msg += " — " + reason suffix = " — " + reason
} }
n.PushEvent("app_deploy_failed", "warning", msg, n.pushEventMsgSuffix("app_deploy_failed", "warning", "event.app_deploy_failed", suffix,
AppDetails{StackName: stackName, DisplayName: displayName}) AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
} }
// AppRunState is one deployed app's running state for the fix-3 start-failure notifier: Down=true // 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. // 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{}) { func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
if n.pushFn != nil { if n.pushFn != nil {
n.pushFn(eventType, severity, message, details) n.pushFn(eventType, severity, message, "", details)
return return
} }
n.PushEvent(eventType, severity, message, details) 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. // NotifyAppRemoved sends an app removal event.
func (n *Notifier) NotifyAppRemoved(stackName, displayName string) { func (n *Notifier) NotifyAppRemoved(stackName, displayName string) {
n.PushEvent("app_removed", "info", n.pushEventMsg("app_removed", "info", "event.app_removed",
fmt.Sprintf("Alkalmazás eltávolítva: %s", displayName), AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
AppDetails{StackName: stackName, DisplayName: displayName})
} }
// NotifyCrossDriveCompleted sends a cross-drive backup success event. // NotifyCrossDriveCompleted sends a cross-drive backup success event.
func (n *Notifier) NotifyCrossDriveCompleted(details CrossDriveDetails) { func (n *Notifier) NotifyCrossDriveCompleted(details CrossDriveDetails) {
n.PushEvent("crossdrive_completed", "info", n.pushEventMsg("crossdrive_completed", "info", "event.crossdrive_completed", details, details.StackName)
fmt.Sprintf("Másodlagos mentés elkészült: %s", details.StackName), details)
} }
// NotifyCrossDriveFailed sends a cross-drive backup failure event. // NotifyCrossDriveFailed sends a cross-drive backup failure event.
func (n *Notifier) NotifyCrossDriveFailed(details CrossDriveDetails) { func (n *Notifier) NotifyCrossDriveFailed(details CrossDriveDetails) {
n.PushEvent("crossdrive_failed", "error", n.pushEventMsg("crossdrive_failed", "error", "event.crossdrive_failed", details, details.StackName)
fmt.Sprintf("Másodlagos mentés sikertelen: %s", details.StackName), details)
} }
// NotifyDRStarted sends a disaster recovery start event. // NotifyDRStarted sends a disaster recovery start event.
func (n *Notifier) NotifyDRStarted(appCount int) { func (n *Notifier) NotifyDRStarted(appCount int) {
n.PushEvent("disaster_recovery_started", "warning", n.pushEventMsg("disaster_recovery_started", "warning", "event.disaster_recovery_started", nil, appCount)
fmt.Sprintf("Katasztrófa helyreállítás elindítva (%d alkalmazás)", appCount), nil)
} }
// NotifyDRCompleted sends a disaster recovery completion event. // NotifyDRCompleted sends a disaster recovery completion event.
@@ -764,8 +838,8 @@ func (n *Notifier) NotifyDRCompleted(successCount, failCount int) {
if failCount > 0 { if failCount > 0 {
severity = "warning" severity = "warning"
} }
n.PushEvent("disaster_recovery_completed", severity, n.pushEventMsg("disaster_recovery_completed", severity, "event.disaster_recovery_completed", nil,
fmt.Sprintf("Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", successCount, failCount), nil) successCount, failCount)
} }
// ── Preferences sync ───────────────────────────────────────────────── // ── Preferences sync ─────────────────────────────────────────────────
@@ -49,6 +49,14 @@ var hubSeverityVocabulary = map[string]bool{
var severityArg = map[string]int{ var severityArg = map[string]int{
"emit": 1, // (eventType, severity, message, details) "emit": 1, // (eventType, severity, message, details)
"PushEvent": 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 // 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 // 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. // limit, it is a hole.
var knownDynamicSeveritySites = map[string]string{ 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: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: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"`, "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 got string
var seen int var seen int
n := &Notifier{} n := &Notifier{}
n.pushFn = func(_, _, message string, _ interface{}) { n.pushFn = func(_, _, message, _ string, _ interface{}) {
got, seen = message, seen+1 got, seen = message, seen+1
} }
tc.call(n) tc.call(n)
@@ -102,7 +102,7 @@ func TestEventMessageWireTextIsFrozen(t *testing.T) {
func TestEventMessagesCarryNoBundleKey(t *testing.T) { func TestEventMessagesCarryNoBundleKey(t *testing.T) {
var msgs []string var msgs []string
n := &Notifier{} 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.NotifyAppDeployed("privatebin", "PrivateBin")
n.NotifyAppRemoved("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. // surfaced to the dashboard as a persistent banner. Not persisted.
LoadWarning string `json:"-"` 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 // Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml 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() return s.save()
} }
// GetLanguage returns the household's dashboard language, normalised: anything unset or unknown is // SetConfigLanguage records the language controller.yaml says this box should start in.
// Hungarian (i18n.Default). //
// 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 { func (s *Settings) GetLanguage() string {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() 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 // SetLanguage stores the household's dashboard language and saves. Only a supported language is
File diff suppressed because it is too large Load Diff