package api import ( "encoding/json" "fmt" ) // R-1192 follow-up (night 2026-10-21). The guest swap fix (agent: every guest may swap as much as its RAM) moves a // memory burst onto the HOST's swap. Our installer gives an 8 GB machine 7.75 GB of it (measured on demo-hp); a host // with none — a BYO install, or swap turned off — gets nothing from the fix, and the household's first big upload can // freeze the box again. The agent reports both numbers; the hub tells the OPERATOR once per host when the host's swap // is below its guests' allowance (host_swap_short), and once when it is covered again (host_swap_ok). Edge-triggered // from the host's own newest swap event, so a hub restart does not mail again. Operator-only (notify.operatorOnlyEvents): // the household cannot add swap to its host. const ( eventHostSwapShort = "host_swap_short" eventHostSwapOK = "host_swap_ok" ) // 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 on 2026-10-22. const installerMaxSwapMB = 8192 // hostSwapShort is the R-1216 rule (D28 = A, operator 2026-10-22): a host is short of swap when it has none, or when // its swap is below 90 % of the smaller of its guests' swap allowance and what the installer gives. Because the guests // fit in the host's RAM, „what the installer gives a machine of that RAM" is never smaller than the guests' allowance // below 8 GB — so the host's RAM is not needed. The morning's first rule (host swap < the guests' whole allowance) // flagged every machine with a guest above 8 GB; the 8 GB soak's burst used 2.7 GB. // The CONTRACT is testdata/host_swap_rule_cases.json, byte-identical in felhom-controller (the install page's sentence) // — felhom.eu scripts/host_swap_rule_gate.py; TestR1216_HostSwapRuleCases runs it here. func hostSwapShort(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 } func (h *Handler) checkHostSwap(hostID, custID string, hostMB, guestMB *int64) { if hostMB == nil || guestMB == nil || custID == "" { return // an older agent: not evaluated } short := hostSwapShort(*hostMB, *guestMB) prev, err := h.store.LatestHostSwapEvent(custID, hostID) if err != nil { h.logger.Printf("[WARN] host %s swap check: reading the last swap event failed: %v", hostID, err) return } wasShort := prev != nil && prev.EventType == eventHostSwapShort var typ, sev, msg string switch { case short && !wasShort: typ, sev = eventHostSwapShort, "warning" msg = fmt.Sprintf("Host %s: its swap (%d MB) is too small for its guests' swap allowance (%d MB) — below what our installer "+ "would give (the guests' allowance, at most 8192 MB). The guest swap fix needs swap on the host behind it; without it a "+ "memory burst — a big first upload on an 8 GB box — can freeze the box for minutes (R-1192). Add swap on the host or lower "+ "the guest's RAM. The household cannot fix this.", hostID, *hostMB, *guestMB) case !short && wasShort: typ, sev = eventHostSwapOK, "info" msg = fmt.Sprintf("Host %s: its swap (%d MB) is enough for its guests' swap allowance (%d MB) again.", hostID, *hostMB, *guestMB) default: return } h.logger.Printf("[WARN] host %s: %s (host swap %d MB, guests' swap %d MB)", hostID, typ, *hostMB, *guestMB) details, _ := json.Marshal(map[string]any{"host_id": hostID, "host_swap_mb": *hostMB, "guest_swap_mb": *guestMB}) if _, eerr := h.store.SaveEvent(custID, typ, sev, msg, string(details), "hub"); eerr != nil { h.logger.Printf("[WARN] %s event save FAILED for %s: %v", typ, hostID, eerr) return // the audit row first: a mail without its event row lies } if h.dispatcher != nil { go h.dispatcher.ProcessEvent(custID, typ, sev, msg, string(details), "hub") } }