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:
2026-10-22 12:13:26 +02:00
parent 08fa440d04
commit add310c80a
13 changed files with 512 additions and 61 deletions
+7 -1
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -612,7 +613,12 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
return
}
prefs := s.settings.GetNotificationPrefs()
if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
// R-1199 reopened: a list never saved on this box is the box's default, never the household's choice.
source := notify.PrefsSourceDefault
if s.settings.NotificationPrefsSaved() {
source = notify.PrefsSourceHousehold
}
if _, err := s.notifier.SyncPreferencesAs(source, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
return
}
@@ -1,6 +1,8 @@
package web
import (
"encoding/json"
"os"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
@@ -26,3 +28,39 @@ func TestR1192_SwapShortJudgesTheHostSwapAgainstTheGuestRAM(t *testing.T) {
}
}
}
// R-1216 / D28 = A: the shared case table (byte-identical in felhom.eu hub/internal/api/testdata/, checked by felhom.eu
// scripts/host_swap_rule_gate.py), plus the page's own input: a 12 GB guest on an 8 GB-swap host (demo-felhom) is NOT
// short; the guest's RAM stands in when the agent sends no guest_swap_mb.
// RED-PROOF: with the morning's rule (`host < the guest's RAM`) the demo-hp, demo-felhom and 32 GB rows fail — seen
// failing 2026-10-22.
func TestR1216_HostSwapRuleCases(t *testing.T) {
b, err := os.ReadFile("testdata/host_swap_rule_cases.json")
if err != nil {
t.Fatal(err)
}
var f struct {
Cases []struct {
Name string `json:"name"`
Host int64 `json:"host_swap_mb"`
Guest int64 `json:"guest_swap_mb"`
Short bool `json:"short"`
} `json:"cases"`
}
if err := json.Unmarshal(b, &f); err != nil || len(f.Cases) < 10 {
t.Fatalf("case table: %v (%d cases)", err, len(f.Cases))
}
mb := func(v int64) *int64 { return &v }
for _, c := range f.Cases {
if got := hostSwapRuleShort(c.Host, c.Guest); got != c.Short {
t.Errorf("%s: host %d / guests %d → short=%t, want %t", c.Name, c.Host, c.Guest, got, c.Short)
}
// Through the page's own input, the agent's answer.
if got := swapShort(agentapi.GuestMemoryInfo{AllocatedMB: c.Guest, HostSwapMB: mb(c.Host), GuestSwapMB: mb(c.Guest)}); got != c.Short {
t.Errorf("%s (page input): short=%t, want %t", c.Name, got, c.Short)
}
}
if swapShort(agentapi.GuestMemoryInfo{AllocatedMB: 12288, HostSwapMB: mb(8191)}) {
t.Error("demo-felhom without guest_swap_mb (the guest's RAM stands in) must not be short")
}
}
@@ -193,8 +193,9 @@ func procMemTotalMB() int64 {
return 0
}
// hostSwapShort (R-1192 follow-up, night 2026-10-21) asks the agent whether the HOST's swap is below this guest's RAM
// — the guest swap fix (swap = RAM) puts a memory burst there, and a host with none gets nothing from it. False when
// hostSwapShort (R-1192 follow-up, night 2026-10-21; the rule R-1216 / D28 = A, 2026-10-22) asks the agent whether the
// HOST has too little swap behind this guest — the guest swap fix (swap = RAM) puts a memory burst there, and a host
// with none gets nothing from it. False when
// it cannot tell (an older agent, an agent that does not answer within 2 s): the install page then shows only the
// overcommit sentence, as before.
func (s *Server) hostSwapShort() bool {
@@ -211,7 +212,38 @@ func (s *Server) hostSwapShort() bool {
return swapShort(info)
}
// swapShort: the host's swap is known and below the guest's RAM.
// swapShort: the host's swap is known and short for this guest's swap allowance (the agent's guest_swap_mb; the guest's
// RAM when an agent reports none — the fix sets swap = RAM) under the shared rule below.
func swapShort(info agentapi.GuestMemoryInfo) bool {
return info.HostSwapMB != nil && info.AllocatedMB > 0 && *info.HostSwapMB < info.AllocatedMB
if info.HostSwapMB == nil {
return false // an older agent: not judged
}
guest := info.AllocatedMB
if info.GuestSwapMB != nil {
guest = *info.GuestSwapMB
}
return hostSwapRuleShort(*info.HostSwapMB, guest)
}
// installerMaxSwapMB is the most host swap Proxmox's installer gives any machine (Install.pm compute_swapsize: the RAM,
// at least 4096 MB on a disk of 64 GB or more, at most 8192 MB; read from the 9.2-1 ISO, 2026-10-22).
const installerMaxSwapMB = 8192
// hostSwapRuleShort is the R-1216 rule (D28 = A, operator 2026-10-22), the SAME as the hub's host_swap_short: short when
// the host has no swap, or less than 90 % of min(the guests' swap allowance, 8192 MB). The morning's first rule (host
// swap < the guest's RAM) told every household with a guest above 8 GB to call us; the 8 GB soak's burst used 2.7 GB.
// The CONTRACT is testdata/host_swap_rule_cases.json, byte-identical in felhom.eu's hub (felhom.eu
// scripts/host_swap_rule_gate.py); TestR1216_HostSwapRuleCases runs it here.
func hostSwapRuleShort(hostMB, guestMB int64) bool {
if hostMB <= 0 {
return true
}
need := guestMB
if need > installerMaxSwapMB {
need = installerMaxSwapMB
}
if need <= 0 {
return false
}
return hostMB*10 < need*9
}
@@ -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}
]
}