Karakeep
+Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.
+ + + + +
+
+
+diff --git a/controller/internal/stacks/healthprobe.go b/controller/internal/stacks/healthprobe.go index 4b9d4fe..d1ad18b 100644 --- a/controller/internal/stacks/healthprobe.go +++ b/controller/internal/stacks/healthprobe.go @@ -82,13 +82,17 @@ func (m *Manager) RunHealthProbes() error { for _, np := range noProbe { m.mu.Lock() if s, ok := m.stacks[np.name]; ok { + // R-772: NOT healthy — no check ran. R-630's point still holds through NotChecked: the state is read from + // the containers, never forced to unhealthy by this record. Not healthy also means the 10-second fast + // cycle, so a container that comes back is checked within seconds, not 5 minutes later. s.HealthProbe = &HealthProbeResult{ - Healthy: true, // never RED on our own inability to look — R-630's whole point - LastCheck: m.now(), + Healthy: false, + NotChecked: true, + LastCheck: m.now(), Details: []HealthCheckDetail{{ Type: "none", Target: np.name, - Healthy: true, + Healthy: false, Error: MsgHealthNoProbeContainer, MessageKey: KeyHealthNoProbeContainer, }}, diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 87ad349..f7462f2 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -114,10 +114,21 @@ type ContainerInfo struct { } // HealthProbeResult holds the latest controller-side health probe result. +// probeSaysUnhealthy: a probe that RAN and failed overrides a running state to unhealthy; a not-checked record (R-772) +// does not — the containers decide. Pinned by TestProbeSaysUnhealthy_NotCheckedLeavesTheStateAlone. +func probeSaysUnhealthy(hp *HealthProbeResult) bool { + return hp != nil && !hp.Healthy && !hp.NotChecked +} + type HealthProbeResult struct { Healthy bool `json:"healthy"` LastCheck time.Time `json:"last_check"` Details []HealthCheckDetail `json:"details"` + // NotChecked: the declared check could not run — no container to probe (R-772). Healthy is then FALSE: a record of a + // check that did not happen never says healthy (presence is not success). The stack's STATE is still read from its + // containers (manager.go's override skips a not-checked record), so a running app is never painted unhealthy + // because the box could not look. Pinned by TestRunHealthProbes_NoContainerIsNotHealthy. + NotChecked bool `json:"not_checked,omitempty"` } // HealthCheckDetail holds the result of a single health check item. @@ -812,7 +823,7 @@ func (m *Manager) refreshStatusLocked() error { // Re-apply controller-side health probe results: if the last probe // failed and Docker thinks the container is running, override to unhealthy. - if stack.State == StateRunning && stack.HealthProbe != nil && !stack.HealthProbe.Healthy { + if stack.State == StateRunning && probeSaysUnhealthy(stack.HealthProbe) { stack.State = StateUnhealthy } diff --git a/controller/internal/stacks/r772_not_checked_test.go b/controller/internal/stacks/r772_not_checked_test.go new file mode 100644 index 0000000..442c862 --- /dev/null +++ b/controller/internal/stacks/r772_not_checked_test.go @@ -0,0 +1,80 @@ +package stacks + +import ( + "io" + "log" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// R-772 — a health probe that finds NO container to probe recorded `healthy: true` and then waited 5 minutes (measured +// 2026-10-01 on 9202, Karakeep with its container stopped). A record of a check that did not run must never say healthy +// (presence is not success), and the box must look again soon. + +func r772Manager(now time.Time) *Manager { + m := &Manager{cfg: &config.Config{}, logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{}} + m.updateNowFn = func() time.Time { return now } + return m +} + +func r772Stack() *Stack { + return &Stack{ + Name: "karakeep", + State: StateDegraded, + Meta: Metadata{HealthCheck: &HealthCheckConfig{Interval: "5m", + Checks: []HealthCheckItem{{Type: "http", Port: 3000, Path: "/api/health"}}}}, + // the app container stopped; the others still run — nothing the probe may use + Containers: []ContainerInfo{ + {Name: "karakeep", State: StateExited}, + {Name: "karakeep-meilisearch", State: StateRunning}, + {Name: "karakeep-chrome", State: StateRunning}, + }, + } +} + +func TestRunHealthProbes_NoContainerIsNotHealthy(t *testing.T) { + m := r772Manager(time.Now()) + m.stacks["karakeep"] = r772Stack() + if err := m.RunHealthProbes(); err != nil { + t.Fatal(err) + } + hp := m.stacks["karakeep"].HealthProbe + if hp == nil { + t.Fatal("no record written") + } + if hp.Healthy || !hp.NotChecked || len(hp.Details) != 1 || hp.Details[0].Healthy || hp.Details[0].MessageKey != KeyHealthNoProbeContainer { + t.Fatalf("a check that did not run must read NOT healthy + not_checked, got %+v", hp) + } + if m.stacks["karakeep"].State != StateDegraded { + t.Fatalf("the record must not change the state the containers gave, got %s", m.stacks["karakeep"].State) + } +} + +// The box looks again on the FAST cycle (10 s), not after the 5-minute interval: 20 s later a second run re-records. +// Pre-fix (healthy: true) the second run skipped it for 5 minutes. +func TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon(t *testing.T) { + first := time.Now().Add(-20 * time.Second) + m := r772Manager(first) + m.stacks["karakeep"] = r772Stack() + _ = m.RunHealthProbes() + m.updateNowFn = func() time.Time { return time.Now() } + _ = m.RunHealthProbes() + if got := m.stacks["karakeep"].HealthProbe.LastCheck; !got.After(first) { + t.Fatalf("a not-checked app must be looked at again within the 10-second cycle; last check still %v", got) + } +} + +// R-630 stays true: a not-checked record never paints a RUNNING app unhealthy; a probe that ran and failed does. +func TestProbeSaysUnhealthy_NotCheckedLeavesTheStateAlone(t *testing.T) { + if probeSaysUnhealthy(&HealthProbeResult{Healthy: false, NotChecked: true}) { + t.Fatal("not checked must not override the state") + } + if !probeSaysUnhealthy(&HealthProbeResult{Healthy: false}) { + t.Fatal("a failed check must override running → unhealthy") + } + if probeSaysUnhealthy(nil) || probeSaysUnhealthy(&HealthProbeResult{Healthy: true}) { + t.Fatal("nil / healthy must not override") + } +} diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index 2b24899..08f913a 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -231,6 +231,18 @@ func i18nCases() []i18nCase { d["AppInfo"] = st.Meta.AppInfo return d }}, + {"app_info_not_checked", "app_info", func() map[string]interface{} { + // R-772: the declared health check could not run — the page says so. + d := i18nLayoutData("stacks", "Karakeep") + st := stacks.Stack{Name: "karakeep", Deployed: true, State: stacks.StateDegraded, + HealthProbe: &stacks.HealthProbeResult{NotChecked: true}} + st.Meta = stacks.Metadata{DisplayName: "Karakeep", Slug: "karakeep", Category: "Hasznos eszközök", + Resources: stacks.ResourceHints{MemRequest: "64M"}} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + return d + }}, {"app_info_deployed", "app_info", func() map[string]interface{} { d := i18nLayoutData("stacks", "PrivateBin") st := stacks.Stack{Name: "privatebin", Deployed: true, State: stacks.StateRunning, HoldReason: "A frissítés megállt: nincs friss mentés."} diff --git a/controller/internal/web/r772_not_checked_page_test.go b/controller/internal/web/r772_not_checked_page_test.go new file mode 100644 index 0000000..1d796d8 --- /dev/null +++ b/controller/internal/web/r772_not_checked_page_test.go @@ -0,0 +1,41 @@ +package web + +import ( + "html" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-772: the app page SAYS when the declared health check could not run, in the reader's language, and says nothing +// when the check ran (or when there is no record). Both branches rendered — a line in a template that no test renders +// is a seam built and never wired. +func TestR772_AppPageSaysTheCheckDidNotRun(t *testing.T) { + s := i18nTestServer(t) + page := func(name string, hp *stacks.HealthProbeResult) i18nCase { + return i18nCase{name, "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Karakeep") + st := i18nStack("karakeep", "Karakeep", stacks.StateDegraded, true) + st.HealthProbe = hp + d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo + return d + }} + } + notRun := page("r772_not_checked", &stacks.HealthProbeResult{NotChecked: true}) + ran := page("r772_checked", &stacks.HealthProbeResult{Healthy: true}) + none := page("r772_no_record", nil) + enLine, huLine := "No health check ran: no matching container.", stacks.MsgHealthNoProbeContainer + en := html.UnescapeString(renderI18nCase(t, s, "en", notRun)) + hu := html.UnescapeString(renderI18nCase(t, s, "hu", notRun)) + if !strings.Contains(en, enLine) || strings.Contains(en, huLine) || !strings.Contains(hu, huLine) { + t.Fatalf("the not-checked line must reach the page in the reader's language") + } + for _, c := range []i18nCase{ran, none} { + for _, lang := range []string{"en", "hu"} { + if out := renderI18nCase(t, s, lang, c); strings.Contains(out, "data-health-not-checked") { + t.Fatalf("%s/%s: the line must not show when the check ran or there is no record", c.name, lang) + } + } + } +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 89d4830..9ae517f 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -40,7 +40,8 @@
{{T "health.no_probe_container"}}
{{end}} {{if .MissingStorageLabel}}Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.
+ + + + +
+
+
+