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
gates / gates (push) Successful in 1m55s

This commit is contained in:
admin committed 2026-10-22 12:13:26 +02:00
1 parent 08fa440d04
commit add310c80a
13 files changed
+509 -58

No files matched your search

+30
View File
@@ -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) ## 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 **MinAgent: 0.158.1** (unchanged — the install page's swap sentence reads agent ≥ 0.162.0's swap numbers and stays
+12
View File
@@ -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 `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 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. (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 - **„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** (`\\<SMBServerName>` — rendered ONLY while „Szerver memória": **Helyi cím (LAN)**, **Hálózati név** (`\\<SMBServerName>` — rendered ONLY while
Megosztás is enabled; the NetBIOS name exists only while samba runs), **Átjáró**, plus a muted 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` - Synced to Hub on save and on controller startup via `POST /api/v1/preferences`
- Hub sync failure doesn't block local save - 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 **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 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 provisioning-seeded alert delivery). The form re-renders with a Hungarian error and the customer's
+3 -9
View File
@@ -1889,15 +1889,9 @@ func main() {
// Sync notification preferences to hub on startup (handles hub DB rebuild recovery) // Sync notification preferences to hub on startup (handles hub DB rebuild recovery)
if notifier.IsEnabled() { if notifier.IsEnabled() {
go func() { go func() {
prefs := sett.GetNotificationPrefs() // R-922 (a saved list's address or clear is re-pushed) and R-1199 reopened (a box with no list of its own
// R-922: a household's clear is pushed too, so a clear the hub missed reaches it at the next start. // adopts the household's from the hub, or pushes its default once) — prefs_sync_wiring.go.
// R-1199: a box whose household never saved its choice pushes the default list its page shows (with no startupPrefsSync(notifier, sett, logger)
// 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)
}
}
}() }()
} }
+75 -17
View File
@@ -3,28 +3,86 @@ package main
import ( import (
"log" "log"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "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 // R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT
// box shows the household its DEFAULT notification list ticked (disk warning, drive disconnected, second-drive failure // notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when
// …), but pushed nothing to the hub until the household saved the page — and the hub mails the household only by its // nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box
// OWN stored list (`processCustomer`), which for Tester4 was a list saved two days before that box existed. So the // (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the
// household got one alarm of nine. The box's page is the household's choice (`08` „the household decides" — the three // push created no local save. `08` „the household decides".
// groups); the hub follows it. Decided by CC unattended — operator may reverse.
// //
// prefsSyncAtStartup: push at start when the household saved an address or a clear (R-922, unchanged), or when it never // Now, at start:
// saved a choice at all (the default list, pushed with no address — the hub's no-clobber guard keeps the stored one). // - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's;
// Pinned by TestR1199_*. // - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's
func prefsSyncAtStartup(prefs *settings.NotificationPrefs, saved bool, logger *log.Logger) bool { // list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next
if prefs.SyncOnStartup() { // start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again.
return true //
} // The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also
if !saved && prefs != nil { // 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 { 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)") logger.Printf(f, a...)
} }
return true
} }
return false 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"
} }
@@ -1,28 +1,137 @@
package main package main
import ( import (
"errors"
"io"
"log"
"path/filepath"
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "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 // R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like
// without an address (and no clear) is still not pushed (R-922's rule unchanged); a saved address is pushed. // hub ≥ 0.165.0 (it keeps a household's list over a default push).
// RED-PROOF: with the `!saved` branch removed the first case fails — the hub keeps mailing by a list the page never showed. // RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start)
func TestR1199_ANeverSavedChoiceIsPushedAtStart(t *testing.T) { // case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22.
def := &settings.NotificationPrefs{EnabledEvents: settings.DefaultEnabledEvents, CooldownHours: 6}
if !prefsSyncAtStartup(def, false, nil) { type fakeHub struct {
t.Fatal("a box with no saved choice must push the default list its page shows") 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) { if f.stored == nil {
t.Fatal("a saved choice with no address and no clear stays local (R-922 unchanged)") return &notify.HubPrefs{Exists: false}, nil
} }
if !prefsSyncAtStartup(&settings.NotificationPrefs{Email: "x@example.invalid", EnabledEvents: []string{"backup_failed"}}, true, nil) { cp := *f.stored
t.Fatal("a saved address is pushed, as before") 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 = &notify.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: &notify.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: &notify.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) { func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) {
want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false} want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false}
for _, e := range settings.DefaultEnabledEvents { for _, e := range settings.DefaultEnabledEvents {
+74 -12
View File
@@ -8,6 +8,7 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "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 // 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. // unconfigured box must never wipe a seeded address). Decoded by the hub's savePreferences.
EmailCleared bool `json:"email_cleared,omitempty"` 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. // 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. // 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 { 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 { if !n.enabled {
return util.MsgError("err.notify.hub_nem_konfiguralt") return "", util.MsgError("err.notify.hub_nem_konfiguralt")
} }
payload := preferencesRequest{ payload := preferencesRequest{
@@ -1086,41 +1104,85 @@ func (n *Notifier) SyncPreferences(email string, enabledEvents []string, cooldow
EnabledEvents: enabledEvents, EnabledEvents: enabledEvents,
CooldownHours: cooldownHours, CooldownHours: cooldownHours,
EmailCleared: emailCleared, EmailCleared: emailCleared,
Source: source,
} }
jsonData, err := json.Marshal(payload) jsonData, err := json.Marshal(payload)
if err != nil { 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 { if n.debug {
n.logger.Printf("[DEBUG] SyncPreferences: url=%s email=%s events=%v cooldown=%dh", n.logger.Printf("[DEBUG] SyncPreferences: url=%s source=%s email=%s events=%v cooldown=%dh",
url, email, enabledEvents, cooldownHours) 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 { 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("Authorization", "Bearer "+n.apiKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := n.httpClient.Do(req) resp, err := n.httpClient.Do(req)
if err != nil { if err != nil {
return util.MsgError("err.notify.hub_elerhetetlen", err) return "", util.MsgError("err.notify.hub_elerhetetlen", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) 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 { if n.debug {
n.logger.Printf("[DEBUG] SyncPreferences: response HTTP %d", resp.StatusCode) 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) 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 nil 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 ──────────────────────────────────────────────── // ── Test notification ────────────────────────────────────────────────
@@ -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)
}
}
@@ -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))
}
}
+14 -3
View File
@@ -649,8 +649,11 @@ func (p StoragePath) IsInternal() bool { return p.Kind == StorageKindInternal }
// NotificationPrefs holds customer notification preferences. // NotificationPrefs holds customer notification preferences.
type NotificationPrefs struct { type NotificationPrefs struct {
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
EnabledEvents []string `json:"enabled_events,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 CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
// EmailCleared (R-922, operator ruling 2026-10-09 option A) marks a DELIBERATE clear: the household // 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` // 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 err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
logger.Printf("[INFO] [settings] No settings.json found, using defaults") 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 s, nil
} }
return nil, fmt.Errorf("reading settings file: %w", err) return nil, fmt.Errorf("reading settings file: %w", err)
@@ -1354,7 +1362,10 @@ func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
prefs.CooldownHours = 6 prefs.CooldownHours = 6
} }
if prefs.EnabledEvents == nil { 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 // 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 // migration (seedOffboxEnlargeNotice at Load), NOT a getter append — so a customer's later opt-out
+7 -1
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport" "gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "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/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -612,7 +613,12 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
return return
} }
prefs := s.settings.GetNotificationPrefs() 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) writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
return return
} }
@@ -1,6 +1,8 @@
package web package web
import ( import (
"encoding/json"
"os"
"testing" "testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "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")
}
}
@@ -193,8 +193,9 @@ func procMemTotalMB() int64 {
return 0 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 // hostSwapShort (R-1192 follow-up, night 2026-10-21; the rule R-1216 / D28 = A, 2026-10-22) asks the agent whether the
// — the guest swap fix (swap = RAM) puts a memory burst there, and a host with none gets nothing from it. False when // 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 // 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. // overcommit sentence, as before.
func (s *Server) hostSwapShort() bool { func (s *Server) hostSwapShort() bool {
@@ -211,7 +212,38 @@ func (s *Server) hostSwapShort() bool {
return swapShort(info) 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 { 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
} }
@@ -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}
]
}