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 }