Files
felhom-controller/controller/internal/web/system_memory_handlers.go
T

250 lines
10 KiB
Go

package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// Guest RAM resize UI (v0.143.0, R-24; coupled to agent ≥ 0.90.0). The customer sees the guest's
// current memory + the allowed range on the Rendszer settings page and resizes it; the request goes
// to the agent's self-scoped /guest/memory, which enforces every bound. The controller only maps the
// agent's machine `code` to a Hungarian message — the agent's English text is never shown raw.
// memAgent is the agent surface the memory UI needs (seam — tests inject a fake; production is the
// shared *agentapi.Client). It carries NetVerifyStatus + AgentVersion so it satisfies the shared
// SupportProber / version fast-path used by the capability cache (the agent has them anyway).
type memAgent interface {
GuestMemory(ctx context.Context) (agentapi.GuestMemoryInfo, error)
ResizeMemory(ctx context.Context, memoryMB int64) (agentapi.MemoryResizeResult, error)
NetVerifyStatus(ctx context.Context) (agentapi.NetVerifyStatus, error) // to satisfy SupportProber
AgentVersion() string // version fast-path
}
func (s *Server) memAgentForResize() (memAgent, error) {
if s.memAgentFn != nil {
return s.memAgentFn()
}
c, err := s.agentClient()
if err != nil {
return nil, err
}
return c, nil
}
// memUpdateNeededMsg is shown when the agent predates the resize endpoints (pre-0.90).
const memUpdateNeededMsg = "A memória átméretezéséhez a kiszolgáló rendszerfrissítése szükséges."
// memAgentDownMsg is shown when the agent is unreachable — the value falls back to /proc/meminfo.
const memAgentDownMsg = "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később."
// memoryResizeMessage maps the agent's machine code (or success) to the customer's Hungarian line.
func memoryResizeMessage(code string, res agentapi.MemoryResizeResult, bounds agentapi.GuestMemoryInfo) string {
switch code {
case "": // success
if res.Unchanged {
return fmt.Sprintf("A memória változatlan maradt: %d MB.", res.NewMB)
}
return fmt.Sprintf("A memória átméretezése megtörtént: %d MB → %d MB.", res.OldMB, res.NewMB)
case "below_usage_floor":
return fmt.Sprintf("A kért méret túl közel van a jelenlegi felhasználáshoz (%d MB). Állíts le néhány alkalmazást, majd próbáld újra.", bounds.UsageMB)
case "below_min":
return fmt.Sprintf("A minimális memória %d MB.", bounds.MinMB)
case "above_max":
return fmt.Sprintf("A megengedett maximum %d MB (a gazdagép tartaléka miatt).", bounds.MaxMB)
default:
return "A memória átméretezése nem sikerült. Próbáld meg később."
}
}
// memoryCardData fills the settings-page memory card. It never returns an error — every failure mode
// degrades to an honest, render-safe state (supported=false, reachable=false + /proc/meminfo value).
func (s *Server) memoryCardData(data map[string]interface{}) {
data["MemorySupported"] = true // default; flipped to false only on a definite SupportNo
data["MemoryReachable"] = false
data["MemoryProcMB"] = procMemTotalMB() // in-guest fallback (this container's own /proc/meminfo)
agent, err := s.memAgentForResize()
if err != nil {
return // agent not configured/reachable → reachable=false, proc fallback rendered
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Capability gate: only a DEFINITE SupportNo hides the control (SupportUnknown from a down agent
// keeps it, with the honest unreachable note).
if s.memFeatures.Supports(ctx, agent, agentapi.FeatureGuestMemoryResize) == agentapi.SupportNo {
data["MemorySupported"] = false
return
}
info, err := agent.GuestMemory(ctx)
if err != nil {
logx.Debugf(s.logger, "[web] memory card: GuestMemory failed: %v", err)
return // reachable=false; the proc fallback shows the current size
}
data["MemoryReachable"] = true
data["MemoryAllocatedMB"] = info.AllocatedMB
data["MemoryUsageMB"] = info.UsageMB
data["MemoryMinMB"] = info.MinMB
data["MemoryMaxMB"] = info.MaxMB
data["MemoryFloorMB"] = info.FloorMB
data["MemoryHostTotalMB"] = info.HostTotalMB
}
// ServeSystemAPI dispatches /api/system/* (currently the guest-memory resize surface).
func (s *Server) ServeSystemAPI(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/system/memory" && r.Method == http.MethodGet:
s.handleMemoryGet(w, r)
case r.URL.Path == "/api/system/memory/resize" && r.Method == http.MethodPost:
s.handleMemoryResize(w, r)
default:
writeDiskJSON(w, http.StatusNotFound, false, "ismeretlen végpont", nil)
}
}
// handleMemoryGet returns the current allocation + bounds (the JS refreshes the card after a resize).
func (s *Server) handleMemoryGet(w http.ResponseWriter, r *http.Request) {
agent, err := s.memAgentForResize()
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, memAgentDownMsg, nil)
return
}
if s.memFeatures.Supports(r.Context(), agent, agentapi.FeatureGuestMemoryResize) == agentapi.SupportNo {
writeDiskJSON(w, http.StatusPreconditionFailed, false, memUpdateNeededMsg, map[string]any{"code": "agent_outdated"})
return
}
info, err := agent.GuestMemory(r.Context())
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, memAgentDownMsg, nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", info)
}
// handleMemoryResize validates nothing itself (the agent is the authority) beyond parsing, gates on
// capability, calls the agent, and maps the outcome to a Hungarian message + machine code.
func (s *Server) handleMemoryResize(w http.ResponseWriter, r *http.Request) {
var req struct {
MemoryMB int64 `json:"memory_mb"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MemoryMB <= 0 {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen memória érték", nil)
return
}
agent, err := s.memAgentForResize()
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, memAgentDownMsg, nil)
return
}
// Capability gate (publish-train backstop): refuse honestly on an agent that predates the resize.
support, source := s.memFeatures.SupportsWithSource(r.Context(), agent, agentapi.FeatureGuestMemoryResize)
logx.Debugf(s.logger, "[web] memory resize gate: %s=%s (source=%s)", agentapi.FeatureGuestMemoryResize, support, source)
if support == agentapi.SupportNo {
writeDiskJSON(w, http.StatusPreconditionFailed, false, memUpdateNeededMsg, map[string]any{"code": "agent_outdated"})
return
}
res, err := agent.ResizeMemory(r.Context(), req.MemoryMB)
if err != nil {
var refused *agentapi.MemoryRefusedError
if errors.As(err, &refused) {
s.logger.Printf("[INFO] [web] memory resize refused: code=%s target=%d", refused.Code, req.MemoryMB)
writeDiskJSON(w, http.StatusPreconditionFailed, false,
memoryResizeMessage(refused.Code, agentapi.MemoryResizeResult{}, refused.Bounds),
map[string]any{"code": refused.Code})
return
}
logx.Debugf(s.logger, "[web] memory resize agent error: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, "A memória átméretezése nem sikerült — próbáld meg később.", nil)
return
}
s.logger.Printf("[INFO] [web] memory resized: %d MB -> %d MB (unchanged=%v)", res.OldMB, res.NewMB, res.Unchanged)
writeDiskJSON(w, http.StatusOK, true, memoryResizeMessage("", res, agentapi.GuestMemoryInfo{}),
map[string]any{"old_mb": res.OldMB, "new_mb": res.NewMB, "unchanged": res.Unchanged})
}
// procMemTotalMB reads MemTotal from this container's /proc/meminfo (the agent-unreachable fallback).
// Returns 0 if unreadable (the template renders "n/a" then).
func procMemTotalMB() int64 {
raw, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
f := strings.Fields(line)
if len(f) >= 2 {
if kb, err := strconv.ParseInt(f[1], 10, 64); err == nil {
return kb / 1024 // kB → MB
}
}
}
}
return 0
}
// 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 {
agent, err := s.memAgentForResize()
if err != nil || agent == nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
info, err := agent.GuestMemory(ctx)
if err != nil {
return false
}
return swapShort(info)
}
// 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 {
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
}