From 3286c7faaf80dc00a3a1d91e784b11a04eead538 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 17 Jul 2026 19:10:00 +0200 Subject: [PATCH] =?UTF-8?q?v0.143.0=20=E2=80=94=20guest=20RAM=20resize=20U?= =?UTF-8?q?I=20(R-24)=20=E2=80=94=20MinAgent:=200.90.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customer sees the guest's current memory + allowed range on the Rendszer settings page and resizes it. The controller proxies + maps the agent's machine code to Hungarian; the agent (felhom-agent v0.90.0) enforces every bound and applies live (no reboot). agentapi: GuestMemory + ResizeMemory; ruled 412 -> *MemoryRefusedError (code+bounds); pre-0.90 agent 404 -> typed *StatusError. Capability: FeatureGuestMemoryResize + featureMinAgent 0.90.0 + a featureProbes row (type-asserts GuestMemory so the shared SupportProber/netAgent are untouched). UI (internal/web/system_memory_handlers.go, settings_system.html): "Szerver memoria (RAM)" card + number input; POST /api/system/memory/resize -> capability gate -> agent -> flash. JS confirm only on shrink. Code->Hungarian map; agent-outdated hides the control; agent-unreachable falls back to the guest's /proc/meminfo. Agent English never shown raw. Tests: agentapi (decode, 404, refusal-code, capability table) + web handler (success/ below_usage_floor/agent_outdated). Gates + go build/vet/test all pass. --- CHANGELOG.md | 30 +++ controller/cmd/controller/main.go | 2 + controller/internal/agentapi/client.go | 95 +++++++++ controller/internal/agentapi/features.go | 26 ++- .../internal/agentapi/guestmemory_test.go | 132 ++++++++++++ controller/internal/web/handlers.go | 2 + controller/internal/web/server.go | 5 + .../internal/web/system_memory_handlers.go | 194 ++++++++++++++++++ controller/internal/web/system_memory_test.go | 112 ++++++++++ .../web/templates/settings_system.html | 91 ++++++++ 10 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 controller/internal/agentapi/guestmemory_test.go create mode 100644 controller/internal/web/system_memory_handlers.go create mode 100644 controller/internal/web/system_memory_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f34a6d..026e274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ ## Changelog +### v0.143.0 — guest RAM resize UI (R-24) — MinAgent: 0.90.0 (2026-07-17) + +The customer sees the guest's current memory + the allowed range on the **Rendszer** settings page and +resizes it. The controller only proxies + maps the agent's machine `code` to Hungarian — the AGENT +(felhom-agent v0.90.0) enforces every bound and applies the change live (no reboot). Memory only. + +- **agentapi (`internal/agentapi/client.go`):** `GuestMemory(ctx)` (GET /guest/memory) and + `ResizeMemory(ctx, mb)` (POST /guest/memory). A ruled 412 refusal surfaces `*MemoryRefusedError` + carrying the machine code (below_min/above_max/below_usage_floor) + fresh bounds; a pre-0.90 agent + 404s → the typed `*StatusError{404}` (the capability signal). +- **Capability (`internal/agentapi/features.go`):** `FeatureGuestMemoryResize` + `featureMinAgent` + **0.90.0** + a `featureProbes` row (GET /guest/memory is the probe; the probe type-asserts the one + method it needs, so the shared `SupportProber`/`netAgent` stay untouched). Per the publish-train + convention (this CHANGELOG declares MinAgent; the Supports gate sits at the handler entry point). +- **UI (`internal/web/system_memory_handlers.go`, new; `templates/settings_system.html`):** a "Szerver + memória (RAM)" card shows current/used memory + the `2048 MB – {max} MB` range; a number input + (step 256) + "Átméretezés". `POST /api/system/memory/resize` → capability gate (SupportUnknown passes) + → agent → POST-response flash. A JS confirm fires ONLY on a shrink. Code→Hungarian map: success + "A memória átméretezése megtörtént: X MB → Y MB."; below_usage_floor "…túl közel van a jelenlegi + felhasználáshoz (N MB). Állíts le néhány alkalmazást…"; below_min / above_max; agent-outdated → the + control is not offered + a "rendszerfrissítése szükséges" note; agent-unreachable → the value falls + back to the guest's own `/proc/meminfo`, control disabled, honest note (the page never 500s). The + agent's English message is never shown raw. +- **Ripple (no code):** lxcfs updates the guest `/proc/meminfo` live, so the deploy-page memory math + follows a resize automatically. +- **Tests:** agentapi (GuestMemory decode, 404-typed, ResizeMemory success + refusal-code, capability + table 0.89→No / 0.90→Yes / probe 404→No / probe ok→Yes) + web handler (success maps to Hungarian; + below_usage_floor maps + the agent English never leaks; agent_outdated gate refuses with ResizeMemory + never called). Gates: template_id + emoji OK; `go build/vet/test` all pass. + ### v0.142.0 — offsite repo continuity: orphaned-repo guard (A) + run-status auto-refresh (C) (2026-07-17) Closes the reinstall-orphaned-repo incident class (`memory` offbox-repo-orphaned-2026-07-17): a diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 71a4418..4b96668 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1009,6 +1009,8 @@ func main() { mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI)))) // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry). mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI)))) + // Guest RAM resize (v0.143.0, R-24): read current allocation/bounds + apply a bounded resize. + mux.Handle("/api/system/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeSystemAPI)))) // Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance, // a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive. mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot)))) diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 49336fe..8105ffe 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -454,6 +454,101 @@ func (c *Client) GuestReboot(ctx context.Context) error { return err } +// ---- v0.143.0: guest RAM resize (R-24, agent ≥ 0.90.0) ------------------------------------ + +// GuestMemoryInfo mirrors the agent's GET /guest/memory (every field MB, agent-computed). The +// min/max/floor are the CURRENTLY-enforced bounds — the UI renders them but the agent re-checks fresh. +type GuestMemoryInfo struct { + VMID int `json:"vmid"` + AllocatedMB int64 `json:"allocated_mb"` + UsageMB int64 `json:"usage_mb"` + HostTotalMB int64 `json:"host_total_mb"` + MinMB int64 `json:"min_mb"` + MaxMB int64 `json:"max_mb"` + FloorMB int64 `json:"floor_mb"` + Running bool `json:"running"` +} + +// MemoryResizeResult mirrors the agent's POST /guest/memory success body. +type MemoryResizeResult struct { + VMID int `json:"vmid"` + OldMB int64 `json:"old_mb"` + NewMB int64 `json:"new_mb"` + Unchanged bool `json:"unchanged"` +} + +// MemoryRefusedError carries the agent's machine refusal code (below_min | above_max | +// below_usage_floor) plus the fresh bounds, so the web layer maps it to a Hungarian message and +// re-renders the range honestly — the agent's English message is never shown raw. +type MemoryRefusedError struct { + Code string + Bounds GuestMemoryInfo + Msg string +} + +func (e *MemoryRefusedError) Error() string { + return "agentapi: memory resize refused (" + e.Code + "): " + e.Msg +} + +// GuestMemory reads the guest's current allocation, live usage, and the enforced bounds. A pre-0.90 +// agent has no such route → the get helper returns *StatusError{404} (the capability probe signal +// and the UI's "needs an update" path). +func (c *Client) GuestMemory(ctx context.Context) (GuestMemoryInfo, error) { + var out GuestMemoryInfo + data, err := c.get(ctx, "/guest/memory") + if err != nil { + return out, err + } + if err := json.Unmarshal(data, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /guest/memory: %w", err) + } + return out, nil +} + +// ResizeMemory requests a bounded resize. The agent enforces every bound; a ruled refusal (412) +// returns *MemoryRefusedError carrying the code + fresh bounds; a non-coded failure (e.g. the 502 +// verify-after-apply) returns a plain error; success returns old→new. +func (c *Client) ResizeMemory(ctx context.Context, memoryMB int64) (MemoryResizeResult, error) { + var out MemoryResizeResult + env, status, err := c.postWithStatus(ctx, "/guest/memory", map[string]int64{"memory_mb": memoryMB}) + if err != nil { + return out, err + } + if status == http.StatusOK && env.OK { + if len(env.Data) > 0 { + _ = json.Unmarshal(env.Data, &out) + } + return out, nil + } + // Refusal — the data carries {code, ...fresh bounds}. + var ref struct { + Code string `json:"code"` + AllocatedMB int64 `json:"allocated_mb"` + UsageMB int64 `json:"usage_mb"` + HostTotalMB int64 `json:"host_total_mb"` + MinMB int64 `json:"min_mb"` + MaxMB int64 `json:"max_mb"` + FloorMB int64 `json:"floor_mb"` + } + if len(env.Data) > 0 { + _ = json.Unmarshal(env.Data, &ref) + } + if ref.Code != "" { + return out, &MemoryRefusedError{ + Code: ref.Code, + Bounds: GuestMemoryInfo{ + AllocatedMB: ref.AllocatedMB, UsageMB: ref.UsageMB, HostTotalMB: ref.HostTotalMB, + MinMB: ref.MinMB, MaxMB: ref.MaxMB, FloorMB: ref.FloorMB, + }, + Msg: truncateErr(env.Error, 300), + } + } + if rerr := refusalError("/guest/memory", status, env); rerr != nil { + return out, rerr + } + return out, nil +} + // SwapResult mirrors the agent's 202 from POST /controller/swap (agentic controller update, Phase 1). type SwapResult struct { Status string `json:"status"` // "swapping" diff --git a/controller/internal/agentapi/features.go b/controller/internal/agentapi/features.go index de289a5..85fac1a 100644 --- a/controller/internal/agentapi/features.go +++ b/controller/internal/agentapi/features.go @@ -25,6 +25,10 @@ type Feature string // capability signal. const FeatureNetstorageVerify Feature = "netstorage_verify" +// FeatureGuestMemoryResize is the guest RAM resize (agent v0.90.0, R-24): the resize endpoints +// (GET/POST /guest/memory) shipped together, so GET /guest/memory IS the capability signal. +const FeatureGuestMemoryResize Feature = "guest_memory_resize" + // SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never // refuses — the existing agent-error paths speak honestly when the agent is down). type SupportState int @@ -67,14 +71,34 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error _, err := p.NetVerifyStatus(ctx) return err }, + // The memory-resize prober needs GET /guest/memory, not NetVerifyStatus. Rather than couple the + // shared SupportProber (and every unrelated prober/fake) to the memory surface, the probe + // type-asserts the ONE method it needs — the memory feature is only ever probed with a + // GuestMemory-capable prober (the web memAgent seam / *Client). A prober without it → a non-404 + // error → SupportUnknown (fail-open), never a false "supported". + FeatureGuestMemoryResize: func(ctx context.Context, p SupportProber) error { + gm, ok := p.(interface { + GuestMemory(ctx context.Context) (GuestMemoryInfo, error) + }) + if !ok { + return errNoMemoryProbe + } + _, err := gm.GuestMemory(ctx) + return err + }, } +// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be +// asked never reads as "unsupported". +var errNoMemoryProbe = errors.New("agentapi: prober does not support the guest-memory probe") + // featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled // semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is // KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A // feature missing here (or an unparseable table value) falls back to the probe. var featureMinAgent = map[Feature]string{ - FeatureNetstorageVerify: "0.81.0", + FeatureNetstorageVerify: "0.81.0", + FeatureGuestMemoryResize: "0.90.0", } // AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports diff --git a/controller/internal/agentapi/guestmemory_test.go b/controller/internal/agentapi/guestmemory_test.go new file mode 100644 index 0000000..7cd750f --- /dev/null +++ b/controller/internal/agentapi/guestmemory_test.go @@ -0,0 +1,132 @@ +package agentapi + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// memProber satisfies the SupportProber contract (NetVerifyStatus) PLUS the memory probe's +// type-asserted GuestMemory + the AgentVersion fast-path — the shape the web memAgent seam has. +type memProber struct { + memErr error + ver string +} + +func (p *memProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) { + return NetVerifyStatus{Phase: "none"}, nil +} +func (p *memProber) GuestMemory(context.Context) (GuestMemoryInfo, error) { + return GuestMemoryInfo{AllocatedMB: 8192}, p.memErr +} +func (p *memProber) AgentVersion() string { return p.ver } + +// The capability table: v0.90.0 → Yes, v0.89.0 → No via the version fast-path; the probe (no +// version) classifies 404 → No, nil → Yes. +func TestGuestMemory_Capability(t *testing.T) { + t.Run("version >= 0.90 → Yes", func(t *testing.T) { + var sc SupportCache + if got := sc.Supports(context.Background(), &memProber{ver: "0.90.0"}, FeatureGuestMemoryResize); got != SupportYes { + t.Errorf("v0.90.0 = %v, want SupportYes", got) + } + }) + t.Run("version < 0.90 → No", func(t *testing.T) { + var sc SupportCache + if got := sc.Supports(context.Background(), &memProber{ver: "0.89.0"}, FeatureGuestMemoryResize); got != SupportNo { + t.Errorf("v0.89.0 = %v, want SupportNo", got) + } + }) + t.Run("no version, probe 404 → No", func(t *testing.T) { + var sc SupportCache + p := &memProber{memErr: &StatusError{Path: "/guest/memory", Code: http.StatusNotFound}} + if got := sc.Supports(context.Background(), p, FeatureGuestMemoryResize); got != SupportNo { + t.Errorf("probe 404 = %v, want SupportNo", got) + } + }) + t.Run("no version, probe ok → Yes", func(t *testing.T) { + var sc SupportCache + if got := sc.Supports(context.Background(), &memProber{}, FeatureGuestMemoryResize); got != SupportYes { + t.Errorf("probe ok = %v, want SupportYes", got) + } + }) +} + +// GuestMemory decodes the agent's payload; a 404 (pre-0.90) is the typed *StatusError. +func TestClient_GuestMemory(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(405) + return + } + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"allocated_mb":8192,"usage_mb":3000,"host_total_mb":16384,"min_mb":2048,"max_mb":14336,"floor_mb":3512,"running":true}}`)) + }) + c := newMemTestClient(t, mux) + info, err := c.GuestMemory(context.Background()) + if err != nil { + t.Fatalf("GuestMemory: %v", err) + } + if info.AllocatedMB != 8192 || info.UsageMB != 3000 || info.MaxMB != 14336 || info.FloorMB != 3512 || !info.Running { + t.Errorf("decoded wrong: %+v", info) + } +} + +func TestClient_GuestMemory_404(t *testing.T) { + c := newMemTestClient(t, http.NewServeMux()) // no route → 404 + _, err := c.GuestMemory(context.Background()) + var se *StatusError + if !errors.As(err, &se) || se.Code != http.StatusNotFound { + t.Fatalf("pre-0.90 agent must 404 as *StatusError, got %T: %v", err, err) + } +} + +// ResizeMemory: success returns old→new; a 412 refusal surfaces *MemoryRefusedError with the code. +func TestClient_ResizeMemory(t *testing.T) { + t.Run("success", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"old_mb":8192,"new_mb":12288,"unchanged":false}}`)) + }) + c := newMemTestClient(t, mux) + res, err := c.ResizeMemory(context.Background(), 12288) + if err != nil { + t.Fatalf("ResizeMemory: %v", err) + } + if res.OldMB != 8192 || res.NewMB != 12288 { + t.Errorf("result = %+v", res) + } + }) + t.Run("refusal carries the code", func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPreconditionFailed) + _, _ = w.Write([]byte(`{"ok":false,"error":"requested 3300 MB is too close to current usage 3000 MB (floor 3512 MB)","data":{"code":"below_usage_floor","usage_mb":3000,"floor_mb":3512,"min_mb":2048,"max_mb":14336}}`)) + }) + c := newMemTestClient(t, mux) + _, err := c.ResizeMemory(context.Background(), 3300) + var refused *MemoryRefusedError + if !errors.As(err, &refused) { + t.Fatalf("want *MemoryRefusedError, got %T: %v", err, err) + } + if refused.Code != "below_usage_floor" || refused.Bounds.UsageMB != 3000 || refused.Bounds.FloorMB != 3512 { + t.Errorf("refusal = %+v", refused) + } + }) +} + +func newMemTestClient(t *testing.T, mux *http.ServeMux) *Client { + t.Helper() + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + fp := sha256.Sum256(srv.Certificate().Raw) + c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:])) + if err != nil { + t.Fatalf("New: %v", err) + } + return c +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ff85a13..a621415 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1198,6 +1198,8 @@ func (s *Server) systemPageData() map[string]interface{} { // to. Empty = none set by the operator. data["ControllerFloor"] = s.updater.GetFloor() } + // Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability. + s.memoryCardData(data) return data } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index a72ce9b..e32715f 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -109,6 +109,11 @@ type Server struct { // semantics — the add gate + the settings-page banner read it. Zero value ready. netFeatures agentapi.SupportCache + // memFeatures caches the guest-memory-resize capability probe (agent v0.90.0, R-24) — the resize + // handler gate + the settings-page render read it. Zero value ready. memAgentFn is the test seam. + memFeatures agentapi.SupportCache + memAgentFn func() (memAgent, error) + // Asset syncer for Hub-managed assets (optional) assetsSyncer *assets.Syncer diff --git a/controller/internal/web/system_memory_handlers.go b/controller/internal/web/system_memory_handlers.go new file mode 100644 index 0000000..2d978ec --- /dev/null +++ b/controller/internal/web/system_memory_handlers.go @@ -0,0 +1,194 @@ +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 +} diff --git a/controller/internal/web/system_memory_test.go b/controller/internal/web/system_memory_test.go new file mode 100644 index 0000000..f652f42 --- /dev/null +++ b/controller/internal/web/system_memory_test.go @@ -0,0 +1,112 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" +) + +// fakeMemAgent is the memAgent seam for handler tests. AgentVersion drives the capability gate +// (version fast-path); NetVerifyStatus is the SupportProber stub. +type fakeMemAgent struct { + info agentapi.GuestMemoryInfo + resizeRes agentapi.MemoryResizeResult + resizeErr error + ver string + calls int +} + +func (f *fakeMemAgent) GuestMemory(context.Context) (agentapi.GuestMemoryInfo, error) { + return f.info, nil +} +func (f *fakeMemAgent) ResizeMemory(_ context.Context, _ int64) (agentapi.MemoryResizeResult, error) { + f.calls++ + return f.resizeRes, f.resizeErr +} +func (f *fakeMemAgent) NetVerifyStatus(context.Context) (agentapi.NetVerifyStatus, error) { + return agentapi.NetVerifyStatus{Phase: "none"}, nil +} +func (f *fakeMemAgent) AgentVersion() string { return f.ver } + +func postResize(t *testing.T, s *Server, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodPost, "/api/system/memory/resize", strings.NewReader(body)) + w := httptest.NewRecorder() + s.ServeSystemAPI(w, r) + return w +} + +// Success → 200 with the Hungarian old→new message. +func TestMemoryResize_Success(t *testing.T) { + s := testServer(t) + agent := &fakeMemAgent{ver: "0.90.0", resizeRes: agentapi.MemoryResizeResult{OldMB: 8192, NewMB: 12288}} + s.memAgentFn = func() (memAgent, error) { return agent, nil } + + w := postResize(t, s, `{"memory_mb":12288}`) + if w.Code != http.StatusOK { + t.Fatalf("resize = %d (%s), want 200", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "A memória átméretezése megtörtént: 8192 MB → 12288 MB.") { + t.Errorf("success message missing: %s", w.Body.String()) + } + if agent.calls != 1 { + t.Errorf("ResizeMemory calls = %d, want 1", agent.calls) + } +} + +// below_usage_floor refusal → 412, the mapped Hungarian message, and the machine code (agent English +// never shown raw). +func TestMemoryResize_BelowUsageFloor(t *testing.T) { + s := testServer(t) + agent := &fakeMemAgent{ + ver: "0.90.0", + resizeErr: &agentapi.MemoryRefusedError{ + Code: "below_usage_floor", + Bounds: agentapi.GuestMemoryInfo{UsageMB: 3000, FloorMB: 3512, MinMB: 2048, MaxMB: 14336}, + Msg: "requested 3300 MB is too close to current usage 3000 MB (floor 3512 MB)", + }, + } + s.memAgentFn = func() (memAgent, error) { return agent, nil } + + w := postResize(t, s, `{"memory_mb":3300}`) + if w.Code != http.StatusPreconditionFailed { + t.Fatalf("below_usage_floor = %d (%s), want 412", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"code":"below_usage_floor"`) { + t.Errorf("machine code missing: %s", body) + } + if !strings.Contains(body, "túl közel van a jelenlegi felhasználáshoz (3000 MB)") { + t.Errorf("mapped Hungarian message missing: %s", body) + } + // The agent's raw English must NEVER surface. + if strings.Contains(body, "requested 3300 MB is too close") { + t.Errorf("agent English leaked to the customer: %s", body) + } +} + +// A pre-0.90 agent (version < MinAgent) → the capability gate refuses up front with agent_outdated, +// and ResizeMemory is NEVER called. +func TestMemoryResize_AgentOutdated(t *testing.T) { + s := testServer(t) + agent := &fakeMemAgent{ver: "0.89.0"} + s.memAgentFn = func() (memAgent, error) { return agent, nil } + + w := postResize(t, s, `{"memory_mb":12288}`) + if w.Code != http.StatusPreconditionFailed { + t.Fatalf("outdated agent = %d (%s), want 412", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"code":"agent_outdated"`) { + t.Errorf("agent_outdated code missing: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "rendszerfrissítése szükséges") { + t.Errorf("outdated Hungarian note missing: %s", w.Body.String()) + } + if agent.calls != 0 { + t.Errorf("ResizeMemory called %d times on a gated request — must be 0", agent.calls) + } +} diff --git a/controller/internal/web/templates/settings_system.html b/controller/internal/web/templates/settings_system.html index 1424ff4..afe8129 100644 --- a/controller/internal/web/templates/settings_system.html +++ b/controller/internal/web/templates/settings_system.html @@ -143,6 +143,48 @@ +
+

Szerver memória (RAM)

+ {{if .MemorySupported}} + {{if .MemoryReachable}} +
+
+ Jelenlegi memória + {{.MemoryAllocatedMB}} MB +
+
+ Felhasznált + {{.MemoryUsageMB}} MB +
+
+ Megengedett tartomány + {{.MemoryMinMB}} MB – {{.MemoryMaxMB}} MB +
+
+ Új méret (MB) + + + + + +
+
+ {{else}} +
+
+ Jelenlegi memória + {{if .MemoryProcMB}}{{.MemoryProcMB}} MB{{else}}n/a{{end}} +
+
+

A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.

+ {{end}} + {{else}} +

A memória átméretezéséhez a kiszolgáló rendszerfrissítése szükséges.

+ {{end}} +
+