From fcd9f099270d2a263d13ba23e28e1f865cc28c7f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 14:31:17 +0200 Subject: [PATCH] health check: the last six producers carry their dashboard sentence (R-79 option A, D3) Docker unreachable, protected container down, and the four storage-path entries now carry a bundle key beside their wire text; the wire text is unchanged byte for byte. Hungarian values of the four storage keys equal the frozen formats (i18n_go_parity). Red-proven: a zero MsgRef on the unavailable-path warning fails TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold (producer-driven, English household sees Hungarian). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- controller/internal/i18n/locales/en.json | 6 ++ controller/internal/i18n/locales/hu.json | 6 ++ controller/internal/monitor/healthcheck.go | 76 ++++++++++++----- .../monitor/r516_resource_msgs_test.go | 81 +++++++++++++++++++ .../monitor/r553_warning_kinds_test.go | 2 +- .../internal/monitor/wire_golden_test.go | 2 +- .../internal/web/r79_health_banner_test.go | 51 ++++++++++++ controller/scripts/i18n_go_keys.json | 6 ++ 8 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 controller/internal/web/r79_health_banner_test.go diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index c12795d..19499c8 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1571,6 +1571,12 @@ "health.memory_high": "Memory usage high: %.0f%%", "health.cpu_high": "CPU usage high: %.0f%%", "health.temperature_high": "Temperature high: %.0f°C (%s)", + "health.docker_unreachable": "Docker is not reachable: %s", + "health.protected_container_down": "A protected system service is not running: %s", + "health.storage_unavailable": "Storage not reachable: %s", + "health.storage_not_separate": "The storage (%s) is not on a separate drive — data is written to the system drive", + "health.storage_usage_high": "Storage usage high: %s (%.0f%%)", + "health.storage_almost_full": "Storage almost full: %s (%.0f%%)", "hold.copy_holds.db_only": "holds only the settings and the database, not the files", "hold.copy_holds.files": "holds the settings, the database and the files", "hold.copy_holds.volumes": "holds the settings, the database and the data volumes", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 35b0fd5..6093bee 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1559,6 +1559,12 @@ "health.memory_high": "A memóriahasználat magas: %.0f%%", "health.cpu_high": "A processzor terhelése magas: %.0f%%", "health.temperature_high": "A hőmérséklet magas: %.0f°C (%s)", + "health.docker_unreachable": "A Docker nem érhető el: %s", + "health.protected_container_down": "Egy védett rendszerszolgáltatás nem fut: %s", + "health.storage_unavailable": "Adattároló nem elérhető: %s", + "health.storage_not_separate": "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", + "health.storage_usage_high": "Adattároló használat magas: %s (%.0f%%)", + "health.storage_almost_full": "Adattároló majdnem megtelt: %s (%.0f%%)", "hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem", "hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza", "hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza", diff --git a/controller/internal/monitor/healthcheck.go b/controller/internal/monitor/healthcheck.go index ae80826..1593e86 100644 --- a/controller/internal/monitor/healthcheck.go +++ b/controller/internal/monitor/healthcheck.go @@ -135,7 +135,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora // 5. Docker health if err := checkDocker(); err != nil { - report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{}) + addDockerIssue(report, err) if debug { logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err) } @@ -153,9 +153,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected) } missingProtected := checkProtectedContainers(protected) - for _, name := range missingProtected { - report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{}) - } + addProtectedIssues(report, missingProtected) if debug { if len(missingProtected) > 0 { logger.Printf("[DEBUG] [monitor] Protected containers missing: %v", missingProtected) @@ -165,13 +163,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora } // 7. Storage paths - storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths) - for _, is := range storageIssues { - report.addIssue(is, MsgRef{}) - } - for i, w := range storageWarnings { - report.addWarning(w, storageKinds[i]) - } + addStorage(report, storagePaths) // Determine status if len(report.Issues) > 0 { @@ -225,6 +217,38 @@ func (r *HealthReport) FormatMessage() string { return sb.String() } +// addDockerIssue / addProtectedIssues / addStorage are RunHealthCheck's Docker, protected-container +// and storage-path halves, split out so a test can drive them without a Docker daemon or a real drive +// (R-79 option A: TestR79_RemainingProducersCarryTheirDashboardSentence). Each entry keeps its wire +// text and carries the dashboard's own sentence beside it (R-516 item 10). +func addDockerIssue(report *HealthReport, err error) { + // The argument is Docker's own error text, which is English in every language (it comes from + // the docker client); the sentence around it follows the household. + report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{"health.docker_unreachable", []interface{}{err.Error()}}) +} + +func addProtectedIssues(report *HealthReport, missing []string) { + for _, name := range missing { + report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{"health.protected_container_down", []interface{}{name}}) + } +} + +func addStorage(report *HealthReport, paths []settings.StoragePath) { + issues, issueMsgs, warnings, kinds, warnMsgs := checkStoragePaths(paths) + for i, is := range issues { + report.addIssue(is, issueMsgs[i]) + } + for i, w := range warnings { + report.addWarningMsg(w, kinds[i], warnMsgs[i]) + } +} + +// isMountPointFn / diskUsageFn are test seams over the two drive probes checkStoragePaths makes. +var ( + isMountPointFn = system.IsMountPoint + diskUsageFn = system.GetDiskUsage +) + func checkDocker() error { cmd := dockerexec.Command("docker", "info", "--format", "{{.ServerVersion}}") out, err := cmd.Output() @@ -303,39 +327,47 @@ func checkProtectedContainers(protected []string) []string { // checkStoragePaths returns the storage issues and, beside each warning, its KIND (R-553) — the // dashboard places the "not on a separate drive" warning inline under the storage bars, and it must -// find it by kind rather than by the words the sentence happens to contain today. -func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []string) { +// find it by kind rather than by the words the sentence happens to contain today. issueMsgs and +// warnMsgs are parallel to issues and warnings: the dashboard sentence of each (R-79 option A; the +// Hungarian bundle value of each key equals the frozen wire format above, byte for byte). +func checkStoragePaths(paths []settings.StoragePath) (issues []string, issueMsgs []MsgRef, warnings, kinds []string, warnMsgs []MsgRef) { + warn := func(text, kind string, msg MsgRef) { + warnings, kinds, warnMsgs = append(warnings, text), append(kinds, kind), append(warnMsgs, msg) + } for _, sp := range paths { // Skip decommissioned paths — no longer in active use if sp.Decommissioned { continue } - // Skip disconnected paths — handled by the storage watchdog + // Skip disconnected paths — handled by the storage watchdog. No dashboard sentence: the + // dashboard drops this warning for its own alert.storage.disconnected banner (R-516 item 8). if sp.Disconnected { - warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path)), append(kinds, WarnKindStorageDisconnected) + warn(fmt.Sprintf(warnFmtStorageDisconnected, sp.Label, sp.Path), WarnKindStorageDisconnected, MsgRef{}) continue } // Path accessible? if _, err := os.Stat(sp.Path); err != nil { - warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUnavailable, sp.Path)), append(kinds, WarnKindStorageUnavailable) + warn(fmt.Sprintf(warnFmtStorageUnavailable, sp.Path), WarnKindStorageUnavailable, + MsgRef{"health.storage_unavailable", []interface{}{sp.Path}}) continue } // Mount point check — warning, not issue (avoids false FAIL on demo/test environments) - if !system.IsMountPoint(sp.Path) { - warnings = append(warnings, fmt.Sprintf( - warnFmtStorageNotSeparate, sp.Path)) - kinds = append(kinds, WarnKindStorageNotSeparate) + if !isMountPointFn(sp.Path) { + warn(fmt.Sprintf(warnFmtStorageNotSeparate, sp.Path), WarnKindStorageNotSeparate, + MsgRef{"health.storage_not_separate", []interface{}{sp.Path}}) } // Disk usage - if di := system.GetDiskUsage(sp.Path); di != nil { + if di := diskUsageFn(sp.Path); di != nil { if di.UsedPercent >= 95 { issues = append(issues, fmt.Sprintf(issueFmtStorageAlmostFull, sp.Path, di.UsedPercent)) + issueMsgs = append(issueMsgs, MsgRef{"health.storage_almost_full", []interface{}{sp.Path, di.UsedPercent}}) } else if di.UsedPercent >= 90 { - warnings, kinds = append(warnings, fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent)), append(kinds, WarnKindStorageUsageHigh) + warn(fmt.Sprintf(warnFmtStorageUsageHigh, sp.Path, di.UsedPercent), WarnKindStorageUsageHigh, + MsgRef{"health.storage_usage_high", []interface{}{sp.Path, di.UsedPercent}}) } } } diff --git a/controller/internal/monitor/r516_resource_msgs_test.go b/controller/internal/monitor/r516_resource_msgs_test.go index dad00bb..c649087 100644 --- a/controller/internal/monitor/r516_resource_msgs_test.go +++ b/controller/internal/monitor/r516_resource_msgs_test.go @@ -1,9 +1,14 @@ package monitor import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -62,3 +67,79 @@ func TestR516_ResourceWarningsCarryTheirDashboardSentence(t *testing.T) { } } } + +// R-79 option A (operator ruling D3, 2026-10-08) — the six producers R-516 item 10 left verbatim: +// Docker unreachable, a protected container down, and the four storage-path entries. Each now +// carries its dashboard sentence; the wire text is byte for byte what it was (it is ON THE WIRE, +// report health.*; the frozen storage formats are also pinned by TestStorageWarningFormatsAreFrozen). +// +// COMPANION RED-PROOF: give any one of them a zero MsgRef (e.g. the unavailable-path warning back to +// addWarning) — its case fails here, and TestR79_StorageBannerFollowsTheHousehold in internal/web +// shows the Hungarian wire text to an English household again. +func TestR79_RemainingProducersCarryTheirDashboardSentence(t *testing.T) { + type entry struct{ wire, key string } + check := func(name string, texts []string, msgs func(int) MsgRef, want []entry) { + t.Helper() + if len(texts) != len(want) { + t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", name, len(texts), texts, len(want)) + } + for i := range texts { + if texts[i] != want[i].wire { + t.Errorf("%s: the wire text moved: %q, want %q", name, texts[i], want[i].wire) + } + if k := msgs(i).Key; k != want[i].key { + t.Errorf("%s: %q carries dashboard key %q, want %q", name, texts[i], k, want[i].key) + } + } + } + + // Docker and protected containers: driven through the same adders RunHealthCheck calls. + r := &HealthReport{} + addDockerIssue(r, fmt.Errorf("docker not reachable: exit status 1")) + addProtectedIssues(r, []string{"cloudflared", "traefik"}) + check("docker+protected", r.Issues, r.IssueMsgAt, []entry{ + {"Docker: docker not reachable: exit status 1", "health.docker_unreachable"}, + {"Protected container not running: cloudflared", "health.protected_container_down"}, + {"Protected container not running: traefik", "health.protected_container_down"}, + }) + if a := r.IssueMsgAt(1).Args; len(a) != 1 || a[0] != "cloudflared" { + t.Errorf("protected-container sentence args = %v, want [cloudflared]", a) + } + + // Storage paths: a missing path, a path that is not a mount point at 92 %, one at 97 %. + oldMount, oldUsage := isMountPointFn, diskUsageFn + defer func() { isMountPointFn, diskUsageFn = oldMount, oldUsage }() + isMountPointFn = func(p string) bool { return !strings.HasSuffix(p, "/notsep") } + diskUsageFn = func(p string) *system.DiskUsageInfo { + switch { + case strings.HasSuffix(p, "/notsep"): + return &system.DiskUsageInfo{UsedPercent: 92} + case strings.HasSuffix(p, "/full"): + return &system.DiskUsageInfo{UsedPercent: 97} + } + return nil + } + dir := t.TempDir() + for _, sub := range []string{"notsep", "full"} { + if err := os.Mkdir(filepath.Join(dir, sub), 0o755); err != nil { + t.Fatal(err) + } + } + missing := filepath.Join(dir, "missing") + r = &HealthReport{} + addStorage(r, []settings.StoragePath{{Path: missing}, {Path: dir + "/notsep"}, {Path: dir + "/full"}}) + check("storage warnings", r.Warnings, r.WarningMsgAt, []entry{ + {"Adattároló nem elérhető: " + missing, "health.storage_unavailable"}, + {"Az adattároló (" + dir + "/notsep) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", "health.storage_not_separate"}, + {"Adattároló használat magas: " + dir + "/notsep (92%)", "health.storage_usage_high"}, + }) + check("storage issues", r.Issues, r.IssueMsgAt, []entry{ + {"Adattároló majdnem megtelt: " + dir + "/full (97%)", "health.storage_almost_full"}, + }) + if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) { + t.Errorf("storage: the parallel slices drifted apart") + } + if got := r.WarningKindAt(1); got != WarnKindStorageNotSeparate { + t.Errorf("not-separate warning lost its kind: %q", got) + } +} diff --git a/controller/internal/monitor/r553_warning_kinds_test.go b/controller/internal/monitor/r553_warning_kinds_test.go index 17f2fd9..7ea1826 100644 --- a/controller/internal/monitor/r553_warning_kinds_test.go +++ b/controller/internal/monitor/r553_warning_kinds_test.go @@ -27,7 +27,7 @@ func TestR553_StorageWarningsCarryKindsAndKeepTheirWords(t *testing.T) { {Path: present}, {Path: filepath.Join(dir, "kihagyott"), Decommissioned: true}, } - _, warnings, kinds := checkStoragePaths(paths) + _, _, warnings, kinds, _ := checkStoragePaths(paths) if len(warnings) != len(kinds) { t.Fatalf("a warning without its kind (or the reverse): %d warnings, %d kinds", len(warnings), len(kinds)) } diff --git a/controller/internal/monitor/wire_golden_test.go b/controller/internal/monitor/wire_golden_test.go index 0555a3e..298248b 100644 --- a/controller/internal/monitor/wire_golden_test.go +++ b/controller/internal/monitor/wire_golden_test.go @@ -53,7 +53,7 @@ func TestStorageWarningWireTextIsFrozen(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, warnings, kinds := checkStoragePaths([]settings.StoragePath{tc.in}) + _, _, warnings, kinds, _ := checkStoragePaths([]settings.StoragePath{tc.in}) if len(warnings) != len(tc.want) { t.Fatalf("got %d warnings %q, want %d %q", len(warnings), warnings, len(tc.want), tc.want) } diff --git a/controller/internal/web/r79_health_banner_test.go b/controller/internal/web/r79_health_banner_test.go new file mode 100644 index 0000000..675f261 --- /dev/null +++ b/controller/internal/web/r79_health_banner_test.go @@ -0,0 +1,51 @@ +package web + +import ( + "io" + "log" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-79 option A (operator ruling D3, 2026-10-08) — the storage and protected-container entries of the +// health check reached the banner verbatim: Hungarian for an English household (the frozen storage +// formats), English for a Hungarian one (the container issue). Driven from the PRODUCER +// (monitor.RunHealthCheck), not a hand-built report, so a dropped MsgRef in healthcheck.go is seen here. +// Docker is refused under go test (R-650), so the Docker and protected-container issues appear on +// every run of this test — that is the input, not a side effect on this host. +// +// COMPANION RED-PROOF: give the unavailable-path warning in checkStoragePaths a zero MsgRef — the "en" +// case fails with „Adattároló nem elérhető: …". +func TestR79_HealthBannersFollowTheHousehold(t *testing.T) { + missing := filepath.Join(t.TempDir(), "gone") + cfg := config.Default() + cfg.Hub.Enabled = false + cfg.Stacks.Protected = []string{"traefik"} + hr := monitor.RunHealthCheck(cfg, nil, []settings.StoragePath{{Path: missing}}, settings.SMBSettings{}, nil) + am := NewAlertManager(log.New(io.Discard, "", 0)) + am.Refresh(hr, cfg, nil, false, "") + + want := map[string][]string{ + "hu": {"Adattároló nem elérhető: " + missing, "Egy védett rendszerszolgáltatás nem fut: traefik", "A Docker nem érhető el: "}, + "en": {"Storage not reachable: " + missing, "A protected system service is not running: traefik", "Docker is not reachable: "}, + } + for lang, ws := range want { + var got []string + for _, a := range am.GetAlerts(lang) { + if strings.HasPrefix(a.ID, "health-") { + got = append(got, a.Message) + } + } + joined := strings.Join(got, "\n") + for _, w := range ws { + if !strings.Contains(joined, w) { + t.Errorf("%s: no banner contains %q; banners:\n%s", lang, w, joined) + } + } + } +} diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index a1cb0c2..af743e4 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -178,6 +178,12 @@ "health.memory_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", "health.cpu_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", "health.temperature_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.", + "health.docker_unreachable": "BORN AS A KEY (R-79 option A, operator ruling D3 2026-10-08) -- the dashboard banner's own sentence for a health-check issue whose wire text (report health.issues) is English and stays so; a NEW sentence, never a Go literal. Pinned by TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold.", + "health.protected_container_down": "BORN AS A KEY (R-79 option A, operator ruling D3 2026-10-08) -- the dashboard banner's own sentence for a health-check issue whose wire text (report health.issues) is English and stays so; a NEW sentence, never a Go literal. Pinned by TestR79_RemainingProducersCarryTheirDashboardSentence and TestR79_HealthBannersFollowTheHousehold.", + "health.storage_unavailable": "Adattároló nem elérhető: %s", + "health.storage_not_separate": "Az adattároló (%s) nem külön meghajtón van — az adatok a rendszermeghajtóra íródnak", + "health.storage_usage_high": "Adattároló használat magas: %s (%.0f%%)", + "health.storage_almost_full": "Adattároló majdnem megtelt: %s (%.0f%%)", "alert.deadapp.group": "R-516 item 9 -- REWORDED on purpose: the formal „nézze meg\" became the product's te-form „nézd meg\"; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned in both languages by TestPluralEnglish (i18n_flash_test.go).", "event.appstop.failed": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.", "event.appstop.failed_held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.",