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
gates / gates (push) Successful in 1m55s
This commit is contained in:
1 parent
08fa440d04
commit
add310c80a
13 files changed
+509
-58
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ¬ify.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 = ¬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) {
|
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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user