From add310c80a7c6f48807089d366433dc9ae348f06 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 22 Oct 2026 12:13:26 +0200 Subject: [PATCH] R-1199 reopened: a box with no list of its own reads the household's from the hub and adopts it, pushes its default once only when no household saved one; every push names its source; an empty saved list stays empty; R-1216 (D28 = A): the install page's swap sentence only when host swap is really short (shared case table); unreleased --- CHANGELOG.md | 30 ++++ controller/README.md | 12 ++ controller/cmd/controller/main.go | 12 +- .../cmd/controller/prefs_sync_wiring.go | 98 ++++++++++--- .../cmd/controller/prefs_sync_wiring_test.go | 133 ++++++++++++++++-- controller/internal/notify/notifier.go | 86 +++++++++-- .../internal/notify/prefs_source_test.go | 46 ++++++ .../settings/notif_empty_list_test.go | 36 +++++ controller/internal/settings/settings.go | 17 ++- controller/internal/web/handler_debug.go | 8 +- .../internal/web/r1192_swap_short_test.go | 38 +++++ .../internal/web/system_memory_handlers.go | 40 +++++- .../web/testdata/host_swap_rule_cases.json | 17 +++ 13 files changed, 512 insertions(+), 61 deletions(-) create mode 100644 controller/internal/notify/prefs_source_test.go create mode 100644 controller/internal/settings/notif_empty_list_test.go create mode 100644 controller/internal/web/testdata/host_swap_rule_cases.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 707278b..c1776ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## Unreleased — a rebuilt box never overwrites the household's mail list (R-1199 reopened); the host-swap sentence only when swap is really short (R-1216, D28 = A) + +**MinAgent: 0.158.1** (unchanged). Needs hub ≥ 0.165.0 for the read (an older hub: the box pushes nothing at start). +Task: the 2026-10-22 day brief. + +- **R-1199 reopened — the household's list is never overwritten.** v0.338.0 pushed the box's DEFAULT list at every start + when nothing was saved on the box; on a rebuilt box that overwrote the list the household had saved on its old box — + measured on the hub: demo-felhom's own 13 events became the 17 defaults at 07:54:55 UTC the morning it shipped, and the + push repeated at every start (it saved nothing locally). Now (`cmd/controller/prefs_sync_wiring.go` + `startupPrefsSync`): a list saved on this box is re-pushed as before (R-922) as the household's; with nothing saved + the box READS the hub's list (`notify.FetchPreferences`, `GET /api/v1/preferences`) — one the household saved is + ADOPTED as this box's list and nothing is pushed; otherwise the default is pushed ONCE as `source: default` and saved + here, so the next start sends nothing; a hub that cannot be read gets nothing. Every push names its `source` + (`SyncPreferencesAs`; the settings page = `household`; the debug re-push by whether a list is saved). The hub keeps a + household's list over any default (felhom.eu hub 0.165.0). Tests `TestR1199Reopened_A/B/C/AnUnreadableHubGetsNothing` + (cmd/controller), `TestR1199Reopened_TheWire` (notify) — A, B and the unreadable hub red-proved against v0.338.0's rule. +- **Fixed without a row — an empty list read back as the defaults.** A household that unticked every mail saved `[]`; + `omitempty` dropped it and the getter turned the missing list into the 17 defaults, so the page showed every mail + ticked while the hub mailed by the empty list. Measured live: Tester 1's and demo-hp's `settings.json` hold only + `{"cooldown_hours": 6}`. Now `enabled_events` keeps `[]`, and a SAVED list with no events reads as empty (an unsaved + box still shows the defaults). With it: the three one-time add-only seeds are marked done on a fresh settings file — + unset, they ran at a new box's second start and re-added a mail the household had just switched off. + `TestR1199Reopened_AnEmptySavedListStaysEmpty`, red-proved both ways. +- **R-1216 / D28 = A (operator 2026-10-22) — the install page's swap sentence only when swap is really short.** The rule + (`hostSwapRuleShort`, the same as the hub's `host_swap_short`): no host swap, or less than 90 % of min(the guest's + swap allowance, 8192 MB) — 8192 MB being the most Proxmox's installer gives (read from its `Install.pm`). The morning's + rule (host swap < the guest's RAM) would have told every household with a guest above 8 GB to call us; the 8 GB + soak's burst used 2.7 GB. Shared case table `internal/web/testdata/host_swap_rule_cases.json`, byte-identical in the + hub (felhom.eu `scripts/host_swap_rule_gate.py`); `TestR1216_HostSwapRuleCases` red-proved against the morning's rule. + ## v0.338.0 (2026-10-22) — D22: every restore puts the household's data back exactly on failure, and after a power cut (R-1177, R-1195, R-1178, R-1180) **MinAgent: 0.158.1** (unchanged — the install page's swap sentence reads agent ≥ 0.162.0's swap numbers and stays diff --git a/controller/README.md b/controller/README.md index 5dc88e8..dadbba3 100644 --- a/controller/README.md +++ b/controller/README.md @@ -123,6 +123,10 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated `agentapi.ResizeMemory` → the code→Hungarian map. A JS confirm fires only on a shrink; an outdated agent hides the control; an unreachable agent falls back to the guest's own `/proc/meminfo`. Memory only (cores stay observation). The lxcfs ripple means the deploy-page memory math follows a resize for free. + **Host swap sentence (v0.338.0, rule v0.339.0, R-1192/R-1216):** when the install page warns that memory is + tight it adds „A gépen nincs elég cserehely (swap)…" if the agent (≥ 0.162.0) reports the host's swap short for + this guest — none, or below 90 % of min(the guest's swap allowance, 8192 MB), the most Proxmox's installer gives. + The hub's operator alarm `host_swap_short` uses the same rule (shared case table, felhom.eu `host_swap_rule_gate.py`). - **„Hálózat" card (v0.159.0, R-66)** — Beállítások → Rendszer, between „Verzió és frissítés" and „Szerver memória": **Helyi cím (LAN)**, **Hálózati név** (`\\` — rendered ONLY while Megosztás is enabled; the NetBIOS name exists only while samba runs), **Átjáró**, plus a muted @@ -3091,6 +3095,14 @@ Notification preferences (email, enabled events, cooldown hours) are: - Synced to Hub on save and on controller startup via `POST /api/v1/preferences` - Hub sync failure doesn't block local save +**Who chose the list (v0.339.0, R-1199 reopened):** every push names its `source` — `household` (the settings page, +or the start-up re-push of a list saved on this box) or `default` (the box's own default). At start a box with NO list +of its own first READS the hub's (`GET /api/v1/preferences`, hub ≥ 0.165.0): a list the household saved on any box is +adopted as this box's list and nothing is pushed; otherwise the default is pushed once, saved here, and the next start +sends nothing. A hub that cannot be read gets nothing. The hub never lets a default overwrite a household's list (it +answers `{"status":"kept"}`). A list with every mail unticked is saved as `[]` and reads back as empty (it read as the +defaults before). `cmd/controller/prefs_sync_wiring.go`, `TestR1199Reopened_*`. + **Empty-email save guard (v0.137.0):** the save handler REFUSES a submit with a blank e-mail box while any event is still enabled (it would store an empty address AND push it to the hub, wiping the provisioning-seeded alert delivery). The form re-renders with a Hungarian error and the customer's diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index a083265..64ae1ff 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1889,15 +1889,9 @@ func main() { // Sync notification preferences to hub on startup (handles hub DB rebuild recovery) if notifier.IsEnabled() { go func() { - prefs := sett.GetNotificationPrefs() - // R-922: a household's clear is pushed too, so a clear the hub missed reaches it at the next start. - // R-1199: a box whose household never saved its choice pushes the default list its page shows (with no - // address — the hub keeps the stored one, its no-clobber guard). - if prefsSyncAtStartup(prefs, sett.NotificationPrefsSaved(), logger) { - if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil { - logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err) - } - } + // R-922 (a saved list's address or clear is re-pushed) and R-1199 reopened (a box with no list of its own + // adopts the household's from the hub, or pushes its default once) — prefs_sync_wiring.go. + startupPrefsSync(notifier, sett, logger) }() } diff --git a/controller/cmd/controller/prefs_sync_wiring.go b/controller/cmd/controller/prefs_sync_wiring.go index ab18159..9379fbe 100644 --- a/controller/cmd/controller/prefs_sync_wiring.go +++ b/controller/cmd/controller/prefs_sync_wiring.go @@ -3,28 +3,86 @@ package main import ( "log" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) -// R-1199 (rehearsal 2026-10-20; mechanism read the night of 2026-10-21, `audits/night-2026-10-21/P5-checks/`): a fresh -// box shows the household its DEFAULT notification list ticked (disk warning, drive disconnected, second-drive failure -// …), but pushed nothing to the hub until the household saved the page — and the hub mails the household only by its -// OWN stored list (`processCustomer`), which for Tester4 was a list saved two days before that box existed. So the -// household got one alarm of nine. The box's page is the household's choice (`08` „the household decides" — the three -// groups); the hub follows it. Decided by CC unattended — operator may reverse. +// R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT +// notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when +// nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box +// (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the +// push created no local save. `08` „the household decides". // -// prefsSyncAtStartup: push at start when the household saved an address or a clear (R-922, unchanged), or when it never -// saved a choice at all (the default list, pushed with no address — the hub's no-clobber guard keeps the stored one). -// Pinned by TestR1199_*. -func prefsSyncAtStartup(prefs *settings.NotificationPrefs, saved bool, logger *log.Logger) bool { - if prefs.SyncOnStartup() { - return true - } - if !saved && prefs != nil { - if logger != nil { - logger.Printf("[INFO] notification prefs: the household never saved its choice — pushing the default list the page shows, so the hub mails by it (R-1199)") - } - return true - } - return false +// Now, at start: +// - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's; +// - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's +// list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next +// start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again. +// +// The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also +// stops a box still on v0.338.0. Pinned by TestR1199Reopened_* (prefs_sync_wiring_test.go). + +type prefsHub interface { + FetchPreferences() (*notify.HubPrefs, error) + SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error) +} + +type prefsStore interface { + GetNotificationPrefs() *settings.NotificationPrefs + NotificationPrefsSaved() bool + SetNotificationPrefs(*settings.NotificationPrefs) error +} + +// startupPrefsSync runs the start-up step above and returns what it did (for the log and the tests): "pushed", +// "adopted", "default-pushed", "default-kept", "nothing", "no-hub" or "error". +func startupPrefsSync(hub prefsHub, st prefsStore, logger *log.Logger) string { + logf := func(f string, a ...any) { + if logger != nil { + logger.Printf(f, a...) + } + } + prefs := st.GetNotificationPrefs() + if st.NotificationPrefsSaved() { + if !prefs.SyncOnStartup() { + return "nothing" + } + if _, err := hub.SyncPreferencesAs(notify.PrefsSourceHousehold, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil { + logf("[WARN] Failed to sync notification preferences on startup: %v", err) + return "error" + } + return "pushed" + } + hp, err := hub.FetchPreferences() + if err != nil { + logf("[WARN] notification prefs: no list saved on this box and the hub's could not be read (%v) — nothing pushed; asked again at the next start (R-1199)", err) + return "no-hub" + } + if hp.Exists && hp.HouseholdSaved { + adopted := &settings.NotificationPrefs{Email: hp.Email, EnabledEvents: hp.EnabledEvents, CooldownHours: hp.CooldownHours} + if adopted.EnabledEvents == nil { + adopted.EnabledEvents = []string{} + } + if err := st.SetNotificationPrefs(adopted); err != nil { + logf("[ERROR] notification prefs: adopting the household's list from the hub failed: %v", err) + return "error" + } + logf("[INFO] notification prefs: adopted the household's saved list from the hub (%d events, address set=%t) — nothing pushed (R-1199)", len(adopted.EnabledEvents), adopted.Email != "") + return "adopted" + } + status, err := hub.SyncPreferencesAs(notify.PrefsSourceDefault, "", prefs.EnabledEvents, prefs.CooldownHours, false) + if err != nil { + logf("[WARN] notification prefs: pushing the default list failed: %v", err) + return "error" + } + if status == "kept" { + logf("[INFO] notification prefs: the hub kept the household's own list over this box's default — adopted at the next start (R-1199)") + return "default-kept" + } + local := &settings.NotificationPrefs{EnabledEvents: prefs.EnabledEvents, CooldownHours: prefs.CooldownHours} + if err := st.SetNotificationPrefs(local); err != nil { + logf("[ERROR] notification prefs: saving the pushed default on this box failed: %v", err) + return "error" + } + logf("[INFO] notification prefs: no household list anywhere — pushed this box's default once and saved it here (R-1199)") + return "default-pushed" } diff --git a/controller/cmd/controller/prefs_sync_wiring_test.go b/controller/cmd/controller/prefs_sync_wiring_test.go index 01bca04..737a7fa 100644 --- a/controller/cmd/controller/prefs_sync_wiring_test.go +++ b/controller/cmd/controller/prefs_sync_wiring_test.go @@ -1,28 +1,137 @@ package main import ( + "errors" + "io" + "log" + "path/filepath" "testing" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) -// R-1199: a box whose household never saved its notification choice pushes the default list at start; a saved choice -// without an address (and no clear) is still not pushed (R-922's rule unchanged); a saved address is pushed. -// RED-PROOF: with the `!saved` branch removed the first case fails — the hub keeps mailing by a list the page never showed. -func TestR1199_ANeverSavedChoiceIsPushedAtStart(t *testing.T) { - def := &settings.NotificationPrefs{EnabledEvents: settings.DefaultEnabledEvents, CooldownHours: 6} - if !prefsSyncAtStartup(def, false, nil) { - t.Fatal("a box with no saved choice must push the default list its page shows") +// R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like +// hub ≥ 0.165.0 (it keeps a household's list over a default push). +// RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start) +// case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22. + +type fakeHub struct { + stored *notify.HubPrefs + readErr error + pushes []string // the source of every push + lastEvents []string +} + +func (f *fakeHub) FetchPreferences() (*notify.HubPrefs, error) { + if f.readErr != nil { + return nil, f.readErr } - if prefsSyncAtStartup(&settings.NotificationPrefs{EnabledEvents: []string{"backup_failed"}}, true, nil) { - t.Fatal("a saved choice with no address and no clear stays local (R-922 unchanged)") + if f.stored == nil { + return ¬ify.HubPrefs{Exists: false}, nil } - if !prefsSyncAtStartup(&settings.NotificationPrefs{Email: "x@example.invalid", EnabledEvents: []string{"backup_failed"}}, true, nil) { - t.Fatal("a saved address is pushed, as before") + cp := *f.stored + return &cp, nil +} + +func (f *fakeHub) SyncPreferencesAs(source, email string, ev []string, cd int, cleared bool) (string, error) { + f.pushes = append(f.pushes, source) + if source != notify.PrefsSourceHousehold && f.stored != nil && f.stored.HouseholdSaved { + return "kept", nil + } + if f.stored == nil { + f.stored = ¬ify.HubPrefs{Exists: true} + } + f.stored.EnabledEvents, f.lastEvents = ev, ev + if email != "" { + f.stored.Email = email + } + if source == notify.PrefsSourceHousehold { + f.stored.HouseholdSaved = true + } + return "ok", nil +} + +func newSettings(t *testing.T) *settings.Settings { + t.Helper() + s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + return s +} + +var familyList = []string{"backup_failed", "disk_warning", "node_down"} + +// A: the household saved on an earlier box; this box has no local save → nothing reaches the hub, the box adopts the +// list and its page (GetNotificationPrefs) shows it. +func TestR1199Reopened_A_ARebuiltBoxAdoptsTheHouseholdsList(t *testing.T) { + hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, Email: "family@example.hu", EnabledEvents: familyList, CooldownHours: 12}} + st := newSettings(t) + if got := startupPrefsSync(hub, st, nil); got != "adopted" { + t.Fatalf("startup = %q, want adopted", got) + } + if len(hub.pushes) != 0 { + t.Fatalf("a box with no list of its own pushed %v over the household's list", hub.pushes) + } + p := st.GetNotificationPrefs() + if !st.NotificationPrefsSaved() || len(p.EnabledEvents) != 3 || p.Email != "family@example.hu" || p.CooldownHours != 12 { + t.Fatalf("the page must show the household's list: %+v", p) + } + // D after A: the next start re-pushes the adopted list as the household's (it carries an address, R-922) — the + // same list, never the default. + startupPrefsSync(hub, st, nil) + if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceHousehold || len(hub.lastEvents) != 3 { + t.Fatalf("after adoption: pushes %v events %v", hub.pushes, hub.lastEvents) } } -// The default list the page shows carries the three events the rehearsal household did not get (the consequence). +// B: no household ever saved (a seeded row, or none) → the default goes once, is saved here, and the hub mails by it. +func TestR1199Reopened_B_NoHouseholdListAnywhereTakesTheDefaultOnce(t *testing.T) { + for _, stored := range []*notify.HubPrefs{nil, {Exists: true, Email: "seed@example.hu", EnabledEvents: []string{"node_down"}}} { + hub := &fakeHub{stored: stored} + st := newSettings(t) + if got := startupPrefsSync(hub, st, nil); got != "default-pushed" { + t.Fatalf("startup = %q, want default-pushed", got) + } + if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceDefault || len(hub.stored.EnabledEvents) != len(settings.DefaultEnabledEvents) { + t.Fatalf("the default must reach the hub once, as a default: %v %v", hub.pushes, hub.stored.EnabledEvents) + } + if !st.NotificationPrefsSaved() { + t.Fatal("the pushed default must be saved on the box") + } + // D: a restart sends nothing (a local save exists, with no address). + if got := startupPrefsSync(hub, st, nil); got != "nothing" || len(hub.pushes) != 1 { + t.Fatalf("restart after B: %q, pushes %v — want nothing", got, hub.pushes) + } + } +} + +// C: the household saves on the box → the page save pushes as the household's (web handler, unchanged path); at start +// a saved list with an address is re-pushed as the household's (R-922). +func TestR1199Reopened_C_ASavedListIsTheHouseholds(t *testing.T) { + hub := &fakeHub{stored: ¬ify.HubPrefs{Exists: true, HouseholdSaved: true, EnabledEvents: familyList}} + st := newSettings(t) + st.SetNotificationPrefs(&settings.NotificationPrefs{Email: "new@example.hu", EnabledEvents: []string{"node_down"}}) + if got := startupPrefsSync(hub, st, nil); got != "pushed" || hub.pushes[0] != notify.PrefsSourceHousehold { + t.Fatalf("a saved list: %q %v", got, hub.pushes) + } + if len(hub.stored.EnabledEvents) != 1 { + t.Fatalf("the household's new choice must reach the hub: %v", hub.stored.EnabledEvents) + } +} + +// A hub that cannot be read gets nothing (never a blind default); a hub that keeps its list over a race is not +// overwritten and the box saves nothing (it adopts at the next start). +func TestR1199Reopened_AnUnreadableHubGetsNothing(t *testing.T) { + hub := &fakeHub{readErr: errors.New("hub answered HTTP 404")} + st := newSettings(t) + if got := startupPrefsSync(hub, st, nil); got != "no-hub" || len(hub.pushes) != 0 || st.NotificationPrefsSaved() { + t.Fatalf("unreadable hub: %q pushes %v saved %t", got, hub.pushes, st.NotificationPrefsSaved()) + } +} + +// The default list the page shows carries the three events the rehearsal household did not get (kept from R-1199). func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) { want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false} for _, e := range settings.DefaultEnabledEvents { diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 12c225d..d7a5bc6 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -8,6 +8,7 @@ import ( "io" "log" "net/http" + "net/url" "strings" "sync" "time" @@ -1070,14 +1071,31 @@ type preferencesRequest struct { // holds. OMITTED unless true: an empty address without it keeps the hub's no-clobber guard (an // unconfigured box must never wipe a seeded address). Decoded by the hub's savePreferences. EmailCleared bool `json:"email_cleared,omitempty"` + // Source (R-1199 reopened): PrefsSourceHousehold or PrefsSourceDefault. An older hub ignores it. + Source string `json:"source,omitempty"` } -// SyncPreferences pushes the current notification preferences to the hub. +// SyncPreferences pushes the household's saved notification preferences to the hub (source "household"). // Synchronous — returns error for the handler to display to the user. // emailCleared (R-922) is settings.NotificationPrefs.EmailCleared — true only after a household's clear. func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldownHours int, emailCleared bool) error { + _, err := n.SyncPreferencesAs(PrefsSourceHousehold, email, enabledEvents, cooldownHours, emailCleared) + return err +} + +// PrefsSourceHousehold / PrefsSourceDefault (R-1199 reopened) tell the hub who chose the pushed list: the household +// (the settings page, or the re-push of a list saved on this box) or the box's own default (nothing saved here and +// the hub holds no household list). The hub never lets a default overwrite a household's list. +const ( + PrefsSourceHousehold = "household" + PrefsSourceDefault = "default" +) + +// SyncPreferencesAs is SyncPreferences with the source named. It returns the hub's status word: "ok", or "kept" when +// the hub refused a default push because the household saved its own list (hub ≥ 0.165.0). +func (n *Notifier) SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error) { if !n.enabled { - return util.MsgError("err.notify.hub_nem_konfiguralt") + return "", util.MsgError("err.notify.hub_nem_konfiguralt") } payload := preferencesRequest{ @@ -1086,41 +1104,85 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow EnabledEvents: enabledEvents, CooldownHours: cooldownHours, EmailCleared: emailCleared, + Source: source, } jsonData, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("marshal: %w", err) + return "", fmt.Errorf("marshal: %w", err) } - url := n.hubURL + "/api/v1/preferences" + prefsURL := n.hubURL + "/api/v1/preferences" if n.debug { - n.logger.Printf("[DEBUG] SyncPreferences: url=%s email=%s events=%v cooldown=%dh", - url, email, enabledEvents, cooldownHours) + n.logger.Printf("[DEBUG] SyncPreferences: url=%s source=%s email=%s events=%v cooldown=%dh", + prefsURL, source, email, enabledEvents, cooldownHours) } - req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData)) + req, err := http.NewRequest("POST", prefsURL, bytes.NewReader(jsonData)) if err != nil { - return fmt.Errorf("request: %w", err) + return "", fmt.Errorf("request: %w", err) } req.Header.Set("Authorization", "Bearer "+n.apiKey) req.Header.Set("Content-Type", "application/json") resp, err := n.httpClient.Do(req) if err != nil { - return util.MsgError("err.notify.hub_elerhetetlen", err) + return "", util.MsgError("err.notify.hub_elerhetetlen", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return util.MsgError("err.notify.hub_error", resp.StatusCode, string(body)) + return "", util.MsgError("err.notify.hub_error", resp.StatusCode, string(body)) + } + var ack struct { + Status string `json:"status"` + } + _ = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&ack) // an older hub answers {"status":"ok"} too + if ack.Status == "" { + ack.Status = "ok" } if n.debug { n.logger.Printf("[DEBUG] SyncPreferences: response HTTP %d", resp.StatusCode) } - n.logger.Printf("[INFO] Notification preferences synced to hub: email=%s, events=%v, cooldown=%dh, email_cleared=%t", email, enabledEvents, cooldownHours, emailCleared) - return nil + n.logger.Printf("[INFO] Notification preferences synced to hub: source=%s status=%s email=%s, events=%v, cooldown=%dh, email_cleared=%t", source, ack.Status, email, enabledEvents, cooldownHours, emailCleared) + return ack.Status, nil +} + +// HubPrefs is the hub's stored notification list for this box's customer (GET /api/v1/preferences, hub ≥ 0.165.0). +type HubPrefs struct { + Exists bool `json:"exists"` + HouseholdSaved bool `json:"household_saved"` + Email string `json:"email"` + EnabledEvents []string `json:"enabled_events"` + CooldownHours int `json:"cooldown_hours"` +} + +// FetchPreferences reads the hub's stored list for this customer (R-1199 reopened): a box with no list of its own +// adopts the household's instead of pushing its default over it. Any failure — an older hub (404), no network — is +// an error, and the caller then pushes nothing. +func (n *Notifier) FetchPreferences() (*HubPrefs, error) { + if !n.enabled { + return nil, util.MsgError("err.notify.hub_nem_konfiguralt") + } + req, err := http.NewRequest("GET", n.hubURL+"/api/v1/preferences?customer_id="+url.QueryEscape(n.customerID), nil) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+n.apiKey) + resp, err := n.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("hub unreachable: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hub answered HTTP %d", resp.StatusCode) + } + var hp HubPrefs + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&hp); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &hp, nil } // ── Test notification ──────────────────────────────────────────────── diff --git a/controller/internal/notify/prefs_source_test.go b/controller/internal/notify/prefs_source_test.go new file mode 100644 index 0000000..c8461ed --- /dev/null +++ b/controller/internal/notify/prefs_source_test.go @@ -0,0 +1,46 @@ +package notify + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-1199 reopened: the push names its source on the wire and returns the hub's "kept"; the read sends the box's own +// customer id with its key and decodes the hub's answer. The hub side: felhom.eu hub household_prefs.go. +func TestR1199Reopened_TheWire(t *testing.T) { + var gotSource, gotQuery, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if r.Method == http.MethodGet { + gotQuery = r.URL.RawQuery + w.Write([]byte(`{"exists":true,"household_saved":true,"email":"f@example.hu","enabled_events":["node_down"],"cooldown_hours":6}`)) + return + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + gotSource, _ = body["source"].(string) + w.Write([]byte(`{"status":"kept","household_saved":true}`)) + })) + defer srv.Close() + sett, _ := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) + n := New(srv.URL, "box-key", "demo-felhom", sett, log.New(io.Discard, "", 0), false) + + status, err := n.SyncPreferencesAs(PrefsSourceDefault, "", []string{"node_down"}, 6, false) + if err != nil || status != "kept" || gotSource != "default" { + t.Fatalf("push: status=%q err=%v source=%q", status, err, gotSource) + } + hp, err := n.FetchPreferences() + if err != nil || !hp.HouseholdSaved || len(hp.EnabledEvents) != 1 || gotQuery != "customer_id=demo-felhom" || gotAuth != "Bearer box-key" { + t.Fatalf("read: %+v err=%v query=%q", hp, err, gotQuery) + } + if err := n.SyncPreferences("f@example.hu", nil, 6, false); err != nil || gotSource != "household" { + t.Fatalf("the page save must be the household's: err=%v source=%q", err, gotSource) + } +} diff --git a/controller/internal/settings/notif_empty_list_test.go b/controller/internal/settings/notif_empty_list_test.go new file mode 100644 index 0000000..fd21a74 --- /dev/null +++ b/controller/internal/settings/notif_empty_list_test.go @@ -0,0 +1,36 @@ +package settings + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" +) + +// R-1199 reopened (2026-10-22): a household that unticks every mail must see an empty list after a restart — the hub +// mails by that empty list. Before: omitempty dropped the [] and the getter turned the missing list into the defaults. +// Both shapes: a fresh save, and the file Tester 1 and demo-hp actually hold (`{"cooldown_hours": 6}` only). +// RED-PROOF: with the getter's nil → DefaultEnabledEvents restored, both assertions fail — seen failing 2026-10-22. +func TestR1199Reopened_AnEmptySavedListStaysEmpty(t *testing.T) { + p := filepath.Join(t.TempDir(), "s.json") + s, _ := Load(p, log.New(io.Discard, "", 0)) + if err := s.SetNotificationPrefs(&NotificationPrefs{EnabledEvents: []string{}}); err != nil { + t.Fatal(err) + } + s2, _ := Load(p, log.New(io.Discard, "", 0)) + if got := s2.GetNotificationPrefs().EnabledEvents; len(got) != 0 { + t.Fatalf("a saved empty list came back as %v", got) + } + q := filepath.Join(t.TempDir(), "old.json") + os.WriteFile(q, []byte(`{"notifications":{"cooldown_hours":6}}`), 0o600) + s3, _ := Load(q, log.New(io.Discard, "", 0)) + if got := s3.GetNotificationPrefs().EnabledEvents; len(got) != 0 || !s3.NotificationPrefsSaved() { + t.Fatalf("the live boxes' file shape reads as %v (saved=%t), want an empty saved list", got, s3.NotificationPrefsSaved()) + } + // A box with nothing saved still shows the defaults. + s4, _ := Load(filepath.Join(t.TempDir(), "n.json"), log.New(io.Discard, "", 0)) + if got := s4.GetNotificationPrefs().EnabledEvents; len(got) != len(DefaultEnabledEvents) { + t.Fatalf("an unsaved box must show the defaults, got %d", len(got)) + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index bd2b88f..72c4e42 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -649,8 +649,11 @@ func (p StoragePath) IsInternal() bool { return p.Kind == StorageKindInternal } // NotificationPrefs holds customer notification preferences. type NotificationPrefs struct { - Email string `json:"email,omitempty"` - EnabledEvents []string `json:"enabled_events,omitempty"` + Email string `json:"email,omitempty"` + // EnabledEvents: no omitempty since R-1199 reopened (2026-10-22) — a household that unticks every mail saves [], + // and omitempty dropped it, so after the next start the page showed every default mail ticked while the hub mailed + // by the empty list (measured on Tester 1 and demo-hp: their file held only `cooldown_hours`). + EnabledEvents []string `json:"enabled_events"` CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6 // EmailCleared (R-922, operator ruling 2026-10-09 option A) marks a DELIBERATE clear: the household // saved an empty address where one was stored. Every hub push carries it as `email_cleared: true` @@ -770,6 +773,11 @@ func Load(path string, logger *log.Logger) (*Settings, error) { if err != nil { if os.IsNotExist(err) { logger.Printf("[INFO] [settings] No settings.json found, using defaults") + // A fresh box's defaults already carry every type the one-time add-only seeds add, so they are DONE for it. + // Unset, they ran at the second start and re-added a type the household had just switched off — the + // „a later opt-out sticks" promise of each seed was false on every new box (found 2026-10-22, R-1199 + // reopened). Persisted with the first save. Pinned by TestR1199Reopened_AnEmptySavedListStaysEmpty. + s.OffboxEnlargeNoticeSeeded, s.AppUpdateEventsSeeded, s.UnhealthyStopEventSeeded = true, true, true return s, nil } return nil, fmt.Errorf("reading settings file: %w", err) @@ -1354,7 +1362,10 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs { prefs.CooldownHours = 6 } if prefs.EnabledEvents == nil { - prefs.EnabledEvents = DefaultEnabledEvents + // A SAVED list with no events is a household that unticked every mail (the only writer is the settings page; + // before R-1199 reopened omitempty dropped its []). It reads as empty — never as the defaults, which the hub + // does not mail by. Pinned by TestR1199Reopened_AnEmptySavedListStaysEmpty. + prefs.EnabledEvents = []string{} } // Return a copy of the slice verbatim. The offbox_enlarge_blocked seed is a ONE-TIME persisted // migration (seedOffboxEnlargeNotice at Load), NOT a getter append — so a customer's later opt-out diff --git a/controller/internal/web/handler_debug.go b/controller/internal/web/handler_debug.go index 96217ff..49ee6d0 100644 --- a/controller/internal/web/handler_debug.go +++ b/controller/internal/web/handler_debug.go @@ -18,6 +18,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" @@ -612,7 +613,12 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) { return } prefs := s.settings.GetNotificationPrefs() - if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil { + // R-1199 reopened: a list never saved on this box is the box's default, never the household's choice. + source := notify.PrefsSourceDefault + if s.settings.NotificationPrefsSaved() { + source = notify.PrefsSourceHousehold + } + if _, err := s.notifier.SyncPreferencesAs(source, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil) return } diff --git a/controller/internal/web/r1192_swap_short_test.go b/controller/internal/web/r1192_swap_short_test.go index 0cd8174..2d2c346 100644 --- a/controller/internal/web/r1192_swap_short_test.go +++ b/controller/internal/web/r1192_swap_short_test.go @@ -1,6 +1,8 @@ package web import ( + "encoding/json" + "os" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" @@ -26,3 +28,39 @@ func TestR1192_SwapShortJudgesTheHostSwapAgainstTheGuestRAM(t *testing.T) { } } } + +// R-1216 / D28 = A: the shared case table (byte-identical in felhom.eu hub/internal/api/testdata/, checked by felhom.eu +// scripts/host_swap_rule_gate.py), plus the page's own input: a 12 GB guest on an 8 GB-swap host (demo-felhom) is NOT +// short; the guest's RAM stands in when the agent sends no guest_swap_mb. +// RED-PROOF: with the morning's rule (`host < the guest's RAM`) the demo-hp, demo-felhom and 32 GB rows fail — seen +// failing 2026-10-22. +func TestR1216_HostSwapRuleCases(t *testing.T) { + b, err := os.ReadFile("testdata/host_swap_rule_cases.json") + if err != nil { + t.Fatal(err) + } + var f struct { + Cases []struct { + Name string `json:"name"` + Host int64 `json:"host_swap_mb"` + Guest int64 `json:"guest_swap_mb"` + Short bool `json:"short"` + } `json:"cases"` + } + if err := json.Unmarshal(b, &f); err != nil || len(f.Cases) < 10 { + t.Fatalf("case table: %v (%d cases)", err, len(f.Cases)) + } + mb := func(v int64) *int64 { return &v } + for _, c := range f.Cases { + if got := hostSwapRuleShort(c.Host, c.Guest); got != c.Short { + t.Errorf("%s: host %d / guests %d → short=%t, want %t", c.Name, c.Host, c.Guest, got, c.Short) + } + // Through the page's own input, the agent's answer. + if got := swapShort(agentapi.GuestMemoryInfo{AllocatedMB: c.Guest, HostSwapMB: mb(c.Host), GuestSwapMB: mb(c.Guest)}); got != c.Short { + t.Errorf("%s (page input): short=%t, want %t", c.Name, got, c.Short) + } + } + if swapShort(agentapi.GuestMemoryInfo{AllocatedMB: 12288, HostSwapMB: mb(8191)}) { + t.Error("demo-felhom without guest_swap_mb (the guest's RAM stands in) must not be short") + } +} diff --git a/controller/internal/web/system_memory_handlers.go b/controller/internal/web/system_memory_handlers.go index 1cdeec6..e87559e 100644 --- a/controller/internal/web/system_memory_handlers.go +++ b/controller/internal/web/system_memory_handlers.go @@ -193,8 +193,9 @@ func procMemTotalMB() int64 { return 0 } -// hostSwapShort (R-1192 follow-up, night 2026-10-21) asks the agent whether the HOST's swap is below this guest's RAM -// — the guest swap fix (swap = RAM) puts a memory burst there, and a host with none gets nothing from it. False when +// hostSwapShort (R-1192 follow-up, night 2026-10-21; the rule R-1216 / D28 = A, 2026-10-22) asks the agent whether the +// HOST has too little swap behind this guest — the guest swap fix (swap = RAM) puts a memory burst there, and a host +// with none gets nothing from it. False when // it cannot tell (an older agent, an agent that does not answer within 2 s): the install page then shows only the // overcommit sentence, as before. func (s *Server) hostSwapShort() bool { @@ -211,7 +212,38 @@ func (s *Server) hostSwapShort() bool { return swapShort(info) } -// swapShort: the host's swap is known and below the guest's RAM. +// swapShort: the host's swap is known and short for this guest's swap allowance (the agent's guest_swap_mb; the guest's +// RAM when an agent reports none — the fix sets swap = RAM) under the shared rule below. func swapShort(info agentapi.GuestMemoryInfo) bool { - return info.HostSwapMB != nil && info.AllocatedMB > 0 && *info.HostSwapMB < info.AllocatedMB + if info.HostSwapMB == nil { + return false // an older agent: not judged + } + guest := info.AllocatedMB + if info.GuestSwapMB != nil { + guest = *info.GuestSwapMB + } + return hostSwapRuleShort(*info.HostSwapMB, guest) +} + +// installerMaxSwapMB is the most host swap Proxmox's installer gives any machine (Install.pm compute_swapsize: the RAM, +// at least 4096 MB on a disk of 64 GB or more, at most 8192 MB; read from the 9.2-1 ISO, 2026-10-22). +const installerMaxSwapMB = 8192 + +// hostSwapRuleShort is the R-1216 rule (D28 = A, operator 2026-10-22), the SAME as the hub's host_swap_short: short when +// the host has no swap, or less than 90 % of min(the guests' swap allowance, 8192 MB). The morning's first rule (host +// swap < the guest's RAM) told every household with a guest above 8 GB to call us; the 8 GB soak's burst used 2.7 GB. +// The CONTRACT is testdata/host_swap_rule_cases.json, byte-identical in felhom.eu's hub (felhom.eu +// scripts/host_swap_rule_gate.py); TestR1216_HostSwapRuleCases runs it here. +func hostSwapRuleShort(hostMB, guestMB int64) bool { + if hostMB <= 0 { + return true + } + need := guestMB + if need > installerMaxSwapMB { + need = installerMaxSwapMB + } + if need <= 0 { + return false + } + return hostMB*10 < need*9 } diff --git a/controller/internal/web/testdata/host_swap_rule_cases.json b/controller/internal/web/testdata/host_swap_rule_cases.json new file mode 100644 index 0000000..97e1dd5 --- /dev/null +++ b/controller/internal/web/testdata/host_swap_rule_cases.json @@ -0,0 +1,17 @@ +{ + "rule": "R-1216 / D28 = A (operator 2026-10-22): short when host swap is 0, or below 90 % of min(guests' swap, 8192 MB). 8192 MB is the most Proxmox's installer gives (Install.pm compute_swapsize: RAM, at least 4096 on a disk >= 64 GB, at most 8192). This file is the CONTRACT: the hub (felhom.eu hub/internal/api/testdata/) and the controller (felhom-controller controller/internal/web/testdata/) carry byte-identical copies, checked by felhom.eu scripts/host_swap_rule_gate.py.", + "cases": [ + {"name": "8 GB machine from our installer, 4 GB guest (the soak box)", "host_swap_mb": 7871, "guest_swap_mb": 4096, "short": false}, + {"name": "32 GB machine, 8 GB swap, 24 GB guest", "host_swap_mb": 8192, "guest_swap_mb": 24576, "short": false}, + {"name": "demo-hp as measured 2026-10-22", "host_swap_mb": 8191, "guest_swap_mb": 25898, "short": false}, + {"name": "demo-felhom as measured 2026-10-22", "host_swap_mb": 8191, "guest_swap_mb": 12288, "short": false}, + {"name": "Tester 1 as measured 2026-10-22", "host_swap_mb": 7871, "guest_swap_mb": 4096, "short": false}, + {"name": "any host with no swap", "host_swap_mb": 0, "guest_swap_mb": 4096, "short": true}, + {"name": "a host with no swap and no guest", "host_swap_mb": 0, "guest_swap_mb": 0, "short": true}, + {"name": "BYO host, 1 GB swap, 4 GB guest", "host_swap_mb": 1024, "guest_swap_mb": 4096, "short": true}, + {"name": "the no-swap test 2026-10-22 (swap almost all off)", "host_swap_mb": 938, "guest_swap_mb": 4096, "short": true}, + {"name": "boundary: exactly 90 % of 8192", "host_swap_mb": 7373, "guest_swap_mb": 30000, "short": false}, + {"name": "boundary: just under 90 % of 8192", "host_swap_mb": 7372, "guest_swap_mb": 30000, "short": true}, + {"name": "small guest, host swap equal to it", "host_swap_mb": 2048, "guest_swap_mb": 2048, "short": false} + ] +}